Merge branches 'develop' and 'travis/download-logs' of github.com:matrix-org/matrix-react-sdk into travis/download-logs

 Conflicts:
	src/i18n/strings/en_EN.json
	src/rageshake/submit-rageshake.ts
This commit is contained in:
Michael Telatynski 2020-07-21 22:22:28 +01:00
commit 70c81cc377
1182 changed files with 47388 additions and 27457 deletions

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

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

78
src/@types/global.d.ts vendored Normal file
View file

@ -0,0 +1,78 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * 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";
import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {ModalManager} from "../Modal";
declare global {
interface Window {
Modernizr: ModernizrStatic;
mxMatrixClientPeg: IMatrixClientPeg;
Olm: {
init: () => Promise<void>;
};
mxContentMessages: ContentMessages;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRebrandListener: RebrandListener;
mxRoomListStore: RoomListStoreClass;
mxRoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;
singletonModalManager: ModalManager;
}
// workaround for https://github.com/microsoft/TypeScript/issues/30933
interface ObjectConstructor {
fromEntries?(xs: [string|number|symbol, any][]): object;
}
interface Document {
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
hasStorageAccess?: () => Promise<boolean>;
}
interface Navigator {
userLanguage?: string;
}
interface StorageEstimate {
usageDetails?: {[key: string]: number};
}
export interface ISettledFulfilled<T> {
status: "fulfilled";
value: T;
}
export interface ISettledRejected {
status: "rejected";
reason: any;
}
interface PromiseConstructor {
allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>;
}
}

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

@ -0,0 +1,38 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
export function polyfillTouchEvent() {
// Firefox doesn't have touch events without touch devices being present, so create a fake
// one we can rely on lying about.
if (!window.TouchEvent) {
// We have no intention of actually using this, so just lie.
window.TouchEvent = class TouchEvent extends UIEvent {
public get altKey(): boolean { return false; }
public get changedTouches(): any { return []; }
public get ctrlKey(): boolean { return false; }
public get metaKey(): boolean { return false; }
public get shiftKey(): boolean { return false; }
public get targetTouches(): any { return []; }
public get touches(): any { return []; }
public get rotation(): number { return 0.0; }
public get scale(): number { return 0.0; }
constructor(eventType: string, params?: any) {
super(eventType, params);
}
};
}
}

View file

@ -27,7 +27,7 @@ import RoomViewStore from './stores/RoomViewStore';
*/
class ActiveRoomObserver {
constructor() {
this._listeners = {};
this._listeners = {}; // key=roomId, value=function(isActive:boolean)
this._activeRoomId = RoomViewStore.getRoomId();
// TODO: We could self-destruct when the last listener goes away, or at least
@ -35,6 +35,10 @@ class ActiveRoomObserver {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
}
get activeRoomId(): string {
return this._activeRoomId;
}
addListener(roomId, listener) {
if (!this._listeners[roomId]) this._listeners[roomId] = [];
this._listeners[roomId].push(listener);
@ -51,23 +55,23 @@ class ActiveRoomObserver {
}
}
_emit(roomId) {
_emit(roomId, isActive: boolean) {
if (!this._listeners[roomId]) return;
for (const l of this._listeners[roomId]) {
l.call();
l.call(null, isActive);
}
}
_onRoomViewStoreUpdate() {
// emit for the old room ID
if (this._activeRoomId) this._emit(this._activeRoomId);
if (this._activeRoomId) this._emit(this._activeRoomId, false);
// update our cache
this._activeRoomId = RoomViewStore.getRoomId();
// and emit for the new one
if (this._activeRoomId) this._emit(this._activeRoomId);
if (this._activeRoomId) this._emit(this._activeRoomId, true);
}
}

View file

@ -21,6 +21,7 @@ import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
import IdentityAuthClient from './IdentityAuthClient';
import {SSOAuthEntry} from "./components/views/auth/InteractiveAuthEntryComponents";
function getIdServerDomain() {
return MatrixClientPeg.get().idBaseUrl.split("://")[1];
@ -188,11 +189,31 @@ export default class AddThreepid {
// pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("Confirm adding this email address by using " +
"Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm adding email"),
body: _t("Click the button below to confirm adding this email address."),
continueText: _t("Confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createTrackedDialog('Add Email', '', InteractiveAuthDialog, {
title: _t("Add Email Address"),
matrixClient: MatrixClientPeg.get(),
authData: e.data,
makeRequest: this._makeAddThreepidOnlyRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
return finished;
}
@ -285,11 +306,30 @@ export default class AddThreepid {
// pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("Confirm adding this phone number by using " +
"Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm adding phone number"),
body: _t("Click the button below to confirm adding this phone number."),
continueText: _t("Confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createTrackedDialog('Add MSISDN', '', InteractiveAuthDialog, {
title: _t("Add Phone Number"),
matrixClient: MatrixClientPeg.get(),
authData: e.data,
makeRequest: this._makeAddThreepidOnlyRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
return finished;
}

View file

@ -66,7 +66,10 @@ const customVariables = {
},
'App Version': {
id: 2,
expl: _td('The version of Riot'),
expl: _td('The version of %(brand)s'),
getTextVariables: () => ({
brand: SdkConfig.get().brand,
}),
example: '15.0.0',
},
'User Type': {
@ -96,7 +99,10 @@ const customVariables = {
},
'Touch Input': {
id: 8,
expl: _td("Whether you're using Riot on a device where touch is the primary input mechanism"),
expl: _td("Whether you're using %(brand)s on a device where touch is the primary input mechanism"),
getTextVariables: () => ({
brand: SdkConfig.get().brand,
}),
example: 'false',
},
'Breadcrumbs': {
@ -106,7 +112,10 @@ const customVariables = {
},
'Installed PWA': {
id: 10,
expl: _td("Whether you're using Riot as an installed Progressive Web App"),
expl: _td("Whether you're using %(brand)s as an installed Progressive Web App"),
getTextVariables: () => ({
brand: SdkConfig.get().brand,
}),
example: 'false',
},
};
@ -123,8 +132,8 @@ const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() {
try {
let data = localStorage.getItem(UID_KEY);
if (!data) {
let data = localStorage && localStorage.getItem(UID_KEY);
if (!data && localStorage) {
localStorage.setItem(UID_KEY, data = [...Array(16)].map(() => Math.random().toString(16)[2]).join(''));
}
return data;
@ -145,14 +154,16 @@ class Analytics {
this.firstPage = true;
this._heartbeatIntervalID = null;
this.creationTs = localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs) {
this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs && localStorage) {
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
}
this.lastVisitTs = localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage.getItem(VISIT_COUNT_KEY) || 0;
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0;
if (localStorage) {
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
}
}
get disabled() {
@ -193,8 +204,11 @@ class Analytics {
this._setVisitVariable('Chosen Language', getCurrentLanguage());
if (window.location.hostname === 'riot.im') {
const hostname = window.location.hostname;
if (hostname === 'riot.im') {
this._setVisitVariable('Instance', window.location.pathname);
} else if (hostname.endsWith('.element.io')) {
this._setVisitVariable('Instance', hostname.replace('.element.io', ''));
}
let installedPWA = "unknown";
@ -354,12 +368,17 @@ class Analytics {
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
title: _t('Analytics'),
description: <div className="mx_AnalyticsModal">
<div>
{ _t('The information being sent to us to help make Riot better includes:') }
</div>
<div>{_t('The information being sent to us to help make %(brand)s better includes:', {
brand: SdkConfig.get().brand,
})}</div>
<table>
{ rows.map((row) => <tr key={row[0]}>
<td>{ _t(customVariables[row[0]].expl) }</td>
<td>{_t(
customVariables[row[0]].expl,
customVariables[row[0]].getTextVariables ?
customVariables[row[0]].getTextVariables() :
null,
)}</td>
{ row[1] !== undefined && <td><code>{ row[1] }</code></td> }
</tr>) }
{ otherVariables.map((item, index) =>

View file

@ -38,7 +38,7 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this._unmounted = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148

View file

@ -19,6 +19,7 @@ import {MatrixClientPeg} from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
// 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) {
@ -53,13 +54,56 @@ export function avatarUrlForUser(user, width, height, resizeMethod) {
return url;
}
function isValidHexColor(color) {
return typeof color === "string" &&
(color.length === 7 || color.lengh === 9) &&
color.charAt(0) === "#" &&
!color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
}
function urlForColor(color) {
const size = 40;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
// bail out when using jsdom in unit tests
if (!ctx) {
return "";
}
ctx.fillStyle = color;
ctx.fillRect(0, 0, size, size);
return canvas.toDataURL();
}
// XXX: Ideally we'd clear this cache when the theme changes
// but since this function is at global scope, it's a bit
// hard to install a listener here, even if there were a clear event to listen to
const colorToDataURLCache = new Map();
export function defaultAvatarUrlForString(s) {
const images = ['03b381', '368bd6', 'ac3ba8'];
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
return require('../res/img/' + images[total % images.length] + '.png');
const colorIndex = total % defaultColors.length;
// overwritten color value in custom themes
const cssVariable = `--avatar-background-colors_${colorIndex}`;
const cssValue = document.body.style.getPropertyValue(cssVariable);
const color = cssValue || defaultColors[colorIndex];
let dataUrl = colorToDataURLCache.get(color);
if (!dataUrl) {
// validate color as this can come from account_data
// with custom theming
if (isValidHexColor(color)) {
dataUrl = urlForColor(color);
colorToDataURLCache.set(color, dataUrl);
} else {
dataUrl = "";
}
}
return dataUrl;
}
/**

View file

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

284
src/BasePlatform.ts Normal file
View file

@ -0,0 +1,284 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClient} from "matrix-js-sdk/src/client";
import 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";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
export enum UpdateCheckStatus {
Checking = "CHECKING",
Error = "ERROR",
NotAvailable = "NOTAVAILABLE",
Downloading = "DOWNLOADING",
Ready = "READY",
}
const UPDATE_DEFER_KEY = "mx_defer_update";
/**
* Base class for classes that provide platform-specific functionality
* eg. Setting an application badge or displaying notifications
*
* Instances of this class are provided by the application.
*/
export default abstract class BasePlatform {
protected notificationCount = 0;
protected errorDidOccur = false;
constructor() {
dis.register(this.onAction);
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
abstract async getConfig(): Promise<{}>;
abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'on_client_not_viable':
case 'on_logged_out':
this.setNotificationCount(0);
break;
}
};
// Used primarily for Analytics
abstract getHumanReadableName(): string;
setNotificationCount(count: number) {
this.notificationCount = count;
}
setErrorStatus(errorDidOccur: boolean) {
this.errorDidOccur = errorDidOccur;
}
/**
* Whether we can call checkForUpdate on this platform build
*/
async canSelfUpdate(): Promise<boolean> {
return false;
}
startUpdateCheck() {
hideUpdateToast();
localStorage.removeItem(UPDATE_DEFER_KEY);
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
status: UpdateCheckStatus.Checking,
});
}
/**
* Update the currently running app to the latest available version
* and replace this instance of the app with the new version.
*/
installUpdate() {
}
/**
* Check if the version update has been deferred and that deferment is still in effect
* @param newVersion the version string to check
*/
protected shouldShowUpdate(newVersion: string): boolean {
try {
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
return newVersion !== version || Date.now() > deferUntil;
} catch (e) {
return true;
}
}
/**
* Ignore the pending update and don't prompt about this version
* until the next morning (8am).
*/
deferUpdate(newVersion: string) {
const date = new Date(Date.now() + 24 * 60 * 60 * 1000);
date.setHours(8, 0, 0, 0); // set to next 8am
localStorage.setItem(UPDATE_DEFER_KEY, JSON.stringify([newVersion, date.getTime()]));
hideUpdateToast();
}
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/
supportsNotifications(): boolean {
return false;
}
/**
* Returns true if the application currently has permission
* to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/
maySendNotifications(): boolean {
return false;
}
/**
* Requests permission to send notifications. Returns
* a promise that is resolved when the user has responded
* to the request. The promise has a single string argument
* that is 'granted' if the user allowed the request or
* 'denied' otherwise.
*/
abstract requestNotificationPermission(): Promise<string>;
abstract displayNotification(title: string, msg: string, avatarUrl: string, room: Object);
loudNotification(ev: Event, room: Object) {
}
/**
* Returns a promise that resolves to a string representing the current version of the application.
*/
abstract getAppVersion(): Promise<string>;
/*
* If it's not expected that capturing the screen will work
* with getUserMedia, return a string explaining why not.
* Otherwise, return null.
*/
screenCaptureErrorString(): string {
return "Not implemented";
}
/**
* Restarts the application, without neccessarily reloading
* any application code
*/
abstract reload();
supportsAutoLaunch(): boolean {
return false;
}
// XXX: Surely this should be a setting like any other?
async getAutoLaunchEnabled(): Promise<boolean> {
return false;
}
async setAutoLaunchEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsAutoHideMenuBar(): boolean {
return false;
}
async getAutoHideMenuBarEnabled(): Promise<boolean> {
return false;
}
async setAutoHideMenuBarEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsMinimizeToTray(): boolean {
return false;
}
async getMinimizeToTrayEnabled(): Promise<boolean> {
return false;
}
async setMinimizeToTrayEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
/**
* Get our platform specific EventIndexManager.
*
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
* can be null if the platform doesn't support event indexing.
*/
getEventIndexingManager(): BaseEventIndexManager | null {
return null;
}
setLanguage(preferredLangs: string[]) {}
protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || "";
return url;
}
/**
* Begin Single Sign On flows.
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: 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());
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
}
onKeyDown(ev: KeyboardEvent): boolean {
return false; // no shortcuts implemented
}
/**
* Get a previously stored pickle key. The pickle key is used for
* encrypting libolm objects.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
* @returns {string|null} the previously stored pickle key, or null if no
* pickle key has been stored.
*/
async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
return null;
}
/**
* Create and store a pickle key for encrypting libolm objects.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
* @returns {string|null} the pickle key, or null if the platform does not
* support storing pickle keys.
*/
async createPickleKey(userId: string, deviceId: string): Promise<string | null> {
return null;
}
/**
* Delete a previously stored pickle key from storage.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
*/
async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
}
}

View file

@ -59,13 +59,13 @@ import Modal from './Modal';
import * as sdk from './index';
import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk';
import dis from './dispatcher';
import SdkConfig from './SdkConfig';
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import SettingsStore, { SettingLevel } from './settings/SettingsStore';
import {generateHumanReadableId} from "./utils/NamingUtils";
import {Jitsi} from "./widgets/Jitsi";
import {WidgetType} from "./widgets/WidgetType";
global.mxCalls = {
//room_id: MatrixCall
@ -118,62 +118,22 @@ function pause(audioId) {
}
}
function _reAttemptCall(call) {
if (call.direction === 'outbound') {
dis.dispatch({
action: 'place_call',
room_id: call.roomId,
type: call.type,
});
} else {
call.answer();
}
}
function _setCallListeners(call) {
call.on("error", function(err) {
console.error("Call error:", err);
if (err.code === 'unknown_devices') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
title: _t('Call Failed'),
description: _t(
"There are unknown sessions in this room: "+
"if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call.",
),
button: _t('Review Sessions'),
onFinished: function(confirmed) {
if (confirmed) {
const room = MatrixClientPeg.get().getRoom(call.roomId);
showUnknownDeviceDialogForCalls(
MatrixClientPeg.get(),
room,
() => {
_reAttemptCall(call);
},
call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"),
call.direction === 'outbound' ? _t("Call") : _t("Answer"),
);
}
},
});
} else {
if (
MatrixClientPeg.get().getTurnServers().length === 0 &&
SettingsStore.getValue("fallbackICEServerAllowed") === null
) {
_showICEFallbackPrompt();
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Call Failed'),
description: err.message,
});
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");
@ -401,9 +361,9 @@ async function _startCallApp(roomId, type) {
});
const room = MatrixClientPeg.get().getRoom(roomId);
const currentRoomWidgets = WidgetUtils.getRoomWidgets(room);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, 'jitsi')) {
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
@ -413,9 +373,6 @@ async function _startCallApp(roomId, type) {
return;
}
const currentJitsiWidgets = currentRoomWidgets.filter((ev) => {
return ev.getContent().type === 'jitsi';
});
if (currentJitsiWidgets.length > 0) {
console.warn(
"Refusing to start conference call widget in " + roomId +
@ -430,8 +387,8 @@ async function _startCallApp(roomId, type) {
return;
}
const confId = `JitsiConference_${generateHumanReadableId()}`;
const jitsiDomain = SdkConfig.get()['jitsi']['preferredDomain'];
const confId = `JitsiConference${generateHumanReadableId()}`;
const jitsiDomain = Jitsi.getInstance().preferredDomain;
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
@ -454,7 +411,7 @@ async function _startCallApp(roomId, type) {
Date.now()
);
WidgetUtils.setRoomWidget(roomId, widgetId, 'jitsi', widgetUrl, 'Jitsi', widgetData).then(() => {
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
console.log('Jitsi widget added');
}).catch((e) => {
if (e.errcode === 'M_FORBIDDEN') {

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,20 +16,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from "react";
import extend from './extend';
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import {MatrixClientPeg} from './MatrixClientPeg';
import {MatrixClient} from "matrix-js-sdk/src/client";
import * as sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import RoomViewStore from './stores/RoomViewStore';
import encrypt from "browser-encrypt-attachment";
import extractPngChunks from "png-chunks-extract";
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";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -39,6 +42,50 @@ const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
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;
}
interface IContent {
body: string;
msgtype: string;
info: {
size: number;
mimetype?: string;
};
file?: string;
url?: string;
}
interface IThumbnail {
info: {
thumbnail_info: {
w: number;
h: number;
mimetype: string;
size: number;
};
w: number;
h: number;
};
thumbnail: Blob;
}
interface IAbortablePromise<T> extends Promise<T> {
abort(): void;
}
/**
* Create a thumbnail for a image DOM element.
* The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
@ -51,13 +98,13 @@ export class UploadCanceledError extends Error {}
* about the original image and the thumbnail.
*
* @param {HTMLElement} element The element to thumbnail.
* @param {integer} inputWidth The width of the image in the input element.
* @param {integer} inputHeight the width of the image in the input element.
* @param {number} inputWidth The width of the image in the input element.
* @param {number} inputHeight the width of the image in the input element.
* @param {String} mimeType The mimeType to save the blob as.
* @return {Promise} A promise that resolves with an object with an info key
* and a thumbnail key.
*/
function createThumbnail(element, inputWidth, inputHeight, mimeType) {
function createThumbnail(element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string): Promise<IThumbnail> {
return new Promise((resolve) => {
let targetWidth = inputWidth;
let targetHeight = inputHeight;
@ -98,7 +145,7 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) {
* @param {File} imageFile The file to load in an image element.
* @return {Promise} A promise that resolves with the html image element.
*/
async function loadImageElement(imageFile) {
async function loadImageElement(imageFile: File) {
// Load the file into an html element
const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile);
@ -128,8 +175,7 @@ async function loadImageElement(imageFile) {
for (const chunk of chunks) {
if (chunk.name === 'pHYs') {
if (chunk.data.byteLength !== PHYS_HIDPI.length) return;
const hidpi = chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
return hidpi;
return chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
}
}
return false;
@ -152,7 +198,7 @@ async function loadImageElement(imageFile) {
*/
function infoForImageFile(matrixClient, roomId, imageFile) {
let thumbnailType = "image/png";
if (imageFile.type == "image/jpeg") {
if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg";
}
@ -175,15 +221,15 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
* @param {File} videoFile The file to load in an video element.
* @return {Promise} A promise that resolves with the video image element.
*/
function loadVideoElement(videoFile) {
function loadVideoElement(videoFile): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
// Load the file into an html element
const video = document.createElement("video");
const reader = new FileReader();
reader.onload = function(e) {
video.src = e.target.result;
reader.onload = function(ev) {
video.src = ev.target.result as string;
// Once ready, returns its size
// Wait until we have enough data to thumbnail the first frame.
@ -231,11 +277,11 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
* @return {Promise} A promise that resolves with an ArrayBuffer when the file
* is read.
*/
function readFileAsArrayBuffer(file) {
function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
resolve(e.target.result);
resolve(e.target.result as ArrayBuffer);
};
reader.onerror = function(e) {
reject(e);
@ -257,11 +303,11 @@ function readFileAsArrayBuffer(file) {
* If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key.
*/
function uploadFile(matrixClient, roomId, file, progressHandler) {
function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blob, progressHandler?: any) {
let canceled = false;
if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it.
// First read the file into memory.
let canceled = false;
let uploadPromise;
let encryptInfo;
const prom = readFileAsArrayBuffer(file).then(function(data) {
@ -278,9 +324,9 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
progressHandler: progressHandler,
includeFilename: false,
});
return uploadPromise;
}).then(function(url) {
if (canceled) throw new UploadCanceledError();
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and
// add it under a file key.
@ -290,7 +336,7 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
}
return {"file": encryptInfo};
});
prom.abort = () => {
(prom as IAbortablePromise<any>).abort = () => {
canceled = true;
if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise);
};
@ -300,55 +346,23 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
progressHandler: progressHandler,
});
const promise1 = basePromise.then(function(url) {
if (canceled) throw new UploadCanceledError();
// If the attachment isn't encrypted then include the URL directly.
return {"url": url};
});
// XXX: copy over the abort method to the new promise
promise1.abort = basePromise.abort;
promise1.abort = () => {
canceled = true;
MatrixClientPeg.get().cancelUpload(basePromise);
};
return promise1;
}
}
export default class ContentMessages {
constructor() {
this.inprogress = [];
this.nextId = 0;
this._mediaConfig = null;
}
private inprogress: IUpload[] = [];
private mediaConfig: IMediaConfig = null;
static sharedInstance() {
if (global.mx_ContentMessages === undefined) {
global.mx_ContentMessages = new ContentMessages();
}
return global.mx_ContentMessages;
}
_isFileSizeAcceptable(file) {
if (this._mediaConfig !== null &&
this._mediaConfig["m.upload.size"] !== undefined &&
file.size > this._mediaConfig["m.upload.size"]) {
return false;
}
return true;
}
_ensureMediaConfigFetched() {
if (this._mediaConfig !== null) return;
console.log("[Media Config] Fetching");
return MatrixClientPeg.get().getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
this._mediaConfig = config;
});
}
sendStickerContentToRoom(url, roomId, info, text, matrixClient) {
sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) {
return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e;
@ -356,14 +370,14 @@ export default class ContentMessages {
}
getUploadLimit() {
if (this._mediaConfig !== null && this._mediaConfig["m.upload.size"] !== undefined) {
return this._mediaConfig["m.upload.size"];
if (this.mediaConfig !== null && this.mediaConfig["m.upload.size"] !== undefined) {
return this.mediaConfig["m.upload.size"];
} else {
return null;
}
}
async sendContentListToRoom(files, roomId, matrixClient) {
async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) {
if (matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
@ -372,32 +386,32 @@ export default class ContentMessages {
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
if (isQuoting) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const shouldUpload = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Reply Warning', '', QuestionDialog, {
title: _t('Replying With Files'),
description: (
<div>{_t(
'At this time it is not possible to reply with a file. ' +
'Would you like to upload this file without replying?',
)}</div>
),
hasCancelButton: true,
button: _t("Continue"),
onFinished: (shouldUpload) => {
resolve(shouldUpload);
},
});
const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, {
title: _t('Replying With Files'),
description: (
<div>{_t(
'At this time it is not possible to reply with a file. ' +
'Would you like to upload this file without replying?',
)}</div>
),
hasCancelButton: true,
button: _t("Continue"),
});
const [shouldUpload] = await finished;
if (!shouldUpload) return;
}
await this._ensureMediaConfigFetched();
if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
await this.ensureMediaConfigFetched();
modal.close();
}
const tooBigFiles = [];
const okFiles = [];
for (let i = 0; i < files.length; ++i) {
if (this._isFileSizeAcceptable(files[i])) {
if (this.isFileSizeAcceptable(files[i])) {
okFiles.push(files[i]);
} else {
tooBigFiles.push(files[i]);
@ -406,17 +420,12 @@ export default class ContentMessages {
if (tooBigFiles.length > 0) {
const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog");
const uploadFailureDialogPromise = new Promise((resolve) => {
Modal.createTrackedDialog('Upload Failure', '', UploadFailureDialog, {
badFiles: tooBigFiles,
totalFiles: files.length,
contentMessages: this,
onFinished: (shouldContinue) => {
resolve(shouldContinue);
},
});
const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, {
badFiles: tooBigFiles,
totalFiles: files.length,
contentMessages: this,
});
const shouldContinue = await uploadFailureDialogPromise;
const [shouldContinue] = await finished;
if (!shouldContinue) return;
}
@ -428,31 +437,47 @@ export default class ContentMessages {
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
if (!uploadAll) {
const shouldContinue = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
onFinished: (shouldContinue, shouldUploadAll) => {
if (shouldUploadAll) {
uploadAll = true;
}
resolve(shouldContinue);
},
});
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) {
uploadAll = true;
}
}
promBefore = this._sendContentToRoom(file, roomId, matrixClient, promBefore);
promBefore = this.sendContentToRoom(file, roomId, matrixClient, promBefore);
}
}
_sendContentToRoom(file, roomId, matrixClient, promBefore) {
const content = {
getCurrentUploads() {
return this.inprogress.filter(u => !u.canceled);
}
cancelUpload(promise: Promise<any>) {
let upload: IUpload;
for (let i = 0; i < this.inprogress.length; ++i) {
if (this.inprogress[i].promise === promise) {
upload = this.inprogress[i];
break;
}
}
if (upload) {
upload.canceled = true;
MatrixClientPeg.get().cancelUpload(upload.promise);
dis.dispatch({action: 'upload_canceled', upload});
}
}
private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) {
const content: IContent = {
body: file.name || 'Attachment',
info: {
size: file.size,
},
msgtype: "", // set later
};
// if we have a mime type for the file, add it to the message metadata
@ -461,25 +486,25 @@ export default class ContentMessages {
}
const prom = new Promise((resolve) => {
if (file.type.indexOf('image/') == 0) {
if (file.type.indexOf('image/') === 0) {
content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
extend(content.info, imageInfo);
resolve();
}, (error)=>{
console.error(error);
}, (e) => {
console.error(e);
content.msgtype = 'm.file';
resolve();
});
} else if (file.type.indexOf('audio/') == 0) {
} else if (file.type.indexOf('audio/') === 0) {
content.msgtype = 'm.audio';
resolve();
} else if (file.type.indexOf('video/') == 0) {
} else if (file.type.indexOf('video/') === 0) {
content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
extend(content.info, videoInfo);
resolve();
}, (error)=>{
}, (e) => {
content.msgtype = 'm.file';
resolve();
});
@ -489,19 +514,23 @@ export default class ContentMessages {
}
});
const upload = {
// create temporary abort handler for before the actual upload gets passed off to js-sdk
(prom as IAbortablePromise<any>).abort = () => {
upload.canceled = true;
};
const upload: IUpload = {
fileName: file.name || 'Attachment',
roomId: roomId,
total: 0,
total: file.size,
loaded: 0,
promise: prom,
};
this.inprogress.push(upload);
dis.dispatch({action: 'upload_started'});
// Focus the composer view
dis.dispatch({action: 'focus_composer'});
let error;
dis.fire(Action.FocusComposer);
function onProgress(ev) {
upload.total = ev.total;
@ -509,7 +538,9 @@ export default class ContentMessages {
dis.dispatch({action: 'upload_progress', upload: upload});
}
let error;
return prom.then(function() {
if (upload.canceled) throw new UploadCanceledError();
// XXX: upload.promise must be the promise that
// is returned by uploadFile as it has an abort()
// method hacked onto it.
@ -520,16 +551,17 @@ export default class ContentMessages {
content.file = result.file;
content.url = result.url;
});
}).then((url) => {
}).then(() => {
// Await previous message being sent into the room
return promBefore;
}).then(function() {
if (upload.canceled) throw new UploadCanceledError();
return matrixClient.sendMessage(roomId, content);
}, function(err) {
error = err;
if (!upload.canceled) {
let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName});
if (err.http_status == 413) {
if (err.http_status === 413) {
desc = _t(
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
{fileName: upload.fileName},
@ -542,11 +574,9 @@ export default class ContentMessages {
});
}
}).finally(() => {
const inprogressKeys = Object.keys(this.inprogress);
for (let i = 0; i < this.inprogress.length; ++i) {
const k = inprogressKeys[i];
if (this.inprogress[k].promise === upload.promise) {
this.inprogress.splice(k, 1);
if (this.inprogress[i].promise === upload.promise) {
this.inprogress.splice(i, 1);
break;
}
}
@ -555,7 +585,7 @@ export default class ContentMessages {
// clear the media size limit so we fetch it again next time
// we try to upload
if (error && error.http_status === 413) {
this._mediaConfig = null;
this.mediaConfig = null;
}
dis.dispatch({action: 'upload_failed', upload, error});
} else {
@ -565,24 +595,35 @@ export default class ContentMessages {
});
}
getCurrentUploads() {
return this.inprogress.filter(u => !u.canceled);
private isFileSizeAcceptable(file: File) {
if (this.mediaConfig !== null &&
this.mediaConfig["m.upload.size"] !== undefined &&
file.size > this.mediaConfig["m.upload.size"]) {
return false;
}
return true;
}
cancelUpload(promise) {
const inprogressKeys = Object.keys(this.inprogress);
let upload;
for (let i = 0; i < this.inprogress.length; ++i) {
const k = inprogressKeys[i];
if (this.inprogress[k].promise === promise) {
upload = this.inprogress[k];
break;
}
}
if (upload) {
upload.canceled = true;
MatrixClientPeg.get().cancelUpload(upload.promise);
dis.dispatch({action: 'upload_canceled', upload});
private ensureMediaConfigFetched() {
if (this.mediaConfig !== null) return;
console.log("[Media Config] Fetching");
return MatrixClientPeg.get().getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
this.mediaConfig = config;
});
}
static sharedInstance() {
if (window.mxContentMessages === undefined) {
window.mxContentMessages = new ContentMessages();
}
return window.mxContentMessages;
}
}

View file

@ -20,7 +20,6 @@ import {MatrixClientPeg} from './MatrixClientPeg';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler';
import SettingsStore from './settings/SettingsStore';
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
// This stores the secret storage private keys in memory for the JS SDK. This is
@ -32,10 +31,7 @@ let secretStorageKeys = {};
let secretStorageBeingAccessed = false;
function isCachingAllowed() {
return (
secretStorageBeingAccessed ||
SettingsStore.getValue("keepSecretStoragePassphraseForSession")
);
return secretStorageBeingAccessed;
}
export class AccessCancelledError extends Error {
@ -44,25 +40,16 @@ export class AccessCancelledError extends Error {
}
}
async function confirmToDismiss(name) {
let description;
if (name === "m.cross_signing.user_signing") {
description = _t("If you cancel now, you won't complete verifying the other user.");
} else if (name === "m.cross_signing.self_signing") {
description = _t("If you cancel now, you won't complete verifying your other session.");
} else {
description = _t("If you cancel now, you won't complete your secret storage operation.");
}
async function confirmToDismiss() {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
description,
danger: true,
cancelButton: _t("Enter passphrase"),
button: _t("Cancel"),
description: _t("Are you sure you want to cancel entering passphrase?"),
danger: false,
button: _t("Go Back"),
cancelButton: _t("Cancel"),
}).finished;
return sure;
return !sure;
}
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
@ -96,11 +83,8 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
{
keyInfo: info,
checkPrivateKey: async (input) => {
if (!info.pubkey) {
return true;
}
const key = await inputToKey(input);
return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey);
return await MatrixClientPeg.get().checkSecretStorageKey(key, info);
},
},
/* className= */ null,
@ -109,7 +93,7 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
/* options= */ {
onBeforeClose: async (reason) => {
if (reason === "backgroundClick") {
return confirmToDismiss(ssssItemName);
return confirmToDismiss();
}
return true;
},
@ -188,7 +172,7 @@ export async function promptForBackupPassphrase() {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
showSummary: false, keyCallback: k => key = k,
showSummary: false, keyCallback: k => key = k,
}, null, /* priority = */ false, /* static = */ true);
const success = await finished;

View file

@ -1,203 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClientPeg } from './MatrixClientPeg';
import SettingsStore from './settings/SettingsStore';
import * as sdk from './index';
import { _t } from './languageHandler';
import ToastStore from './stores/ToastStore';
function toastKey(deviceId) {
return 'unverified_session_' + deviceId;
}
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
const THIS_DEVICE_TOAST_KEY = 'setupencryption';
export default class DeviceListener {
static sharedInstance() {
if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener();
return global.mx_DeviceListener;
}
constructor() {
// set of device IDs we're currently showing toasts for
this._activeNagToasts = new Set();
// device IDs for which the user has dismissed the verify toast ('Later')
this._dismissed = new Set();
// has the user dismissed any of the various nag toasts to setup encryption on this device?
this._dismissedThisDeviceToast = false;
// cache of the key backup info
this._keyBackupInfo = null;
this._keyBackupFetchedAt = null;
}
start() {
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().on('accountData', this._onAccountData);
this._recheck();
}
stop() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
}
this._dismissed.clear();
}
dismissVerification(deviceId) {
this._dismissed.add(deviceId);
this._recheck();
}
dismissEncryptionSetup() {
this._dismissedThisDeviceToast = true;
this._recheck();
}
_onDevicesUpdated = (users) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this._recheck();
}
_onDeviceVerificationChanged = (userId) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
}
_onUserTrustStatusChanged = (userId, trustLevel) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
}
_onAccountData = (ev) => {
// User may have migrated SSSS to symmetric, in which case we can dismiss that toast
if (ev.getType().startsWith('m.secret_storage.key.')) {
this._recheck();
}
}
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
async _getKeyBackupInfo() {
const now = (new Date()).getTime();
if (!this._keyBackupInfo || this._keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
this._keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this._keyBackupFetchedAt = now;
}
return this._keyBackupInfo;
}
async _recheck() {
const cli = MatrixClientPeg.get();
if (
!SettingsStore.isFeatureEnabled("feature_cross_signing") ||
!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
) return;
if (!cli.isCryptoEnabled()) return;
const crossSigningReady = await cli.isCrossSigningReady();
if (this._dismissedThisDeviceToast) {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
} else {
if (!crossSigningReady) {
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (cli.getStoredCrossSigningForUser(cli.getUserId())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Verify this session"),
icon: "verification_warning",
props: {kind: 'verify_this_session'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
const backupInfo = await this._getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
icon: "verification_warning",
props: {kind: 'upgrade_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
// No cross-signing or key backup on account (set up encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Set up encryption"),
icon: "verification_warning",
props: {kind: 'set_up_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
}
}
return;
} else if (await cli.secretStorageKeyNeedsUpgrade()) {
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
icon: "verification_warning",
props: {kind: 'upgrade_ssss'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
}
}
// as long as cross-signing isn't ready,
// you can't see or dismiss any device toasts
if (crossSigningReady) {
const newActiveToasts = new Set();
const devices = await cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId == cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId));
} else {
this._activeNagToasts.add(device.deviceId);
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey(device.deviceId),
title: _t("Unverified login. Was this you?"),
icon: "verification_warning",
props: { device },
component: sdk.getComponent("toasts.UnverifiedSessionToast"),
});
newActiveToasts.add(device.deviceId);
}
}
// clear any other outstanding toasts (eg. logged out devices)
for (const deviceId of this._activeNagToasts) {
if (!newActiveToasts.has(deviceId)) ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
}
this._activeNagToasts = newActiveToasts;
}
}
}

266
src/DeviceListener.ts Normal file
View file

@ -0,0 +1,266 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import {
hideToast as hideBulkUnverifiedSessionsToast,
showToast as showBulkUnverifiedSessionsToast,
} from "./toasts/BulkUnverifiedSessionsToast";
import {
hideToast as hideSetupEncryptionToast,
Kind as SetupKind,
showToast as showSetupEncryptionToast,
} from "./toasts/SetupEncryptionToast";
import {
hideToast as hideUnverifiedSessionsToast,
showToast as showUnverifiedSessionsToast,
} from "./toasts/UnverifiedSessionToast";
import {privateShouldBeEncrypted} from "./createRoom";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
export default class DeviceListener {
// device IDs for which the user has dismissed the verify toast ('Later')
private dismissed = new Set<string>();
// has the user dismissed any of the various nag toasts to setup encryption on this device?
private dismissedThisDeviceToast = false;
// cache of the key backup info
private keyBackupInfo: object = null;
private keyBackupFetchedAt: number = null;
// We keep a list of our own device IDs so we can batch ones that were already
// there the last time the app launched into a single toast, but display new
// ones in their own toasts.
private ourDeviceIdsAtStart: Set<string> = null;
// The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>();
static sharedInstance() {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
return window.mxDeviceListener;
}
start() {
MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().on('accountData', this._onAccountData);
MatrixClientPeg.get().on('sync', this._onSync);
this._recheck();
}
stop() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
MatrixClientPeg.get().removeListener('sync', this._onSync);
}
this.dismissed.clear();
this.dismissedThisDeviceToast = false;
this.keyBackupInfo = null;
this.keyBackupFetchedAt = null;
this.ourDeviceIdsAtStart = null;
this.displayingToastsForDeviceIds = new Set();
}
/**
* Dismiss notifications about our own unverified devices
*
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
for (const d of deviceIds) {
this.dismissed.add(d);
}
this._recheck();
}
dismissEncryptionSetup() {
this.dismissedThisDeviceToast = true;
this._recheck();
}
_ensureDeviceIdsAtStartPopulated() {
if (this.ourDeviceIdsAtStart === null) {
const cli = MatrixClientPeg.get();
this.ourDeviceIdsAtStart = new Set(
cli.getStoredDevicesForUser(cli.getUserId()).map(d => d.deviceId),
);
}
}
_onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => {
// If we didn't know about *any* devices before (ie. it's fresh login),
// then they are all pre-existing devices, so ignore this and set the
// devicesAtStart list to the devices that we see after the fetch.
if (initialFetch) return;
const myUserId = MatrixClientPeg.get().getUserId();
if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated();
// No need to do a recheck here: we just need to get a snapshot of our devices
// before we download any new ones.
};
_onDevicesUpdated = (users: string[]) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this._recheck();
};
_onDeviceVerificationChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
};
_onUserTrustStatusChanged = (userId: string) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
};
_onCrossSingingKeysChanged = () => {
this._recheck();
};
_onAccountData = (ev) => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith('m.secret_storage.') ||
ev.getType().startsWith('m.cross_signing.')
) {
this._recheck();
}
};
_onSync = (state, prevState) => {
if (state === 'PREPARED' && prevState === null) this._recheck();
};
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
async _getKeyBackupInfo() {
const now = (new Date()).getTime();
if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.keyBackupFetchedAt = now;
}
return this.keyBackupInfo;
}
private shouldShowSetupEncryptionToast() {
// 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;
const cli = MatrixClientPeg.get();
return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId));
}
async _recheck() {
const cli = MatrixClientPeg.get();
if (!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) return;
if (!cli.isCryptoEnabled()) return;
// don't recheck until the initial sync is complete: lots of account data events will fire
// while the initial sync is processing and we don't need to recheck on each one of them
// (we add a listener on sync to do once check after the initial sync is done)
if (!cli.isInitialSyncComplete()) return;
const crossSigningReady = await cli.isCrossSigningReady();
if (this.dismissedThisDeviceToast || crossSigningReady) {
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())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else {
const backupInfo = await this._getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION);
} else {
// No cross-signing or key backup on account (set up encryption)
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
}
}
}
// This needs to be done after awaiting on downloadKeys() above, so
// we make sure we get the devices after the fetch is done.
this._ensureDeviceIdsAtStartPopulated();
// Unverified devices that were there last time the app ran
// (technically could just be a boolean: we don't actually
// need to remember the device IDs, but for the sake of
// symmetry...).
const oldUnverifiedDeviceIds = new Set<string>();
// Unverified devices that have appeared since then
const newUnverifiedDeviceIds = new Set<string>();
// as long as cross-signing isn't ready,
// you can't see or dismiss any device toasts
if (crossSigningReady) {
const devices = cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId === cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (!deviceTrust.isCrossSigningVerified() && !this.dismissed.has(device.deviceId)) {
if (this.ourDeviceIdsAtStart.has(device.deviceId)) {
oldUnverifiedDeviceIds.add(device.deviceId);
} else {
newUnverifiedDeviceIds.add(device.deviceId);
}
}
}
}
// Display or hide the batch toast for old unverified sessions
if (oldUnverifiedDeviceIds.size > 0) {
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
} else {
hideBulkUnverifiedSessionsToast();
}
// Show toasts for new unverified devices if they aren't already there
for (const deviceId of newUnverifiedDeviceIds) {
showUnverifiedSessionsToast(deviceId);
}
// ...and hide any we don't need any more
for (const deviceId of this.displayingToastsForDeviceIds) {
if (!newUnverifiedDeviceIds.has(deviceId)) {
hideUnverifiedSessionsToast(deviceId);
}
}
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
}

View file

@ -17,15 +17,15 @@ limitations under the License.
*/
import URL from 'url';
import dis from './dispatcher';
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, KnownWidgetActions} from "./widgets/WidgetApi";
import SdkConfig from "./SdkConfig";
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 = [
@ -101,7 +101,7 @@ export default class FromWidgetPostMessageApi {
console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
return;
} else {
console.warn(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
this.widgetMessagingEndpoints.push(endpoint);
}
}
@ -166,7 +166,7 @@ export default class FromWidgetPostMessageApi {
const action = event.data.action;
const widgetId = event.data.widgetId;
if (action === 'content_loaded') {
console.warn('Widget reported content loaded for', widgetId);
console.log('Widget reported content loaded for', widgetId);
dis.dispatch({
action: 'widget_content_loaded',
widgetId: widgetId,
@ -220,13 +220,6 @@ export default class FromWidgetPostMessageApi {
}
} else if (action === 'get_openid') {
// Handled by caller
} else if (action === KnownWidgetActions.GetRiotWebConfig) {
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.GetRiotWebConfig)) {
this.sendResponse(event, {
api: INBOUND_API_NAME,
config: SdkConfig.get(),
});
}
} else {
console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was unhandled'});
@ -255,7 +248,7 @@ export default class FromWidgetPostMessageApi {
* @param {Object} res Response data
*/
sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
const data = objectClone(event.data);
data.response = res;
event.source.postMessage(data, event.origin);
}
@ -268,7 +261,7 @@ export default class FromWidgetPostMessageApi {
*/
sendError(event, msg, nestedError) {
console.error('Action:' + event.data.action + ' failed with message: ' + msg);
const data = JSON.parse(JSON.stringify(event.data));
const data = objectClone(event.data);
data.response = {
error: {
message: msg,

View file

@ -22,6 +22,7 @@ import { _t } from './languageHandler';
import {MatrixClientPeg} from './MatrixClientPeg';
import GroupStore from './stores/GroupStore';
import {allSettled} from "./utils/promise";
import StyledCheckbox from './components/views/elements/StyledCheckbox';
export function showGroupInviteDialog(groupId) {
return new Promise((resolve, reject) => {
@ -61,19 +62,19 @@ export function showGroupAddRoomDialog(groupId) {
<div>{ _t("Which rooms would you like to add to this community?") }</div>
</div>;
const checkboxContainer = <label className="mx_GroupAddressPicker_checkboxContainer">
<input type="checkbox" onChange={onCheckboxClicked} />
<div>
{ _t("Show these rooms to non-members on the community page and room list?") }
</div>
</label>;
const checkboxContainer = <StyledCheckbox
className="mx_GroupAddressPicker_checkboxContainer"
onChange={onCheckboxClicked}
>
{ _t("Show these rooms to non-members on the community page and room list?") }
</StyledCheckbox>;
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, {
title: _t("Add rooms to the community"),
description: description,
extraNode: checkboxContainer,
placeholder: _t("Room name or alias"),
placeholder: _t("Room name or address"),
button: _t("Add to community"),
pickerType: 'room',
validAddressTypes: ['mx-room-id'],

View file

@ -17,10 +17,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import ReplyThread from "./components/views/elements/ReplyThread";
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import * as linkify from 'linkifyjs';
@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames';
import {MatrixClientPeg} from './MatrixClientPeg';
import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url';
import EMOJIBASE_REGEX from 'emojibase-regex';
import {MatrixClientPeg} from './MatrixClientPeg';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread";
linkifyMatrix(linkify);
@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
* need emojification.
* unicodeToImage uses this function.
*/
function mightContainEmoji(str) {
function mightContainEmoji(str: string) {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
}
@ -74,7 +71,7 @@ function mightContainEmoji(str) {
* @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:)
*/
export function unicodeToShortcode(char) {
export function unicodeToShortcode(char: string) {
const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
}
@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
* @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists
*/
export function shortcodeToUnicode(shortcode) {
export function shortcodeToUnicode(shortcode: string) {
shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null;
@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string {
}
let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) {
for (let i = 0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML;
@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string {
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
*/
export function sanitizedHtmlNode(insaneHtml) {
export function sanitizedHtmlNode(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
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;
}
/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
export function isUrlPermitted(inputUrl) {
export function isUrlPermitted(inputUrl: string) {
try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
}
}
const transformTags = { // custom to matrix
const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
'a': function(tagName, attribs) {
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) {
attribs.target = '_blank'; // by default
@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
'img': function(tagName, attribs) {
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// 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.
@ -176,17 +180,17 @@ const transformTags = { // custom to matrix
);
return { tagName, attribs };
},
'code': function(tagName, attribs) {
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function(cl) {
return cl.startsWith('language-');
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
attribs.class = classes.join(' ');
}
return { tagName, attribs };
},
'*': function(tagName, attribs) {
'*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
},
};
const sanitizeHtmlParams = {
const sanitizeHtmlParams: sanitizeHtml.IOptions = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
};
// this is the same as the above except with less rewriting
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
composerSanitizeHtmlParams.transformTags = {
'code': transformTags['code'],
'*': transformTags['*'],
const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
...sanitizeHtmlParams,
transformTags: {
'code': transformTags['code'],
'*': transformTags['*'],
},
};
class BaseHighlighter {
constructor(highlightClass, highlightLink) {
this.highlightClass = highlightClass;
this.highlightLink = highlightLink;
abstract class BaseHighlighter<T extends React.ReactNode> {
constructor(public highlightClass: string, public highlightLink: string) {
}
/**
@ -270,47 +274,49 @@ class BaseHighlighter {
* returns a list of results (strings for HtmlHighligher, react nodes for
* TextHighlighter).
*/
applyHighlights(safeSnippet, safeHighlights) {
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0;
let offset;
let nodes = [];
let nodes: T[] = [];
const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble
if (offset > lastOffset) {
var subSnippet = safeSnippet.substring(lastOffset, offset);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
const subSnippet = safeSnippet.substring(lastOffset, offset);
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
// do highlight. use the original string rather than safeHighlight
// to preserve the original casing.
const endOffset = offset + safeHighlight.length;
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
lastOffset = endOffset;
}
// handle postamble
if (lastOffset !== safeSnippet.length) {
subSnippet = safeSnippet.substring(lastOffset, undefined);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
const subSnippet = safeSnippet.substring(lastOffset, undefined);
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
return nodes;
}
_applySubHighlights(safeSnippet, safeHighlights) {
private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
} else {
// no more highlights to be found, just return the unhighlighted string
return [this._processSnippet(safeSnippet, false)];
return [this.processSnippet(safeSnippet, false)];
}
}
protected abstract processSnippet(snippet: string, highlight: boolean): T;
}
class HtmlHighlighter extends BaseHighlighter {
class HtmlHighlighter extends BaseHighlighter<string> {
/* highlight the given snippet if required
*
* snippet: content of the span; must have been sanitised
@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
*
* returns an HTML string
*/
_processSnippet(snippet, highlight) {
protected processSnippet(snippet: string, highlight: boolean): string {
if (!highlight) {
// nothing required here
return snippet;
}
let span = "<span class=\""+this.highlightClass+"\">"
+ snippet + "</span>";
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
if (this.highlightLink) {
span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
+span+"</a>";
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
}
return span;
}
}
class TextHighlighter extends BaseHighlighter {
constructor(highlightClass, highlightLink) {
super(highlightClass, highlightLink);
this._key = 0;
}
class TextHighlighter extends BaseHighlighter<React.ReactNode> {
private key = 0;
/* create a <span> node to hold the given content
*
@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter {
*
* returns a React node
*/
_processSnippet(snippet, highlight) {
const key = this._key++;
protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
const key = this.key++;
let node =
<span key={key} className={highlight ? this.highlightClass : null}>
{ snippet }
</span>;
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>;
@ -364,6 +364,20 @@ class TextHighlighter extends BaseHighlighter {
}
}
interface IContent {
format?: string;
formatted_body?: string;
body: string;
}
interface IOpts {
highlightLink?: string;
disableBigEmoji?: boolean;
stripReplyFallback?: boolean;
returnString?: boolean;
forComposerQuote?: boolean;
ref?: React.Ref<any>;
}
/* turn a matrix event body into html
*
@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/
export function bodyToHtml(content, highlights, opts={}) {
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false;
@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
sanitizeParams = composerSanitizeHtmlParams;
}
let strippedBody;
let safeBody;
let isDisplayedWithHtml;
let strippedBody: string;
let safeBody: string;
let isDisplayedWithHtml: boolean;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
@ -446,7 +460,8 @@ export function bodyToHtml(content, highlights, opts={}) {
// their username. Permalinks (links in pills) can be any URL
// now, so we just check for an HTTP-looking thing.
(
content.formatted_body == undefined ||
strippedBody === safeBody || // replies have the html fallbacks, account for that here
content.formatted_body === undefined ||
(!content.formatted_body.includes("http:") &&
!content.formatted_body.includes("https:"))
);
@ -470,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/
export function linkifyString(str, options = linkifyMatrix.options) {
export function linkifyString(str: string, options = linkifyMatrix.options) {
return _linkifyString(str, options);
}
@ -481,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object}
*/
export function linkifyElement(element, options = linkifyMatrix.options) {
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
return _linkifyElement(element, options);
}
@ -492,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
@ -503,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
* @param {Node} node
* @returns {bool}
*/
export function checkBlockNode(node) {
export function checkBlockNode(node: Node) {
switch (node.nodeName) {
case "H1":
case "H2":

View file

@ -1,158 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as sdk from './index';
import Modal from './Modal';
import SettingsStore from './settings/SettingsStore';
// TODO: We can remove this once cross-signing is the only way.
// https://github.com/vector-im/riot-web/issues/11908
export default class KeyRequestHandler {
constructor(matrixClient) {
this._matrixClient = matrixClient;
// the user/device for which we currently have a dialog open
this._currentUser = null;
this._currentDevice = null;
// userId -> deviceId -> [keyRequest]
this._pendingKeyRequests = Object.create(null);
}
handleKeyRequest(keyRequest) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
const userId = keyRequest.userId;
const deviceId = keyRequest.deviceId;
const requestId = keyRequest.requestId;
if (!this._pendingKeyRequests[userId]) {
this._pendingKeyRequests[userId] = Object.create(null);
}
if (!this._pendingKeyRequests[userId][deviceId]) {
this._pendingKeyRequests[userId][deviceId] = [];
}
// check if we already have this request
const requests = this._pendingKeyRequests[userId][deviceId];
if (requests.find((r) => r.requestId === requestId)) {
console.log("Already have this key request, ignoring");
return;
}
requests.push(keyRequest);
if (this._currentUser) {
// ignore for now
console.log("Key request, but we already have a dialog open");
return;
}
this._processNextRequest();
}
handleKeyRequestCancellation(cancellation) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
// see if we can find the request in the queue
const userId = cancellation.userId;
const deviceId = cancellation.deviceId;
const requestId = cancellation.requestId;
if (userId === this._currentUser && deviceId === this._currentDevice) {
console.log(
"room key request cancellation for the user we currently have a"
+ " dialog open for",
);
// TODO: update the dialog. For now, we just ignore the
// cancellation.
return;
}
if (!this._pendingKeyRequests[userId]) {
return;
}
const requests = this._pendingKeyRequests[userId][deviceId];
if (!requests) {
return;
}
const idx = requests.findIndex((r) => r.requestId === requestId);
if (idx < 0) {
return;
}
console.log("Forgetting room key request");
requests.splice(idx, 1);
if (requests.length === 0) {
delete this._pendingKeyRequests[userId][deviceId];
if (Object.keys(this._pendingKeyRequests[userId]).length === 0) {
delete this._pendingKeyRequests[userId];
}
}
}
_processNextRequest() {
const userId = Object.keys(this._pendingKeyRequests)[0];
if (!userId) {
return;
}
const deviceId = Object.keys(this._pendingKeyRequests[userId])[0];
if (!deviceId) {
return;
}
console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`);
const finished = (r) => {
this._currentUser = null;
this._currentDevice = null;
if (!this._pendingKeyRequests[userId] || !this._pendingKeyRequests[userId][deviceId]) {
// request was removed in the time the dialog was displayed
this._processNextRequest();
return;
}
if (r) {
for (const req of this._pendingKeyRequests[userId][deviceId]) {
req.share();
}
}
delete this._pendingKeyRequests[userId][deviceId];
if (Object.keys(this._pendingKeyRequests[userId]).length === 0) {
delete this._pendingKeyRequests[userId];
}
this._processNextRequest();
};
const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog");
Modal.appendTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, {
matrixClient: this._matrixClient,
userId: userId,
deviceId: deviceId,
onFinished: finished,
});
this._currentUser = userId;
this._currentDevice = deviceId;
}
}

View file

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

View file

@ -26,7 +26,7 @@ import Analytics from './Analytics';
import Notifier from './Notifier';
import UserActivity from './UserActivity';
import Presence from './Presence';
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import Modal from './Modal';
import * as sdk from './index';
@ -40,6 +40,12 @@ 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";
const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url";
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -162,14 +168,16 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
return Promise.resolve(false);
}
if (!queryParams.homeserver) {
const homeserver = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY);
if (!homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use");
return Promise.resolve(false);
}
return sendLoginRequest(
queryParams.homeserver,
queryParams.identityServer,
homeserver,
identityServer,
"m.login.token", {
token: queryParams.loginToken,
initial_device_display_name: defaultDeviceDisplayName,
@ -255,8 +263,8 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
* @returns {Object} Information about the session - see implementation for variables.
*/
export function getLocalStorageSessionVars() {
const hsUrl = localStorage.getItem("mx_hs_url");
const isUrl = localStorage.getItem("mx_is_url");
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
const accessToken = localStorage.getItem("mx_access_token");
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
@ -297,6 +305,8 @@ async function _restoreFromLocalStorage(opts) {
return false;
}
const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
console.log(`Restoring session for ${userId}`);
await _doSetLoggedIn({
userId: userId,
@ -305,6 +315,7 @@ async function _restoreFromLocalStorage(opts) {
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
guest: isGuest,
pickleKey: pickleKey,
}, false);
return true;
} else {
@ -347,9 +358,13 @@ async function _handleLoadSessionFailure(e) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export function setLoggedIn(credentials) {
export async function setLoggedIn(credentials) {
stopMatrixClient();
return _doSetLoggedIn(credentials, true);
const pickleKey = credentials.userId && credentials.deviceId
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
: null;
return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
}
/**
@ -478,9 +493,9 @@ function _showStorageEvictedDialog() {
class AbortLoginAndRebuildStorage extends Error { }
function _persistCredentialsToLocalStorage(credentials) {
localStorage.setItem("mx_hs_url", credentials.homeserverUrl);
localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl);
if (credentials.identityServerUrl) {
localStorage.setItem("mx_is_url", credentials.identityServerUrl);
localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl);
}
localStorage.setItem("mx_user_id", credentials.userId);
localStorage.setItem("mx_access_token", credentials.accessToken);
@ -515,7 +530,9 @@ export function logout() {
}
_isLoggingOut = true;
MatrixClientPeg.get().logout().then(onLoggedOut,
const client = MatrixClientPeg.get();
PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId());
client.logout().then(onLoggedOut,
(err) => {
// Just throwing an error here is going to be very unhelpful
// if you're trying to log out because your server's down and
@ -574,13 +591,12 @@ async function startMatrixClient(startSyncing=true) {
// to work).
dis.dispatch({action: 'will_start_client'}, true);
// reset things first just in case
TypingStore.sharedInstance().reset();
ToastStore.sharedInstance().reset();
Notifier.start();
UserActivity.sharedInstance().start();
TypingStore.sharedInstance().reset(); // just in case
ToastStore.sharedInstance().reset();
if (!SettingsStore.getValue("lowBandwidth")) {
Presence.start();
}
DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.start();
@ -603,6 +619,16 @@ async function startMatrixClient(startSyncing=true) {
// This needs to be started after crypto is set up
DeviceListener.sharedInstance().start();
// Similarly, don't start sending presence updates until we've started
// the client
if (!SettingsStore.getValue("lowBandwidth")) {
Presence.start();
}
// 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.
@ -637,6 +663,10 @@ async function _clearStorage() {
window.localStorage.clear();
}
if (window.sessionStorage) {
window.sessionStorage.clear();
}
// create a temporary client to clear out the persistent stores.
const cli = createMatrixClient({
// we'll never make any requests, so can pass a bogus HS URL
@ -661,6 +691,7 @@ 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();

View file

@ -95,6 +95,8 @@ export default class Login {
identifier = {
type: 'm.id.phone',
country: phoneCountry,
phone: phoneNumber,
// XXX: Synapse historically wanted `number` and not `phone`
number: phoneNumber,
};
} else if (isEmail) {

View file

@ -175,14 +175,6 @@ export default class Markdown {
const renderer = new commonmark.HtmlRenderer({safe: false});
const real_paragraph = renderer.paragraph;
// The default `out` function only sends the input through an XML
// escaping function, which causes messages to be entity encoded,
// which we don't want in this case.
renderer.out = function(s) {
// The `lit` function adds a string literal to the output buffer.
this.lit(s);
};
renderer.paragraph = function(node, entering) {
// as with toHTML, only append lines to paragraphs if there are
// multiple paragraphs

View file

@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClient, MemoryStore} from 'matrix-js-sdk';
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';
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
import {EventTimelineSet} from 'matrix-js-sdk/src/models/event-timeline-set';
@ -34,37 +34,26 @@ import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks } from './CrossSigningManager';
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
interface MatrixClientCreds {
homeserverUrl: string,
identityServerUrl: string,
userId: string,
deviceId: string,
accessToken: string,
guest: boolean,
export interface IMatrixClientCreds {
homeserverUrl: string;
identityServerUrl: string;
userId: string;
deviceId: string;
accessToken: string;
guest: boolean;
pickleKey?: string;
}
/**
* Wrapper object for handling the js-sdk Matrix Client object in the react-sdk
* Handles the creation/initialisation of client objects.
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class _MatrixClientPeg {
constructor() {
this.matrixClient = null;
this._justRegisteredUserId = null;
// TODO: Move this to the js-sdk
export interface IOpts {
initialSyncLimit?: number;
pendingEventOrdering?: "detached" | "chronological";
lazyLoadMembers?: boolean;
clientWellKnownPollPeriod?: number;
}
// These are the default options used when when the
// client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client'
// event is finished processing.
this.opts = {
initialSyncLimit: 20,
};
// the credentials used to init the current client object.
// used if we tear it down & recreate it with a different store
this._currentClientCreds = null;
}
export interface IMatrixClientPeg {
opts: IOpts;
/**
* Sets the script href passed to the IndexedDB web worker
@ -73,19 +62,23 @@ class _MatrixClientPeg {
*
* @param {string} script href to the script to be passed to the web worker
*/
setIndexedDbWorkerScript(script) {
createMatrixClient.indexedDbWorkerScript = script;
}
setIndexedDbWorkerScript(script: string): void;
get(): MatrixClient {
return this.matrixClient;
}
/**
* Return the server name of the user's homeserver
* Throws an error if unable to deduce the homeserver name
* (eg. if the user is not logged in)
*
* @returns {string} The homeserver name, if present.
*/
getHomeserverName(): string;
unset() {
this.matrixClient = null;
get(): MatrixClient;
unset(): void;
assign(): Promise<any>;
start(): Promise<any>;
MatrixActionCreators.stop();
}
getCredentials(): IMatrixClientCreds;
/**
* If we've registered a user ID we set this to the ID of the
@ -95,9 +88,7 @@ class _MatrixClientPeg {
*
* @param {string} uid The user ID of the user we've just registered
*/
setJustRegisteredUserId(uid) {
this._justRegisteredUserId = uid;
}
setJustRegisteredUserId(uid: string): void;
/**
* Returns true if the current user has just been registered by this
@ -105,23 +96,73 @@ class _MatrixClientPeg {
*
* @returns {bool} True if user has just been registered
*/
currentUserIsJustRegistered() {
currentUserIsJustRegistered(): boolean;
/**
* Replace this MatrixClientPeg's client with a client instance that has
* homeserver / identity server URLs and active credentials
*
* @param {IMatrixClientCreds} creds The new credentials to use.
*/
replaceUsingCreds(creds: IMatrixClientCreds): void;
}
/**
* Wrapper object for handling the js-sdk Matrix Client object in the react-sdk
* Handles the creation/initialisation of client objects.
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class _MatrixClientPeg implements IMatrixClientPeg {
// These are the default options used when when the
// client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client'
// event is finished processing.
public opts: IOpts = {
initialSyncLimit: 20,
};
private matrixClient: MatrixClient = null;
private justRegisteredUserId: string;
// the credentials used to init the current client object.
// used if we tear it down & recreate it with a different store
private currentClientCreds: IMatrixClientCreds;
constructor() {
}
public setIndexedDbWorkerScript(script: string): void {
createMatrixClient.indexedDbWorkerScript = script;
}
public get(): MatrixClient {
return this.matrixClient;
}
public unset(): void {
this.matrixClient = null;
MatrixActionCreators.stop();
}
public setJustRegisteredUserId(uid: string): void {
this.justRegisteredUserId = uid;
}
public currentUserIsJustRegistered(): boolean {
return (
this.matrixClient &&
this.matrixClient.credentials.userId === this._justRegisteredUserId
this.matrixClient.credentials.userId === this.justRegisteredUserId
);
}
/*
* Replace this MatrixClientPeg's client with a client instance that has
* homeserver / identity server URLs and active credentials
*/
replaceUsingCreds(creds: MatrixClientCreds) {
this._currentClientCreds = creds;
this._createClient(creds);
public replaceUsingCreds(creds: IMatrixClientCreds): void {
this.currentClientCreds = creds;
this.createClient(creds);
}
async assign() {
public async assign(): Promise<any> {
for (const dbType of ['indexeddb', 'memory']) {
try {
const promise = this.matrixClient.store.startup();
@ -132,7 +173,7 @@ class _MatrixClientPeg {
if (dbType === 'indexeddb') {
console.error('Error starting matrixclient store - falling back to memory store', err);
this.matrixClient.store = new MemoryStore({
localStorage: global.localStorage,
localStorage: localStorage,
});
} else {
console.error('Failed to start memory store!', err);
@ -158,9 +199,7 @@ class _MatrixClientPeg {
// The js-sdk found a crypto DB too new for it to use
const CryptoStoreTooNewDialog =
sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog");
Modal.createDialog(CryptoStoreTooNewDialog, {
host: window.location.host,
});
Modal.createDialog(CryptoStoreTooNewDialog);
}
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
@ -171,6 +210,7 @@ class _MatrixClientPeg {
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached";
opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);
@ -179,7 +219,7 @@ class _MatrixClientPeg {
return opts;
}
async start() {
public async start(): Promise<any> {
const opts = await this.assign();
console.log(`MatrixClientPeg: really starting MatrixClient`);
@ -187,7 +227,7 @@ class _MatrixClientPeg {
console.log(`MatrixClientPeg: MatrixClient started`);
}
getCredentials(): MatrixClientCreds {
public getCredentials(): IMatrixClientCreds {
return {
homeserverUrl: this.matrixClient.baseUrl,
identityServerUrl: this.matrixClient.idBaseUrl,
@ -198,12 +238,7 @@ class _MatrixClientPeg {
};
}
/*
* Return the server name of the user's homeserver
* Throws an error if unable to deduce the homeserver name
* (eg. if the user is not logged in)
*/
getHomeserverName() {
public getHomeserverName(): string {
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive homeserver name from user ID!");
@ -211,13 +246,15 @@ class _MatrixClientPeg {
return matches[1];
}
_createClient(creds: MatrixClientCreds) {
private createClient(creds: IMatrixClientCreds): void {
// TODO: Make these opts typesafe with the js-sdk
const opts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,
userId: creds.userId,
deviceId: creds.deviceId,
pickleKey: creds.pickleKey,
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
@ -228,9 +265,9 @@ class _MatrixClientPeg {
],
unstableClientRelationAggregation: true,
identityServer: new IdentityAuthClient(),
cryptoCallbacks: {},
};
opts.cryptoCallbacks = {};
// These are always installed regardless of the labs flag so that
// cross-signing features can toggle on without reloading and also be
// accessed immediately after login.
@ -253,8 +290,8 @@ class _MatrixClientPeg {
}
}
if (!global.mxMatrixClientPeg) {
global.mxMatrixClientPeg = new _MatrixClientPeg();
if (!window.mxMatrixClientPeg) {
window.mxMatrixClientPeg = new _MatrixClientPeg();
}
export const MatrixClientPeg = global.mxMatrixClientPeg;
export const MatrixClientPeg = window.mxMatrixClientPeg;

View file

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

383
src/Modal.tsx Normal file
View file

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

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,16 +17,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixClientPeg } from './MatrixClientPeg';
import SdkConfig from './SdkConfig';
import PlatformPeg from './PlatformPeg';
import * as TextForEvent from './TextForEvent';
import Analytics from './Analytics';
import * as Avatar from './Avatar';
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
import {
hideToast as hideNotificationsToast,
} from "./toasts/DesktopNotificationsToast";
/*
* Dispatches:
@ -37,6 +42,18 @@ import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
const MAX_PENDING_ENCRYPTED = 20;
/*
Override both the content body and the TextForEvent handler for specific msgtypes, in notifications.
This is useful when the content body contains fallback text that would explain that the client can't handle a particular
type of tile.
*/
const typehandlers = {
"m.key.verification.request": (event) => {
const name = (event.sender || {}).name;
return _t("%(name)s is requesting verification", { name });
},
};
const Notifier = {
notifsByRoom: {},
@ -46,6 +63,9 @@ const Notifier = {
pendingEncryptedEventIds: [],
notificationMessageForEvent: function(ev) {
if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
return typehandlers[ev.getContent().msgtype](ev);
}
return TextForEvent.textForEvent(ev);
},
@ -69,7 +89,9 @@ const Notifier = {
title = room.name;
// notificationMessageForEvent includes sender,
// but we already have the sender here
if (ev.getContent().body) msg = ev.getContent().body;
if (ev.getContent().body && !typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
}
} else if (ev.getType() === 'm.room.member') {
// context is all in the message here, we don't need
// to display sender info
@ -78,7 +100,9 @@ const Notifier = {
title = ev.sender.name + " (" + room.name + ")";
// notificationMessageForEvent includes sender,
// but we've just out sender in the title
if (ev.getContent().body) msg = ev.getContent().body;
if (ev.getContent().body && !typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
}
}
if (!this.isBodyEnabled()) {
@ -100,7 +124,7 @@ const Notifier = {
}
},
getSoundForRoom: async function(roomId) {
getSoundForRoom: function(roomId) {
// We do no caching here because the SDK caches setting
// and the browser will cache the sound.
const content = SettingsStore.getValue("notificationSound", roomId);
@ -129,7 +153,7 @@ const Notifier = {
},
_playAudioNotification: async function(ev, room) {
const sound = await this.getSoundForRoom(room.roomId);
const sound = this.getSoundForRoom(room.roomId);
console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
try {
@ -204,10 +228,11 @@ const Notifier = {
if (result !== 'granted') {
// The permission request was dismissed or denied
// TODO: Support alternative branding in messaging
const brand = SdkConfig.get().brand;
const description = result === 'denied'
? _t('Riot does not have permission to send you notifications - ' +
'please check your browser settings')
: _t('Riot was not given permission to send notifications - please try again');
? _t('%(brand)s does not have permission to send you notifications - ' +
'please check your browser settings', { brand })
: _t('%(brand)s was not given permission to send notifications - please try again', { brand });
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, {
title: _t('Unable to enable Notifications'),
@ -259,12 +284,7 @@ const Notifier = {
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden);
// XXX: why are we dispatching this here?
// this is nothing to do with notifier_enabled
dis.dispatch({
action: "notifier_enabled",
value: this.isEnabled(),
});
hideNotificationsToast();
// update the info to localStorage for persistent settings
if (persistent && global.localStorage) {

View file

@ -84,8 +84,14 @@ export default class PasswordReset {
try {
await this.client.setPassword({
// Note: Though this sounds like a login type for identity servers only, it
// has a dual purpose of being used for homeservers too.
type: "m.login.email.identity",
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
}, this.password);
} catch (err) {
if (err.httpStatus === 401) {

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import BasePlatform from "./BasePlatform";
/*
* Holds the current Platform object used by the code to do anything
* specific to the platform we're running on (eg. web, electron)
@ -21,10 +24,8 @@ limitations under the License.
* This allows the app layer to set a Platform without necessarily
* having to have a MatrixChat object
*/
class PlatformPeg {
constructor() {
this.platform = null;
}
export class PlatformPeg {
platform: BasePlatform = null;
/**
* Returns the current Platform object for the application.
@ -39,12 +40,12 @@ class PlatformPeg {
* application.
* This should be an instance of a class extending BasePlatform.
*/
set(plaf) {
set(plaf: BasePlatform) {
this.platform = plaf;
}
}
if (!global.mxPlatformPeg) {
global.mxPlatformPeg = new PlatformPeg();
if (!window.mxPlatformPeg) {
window.mxPlatformPeg = new PlatformPeg();
}
export default global.mxPlatformPeg;
export default window.mxPlatformPeg;

View file

@ -17,7 +17,7 @@ limitations under the License.
*/
import {MatrixClientPeg} from "./MatrixClientPeg";
import dis from "./dispatcher";
import dis from "./dispatcher/dispatcher";
import Timer from './utils/Timer';
// Time in ms after that a user is considered as unavailable/away

173
src/RebrandListener.tsx Normal file
View file

@ -0,0 +1,173 @@
/*
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();
}
};
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;
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");
}
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,
},
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

@ -20,7 +20,7 @@ limitations under the License.
* registration code.
*/
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import { EventStatus } from 'matrix-js-sdk';
export default class Resend {

View file

@ -56,10 +56,11 @@ export function countRoomsWithNotif(rooms) {
}
export function aggregateNotificationCount(rooms) {
return rooms.reduce((result, room, index) => {
return rooms.reduce((result, room) => {
const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
// use helper method to include highlights in the previous version of the room
const notificationCount = getUnreadNotificationCount(room);
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);

24
src/RoomNotifsTypes.ts Normal file
View file

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

View file

@ -23,10 +23,13 @@ import request from "browser-request";
import * as Matrix from 'matrix-js-sdk';
import SdkConfig from "./SdkConfig";
import {WidgetType} from "./widgets/WidgetType";
// The version of the integration manager API we're intending to work with
const imApiVersion = "1.1";
// TODO: Generify the name of this class and all components within - it's not just for Scalar.
export default class ScalarAuthClient {
constructor(apiUrl, uiUrl) {
this.apiUrl = apiUrl;
@ -233,20 +236,20 @@ export default class ScalarAuthClient {
* Mark all assets associated with the specified widget as "disabled" in the
* integration manager database.
* This can be useful to temporarily prevent purchased assets from being displayed.
* @param {string} widgetType [description]
* @param {string} widgetId [description]
* @param {WidgetType} widgetType The Widget Type to disable assets for
* @param {string} widgetId The widget ID to disable assets for
* @return {Promise} Resolves on completion
*/
disableWidgetAssets(widgetType, widgetId) {
disableWidgetAssets(widgetType: WidgetType, widgetId) {
let url = this.apiUrl + '/widgets/set_assets_state';
url = this.getStarterLink(url);
return new Promise((resolve, reject) => {
request({
method: 'GET',
method: 'GET', // XXX: Actions shouldn't be GET requests
uri: url,
json: true,
qs: {
'widget_type': widgetType,
'widget_type': widgetType.preferred,
'widget_id': widgetId,
'state': 'disable',
},

View file

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

View file

@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,6 +20,8 @@ export interface ConfigOptions {
}
export const DEFAULTS: ConfigOptions = {
// Brand name of the app
brand: "Element",
// URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server
@ -30,8 +32,6 @@ export const DEFAULTS: ConfigOptions = {
jitsi: {
// Default conference domain
preferredDomain: "jitsi.riot.im",
// Default Jitsi Meet API location
externalApiUrl: "https://jitsi.riot.im/libs/external_api.min.js",
},
};

View file

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

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket 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.
@ -17,9 +18,10 @@ limitations under the License.
*/
import React from 'react';
import * as React from 'react';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import {_t, _td} from './languageHandler';
import Modal from './Modal';
@ -33,12 +35,26 @@ import { abbreviateUrl } from './utils/UrlUtils';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
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 { ensureDMExists } from "./createRoom";
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { Action } from "./dispatcher/actions";
import { EffectiveMembership, getEffectiveMembership } from "./utils/membership";
const singleMxcUpload = async () => {
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event {
target: HTMLInputElement & EventTarget;
}
const singleMxcUpload = async (): Promise<any> => {
return new Promise((resolve) => {
const fileSelector = document.createElement('input');
fileSelector.setAttribute('type', 'file');
fileSelector.onchange = (ev) => {
fileSelector.onchange = (ev: HTMLInputEvent) => {
const file = ev.target.files[0];
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
@ -62,28 +78,49 @@ export const CommandCategories = {
"other": _td("Other"),
};
class Command {
constructor({name, args='', description, runFn, category=CommandCategories.other, hideCompletionAfterSpace=false}) {
this.command = '/' + name;
this.args = args;
this.description = description;
this.runFn = runFn;
this.category = category;
this.hideCompletionAfterSpace = hideCompletionAfterSpace;
type RunFn = ((roomId: string, args: string, cmd: string) => {error: any} | {promise: Promise<any>});
interface ICommandOpts {
command: string;
aliases?: string[];
args?: string;
description: string;
runFn?: RunFn;
category: string;
hideCompletionAfterSpace?: boolean;
}
export class Command {
command: string;
aliases: string[];
args: undefined | string;
description: string;
runFn: undefined | RunFn;
category: string;
hideCompletionAfterSpace: boolean;
constructor(opts: ICommandOpts) {
this.command = opts.command;
this.aliases = opts.aliases || [];
this.args = opts.args || "";
this.description = opts.description;
this.runFn = opts.runFn;
this.category = opts.category || CommandCategories.other;
this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false;
}
getCommand() {
return this.command;
return `/${this.command}`;
}
getCommandWithArgs() {
return this.getCommand() + " " + this.args;
}
run(roomId, args) {
run(roomId: string, args: string, cmd: string) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) return;
return this.runFn.bind(this)(roomId, args);
if (!this.runFn) return reject(_t("Command error"));
return this.runFn.bind(this)(roomId, args, cmd);
}
getUsage() {
@ -95,7 +132,7 @@ function reject(error) {
return {error};
}
function success(promise) {
function success(promise?: Promise<any>) {
return {promise};
}
@ -103,11 +140,9 @@ function success(promise) {
* functions are called with `this` bound to the Command instance.
*/
/* eslint-disable babel/no-invalid-this */
export const CommandMap = {
shrug: new Command({
name: 'shrug',
export const Commands = [
new Command({
command: 'shrug',
args: '<message>',
description: _td('Prepends ¯\\_(ツ)_/¯ to a plain-text message'),
runFn: function(roomId, args) {
@ -119,8 +154,8 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
plain: new Command({
name: 'plain',
new Command({
command: 'plain',
args: '<message>',
description: _td('Sends a message as plain text, without interpreting it as markdown'),
runFn: function(roomId, messages) {
@ -128,11 +163,20 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
ddg: new Command({
name: 'ddg',
new Command({
command: 'html',
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));
},
category: CommandCategories.messages,
}),
new Command({
command: 'ddg',
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
runFn: function(roomId, args) {
runFn: function() {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
@ -144,9 +188,8 @@ export const CommandMap = {
category: CommandCategories.actions,
hideCompletionAfterSpace: true,
}),
upgraderoom: new Command({
name: 'upgraderoom',
new Command({
command: 'upgraderoom',
args: '<new_version>',
description: _td('Upgrades a room to a new version'),
runFn: function(roomId, args) {
@ -215,9 +258,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
nick: new Command({
name: 'nick',
new Command({
command: 'nick',
args: '<display_name>',
description: _td('Changes your display nickname'),
runFn: function(roomId, args) {
@ -228,9 +270,9 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
myroomnick: new Command({
name: 'myroomnick',
new Command({
command: 'myroomnick',
aliases: ['roomnick'],
args: '<display_name>',
description: _td('Changes your display nickname in the current room only'),
runFn: function(roomId, args) {
@ -247,9 +289,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
roomavatar: new Command({
name: 'roomavatar',
new Command({
command: 'roomavatar',
args: '[<mxc_url>]',
description: _td('Changes the avatar of the current room'),
runFn: function(roomId, args) {
@ -265,9 +306,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
myroomavatar: new Command({
name: 'myroomavatar',
new Command({
command: 'myroomavatar',
args: '[<mxc_url>]',
description: _td('Changes your avatar in this current room only'),
runFn: function(roomId, args) {
@ -292,9 +332,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
myavatar: new Command({
name: 'myavatar',
new Command({
command: 'myavatar',
args: '[<mxc_url>]',
description: _td('Changes your avatar in all rooms'),
runFn: function(roomId, args) {
@ -310,9 +349,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
topic: new Command({
name: 'topic',
new Command({
command: 'topic',
args: '[<topic>]',
description: _td('Gets or sets the room topic'),
runFn: function(roomId, args) {
@ -321,7 +359,7 @@ export const CommandMap = {
return success(cli.setRoomTopic(roomId, args));
}
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Failed to set topic"));
const topicEvents = room.currentState.getStateEvents('m.room.topic', '');
const topic = topicEvents && topicEvents.getContent().topic;
@ -331,14 +369,14 @@ export const CommandMap = {
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
title: room.name,
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
hasCloseButton: true,
});
return success();
},
category: CommandCategories.admin,
}),
roomname: new Command({
name: 'roomname',
new Command({
command: 'roomname',
args: '<name>',
description: _td('Sets the room name'),
runFn: function(roomId, args) {
@ -349,9 +387,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
invite: new Command({
name: 'invite',
new Command({
command: 'invite',
args: '<user-id>',
description: _td('Invites user with given id to current room'),
runFn: function(roomId, args) {
@ -364,14 +401,16 @@ export const CommandMap = {
// If we need an identity server but don't have one, things
// get a bit more complex here, but we try to show something
// meaningful.
let finished = Promise.resolve();
let prom = Promise.resolve();
if (
getAddressType(address) === 'email' &&
!MatrixClientPeg.get().getIdentityServerUrl()
) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) {
({ finished } = Modal.createTrackedDialog('Slash Commands', 'Identity server',
const { finished } = Modal.createTrackedDialog<[boolean]>(
'Slash Commands',
'Identity server',
QuestionDialog, {
title: _t("Use an identity server"),
description: <p>{_t(
@ -384,18 +423,21 @@ export const CommandMap = {
)}</p>,
button: _t("Continue"),
},
));
);
prom = finished.then(([useDefault]) => {
if (useDefault) {
useDefaultIdentityServer();
return;
}
throw new Error(_t("Use an identity server to invite by email. Manage in Settings."));
});
} else {
return reject(_t("Use an identity server to invite by email. Manage in Settings."));
}
}
const inviter = new MultiInviter(roomId);
return success(finished.then(([useDefault] = []) => {
if (useDefault) {
useDefaultIdentityServer();
} else if (useDefault === false) {
throw new Error(_t("Use an identity server to invite by email. Manage in Settings."));
}
return success(prom.then(() => {
return inviter.invite([address]);
}).then(() => {
if (inviter.getCompletionState(address) !== "invited") {
@ -408,12 +450,12 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
join: new Command({
name: 'join',
args: '<room-alias>',
description: _td('Joins room with given alias'),
runFn: function(roomId, args) {
new Command({
command: 'join',
aliases: ['j', 'goto'],
args: '<room-address>',
description: _td('Joins room with given address'),
runFn: function(_, args) {
if (args) {
// Note: we support 2 versions of this command. The first is
// the public-facing one for most users and the other is a
@ -456,8 +498,7 @@ export const CommandMap = {
});
return success();
} else if (params[0][0] === '!') {
const roomId = params[0];
const viaServers = params.splice(0);
const [roomId, ...viaServers] = params;
dis.dispatch({
action: 'view_room',
@ -521,10 +562,9 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
part: new Command({
name: 'part',
args: '[<room-alias>]',
new Command({
command: 'part',
args: '[<room-address>]',
description: _td('Leave room'),
runFn: function(roomId, args) {
const cli = MatrixClientPeg.get();
@ -556,7 +596,7 @@ export const CommandMap = {
}
if (targetRoomId) break;
}
if (!targetRoomId) return reject(_t('Unrecognised room alias:') + ' ' + roomAlias);
if (!targetRoomId) return reject(_t('Unrecognised room address:') + ' ' + roomAlias);
}
}
@ -569,9 +609,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
kick: new Command({
name: 'kick',
new Command({
command: 'kick',
args: '<user-id> [reason]',
description: _td('Kicks user with given id'),
runFn: function(roomId, args) {
@ -585,10 +624,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
// Ban a user from the room with an optional reason
ban: new Command({
name: 'ban',
new Command({
command: 'ban',
args: '<user-id> [reason]',
description: _td('Bans user with given id'),
runFn: function(roomId, args) {
@ -602,10 +639,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
// Unban a user from ythe room
unban: new Command({
name: 'unban',
new Command({
command: 'unban',
args: '<user-id>',
description: _td('Unbans user with given ID'),
runFn: function(roomId, args) {
@ -620,16 +655,15 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
ignore: new Command({
name: 'ignore',
new Command({
command: 'ignore',
args: '<user-id>',
description: _td('Ignores a user, hiding their messages from you'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
const matches = args.match(/^(@[^:]+:\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
@ -651,16 +685,15 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
unignore: new Command({
name: 'unignore',
new Command({
command: 'unignore',
args: '<user-id>',
description: _td('Stops ignoring a user, showing their messages going forward'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
const matches = args.match(/(^@[^:]+:\S+$)/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
@ -683,10 +716,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
// Define the power level of a user
op: new Command({
name: 'op',
new Command({
command: 'op',
args: '<user-id> [<power-level>]',
description: _td('Define the power level of a user'),
runFn: function(roomId, args) {
@ -696,13 +727,16 @@ export const CommandMap = {
if (matches) {
const userId = matches[1];
if (matches.length === 4 && undefined !== matches[3]) {
powerLevel = parseInt(matches[3]);
powerLevel = parseInt(matches[3], 10);
}
if (!isNaN(powerLevel)) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Command failed"));
const member = room.getMember(args);
if (!member || getEffectiveMembership(member.membership) === EffectiveMembership.Leave) {
return reject(_t("Could not find user in room"));
}
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
}
@ -712,10 +746,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
// Reset the power level of a user
deop: new Command({
name: 'deop',
new Command({
command: 'deop',
args: '<user-id>',
description: _td('Deops user with given id'),
runFn: function(roomId, args) {
@ -724,9 +756,10 @@ export const CommandMap = {
if (matches) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Command failed"));
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
if (!powerLevelEvent.getContent().users[args]) return reject(_t("Could not find user in room"));
return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent));
}
}
@ -734,9 +767,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
devtools: new Command({
name: 'devtools',
new Command({
command: 'devtools',
description: _td('Opens the Developer Tools dialog'),
runFn: function(roomId) {
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
@ -745,31 +777,60 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
addwidget: new Command({
name: 'addwidget',
args: '<url>',
new Command({
command: 'addwidget',
args: '<url | embed code | Jitsi url>',
description: _td('Adds a custom widget by URL to the room'),
runFn: function(roomId, args) {
if (!args || (!args.startsWith("https://") && !args.startsWith("http://"))) {
runFn: function(roomId, widgetUrl) {
if (!widgetUrl) {
return reject(_t("Please supply a widget URL or embed code"));
}
// Try and parse out a widget URL from iframes
if (widgetUrl.toLowerCase().startsWith("<iframe ")) {
// We use parse5, which doesn't render/create a DOM node. It instead runs
// some superfast regex over the text so we don't have to.
const embed = parseHtml(widgetUrl);
if (embed && embed.childNodes && embed.childNodes.length === 1) {
const iframe = embed.childNodes[0];
if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) {
const srcAttr = iframe.attrs.find(a => a.name === 'src');
console.log("Pulling URL out of iframe (embed code)");
widgetUrl = srcAttr.value;
}
}
}
if (!widgetUrl.startsWith("https://") && !widgetUrl.startsWith("http://")) {
return reject(_t("Please supply a https:// or http:// widget URL"));
}
if (WidgetUtils.canUserModifyWidgets(roomId)) {
const userId = MatrixClientPeg.get().getUserId();
const nowMs = (new Date()).getTime();
const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`);
return success(WidgetUtils.setRoomWidget(
roomId, widgetId, "m.custom", args, "Custom Widget", {}));
let type = WidgetType.CUSTOM;
let name = "Custom Widget";
let data = {};
// Make the widget a Jitsi widget if it looks like a Jitsi widget
const jitsiData = Jitsi.getInstance().parsePreferredConferenceUrl(widgetUrl);
if (jitsiData) {
console.log("Making /addwidget widget a Jitsi conference");
type = WidgetType.JITSI;
name = "Jitsi Conference";
data = jitsiData;
widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
}
return success(WidgetUtils.setRoomWidget(roomId, widgetId, type, widgetUrl, name, data));
} else {
return reject(_t("You cannot modify widgets in this room."));
}
},
category: CommandCategories.admin,
}),
// Verify a user, device, and pubkey tuple
verify: new Command({
name: 'verify',
new Command({
command: 'verify',
args: '<user-id> <device-id> <device-signing-key>',
description: _td('Verifies a user, session, and pubkey tuple'),
runFn: function(roomId, args) {
@ -783,7 +844,7 @@ export const CommandMap = {
const fingerprint = matches[3];
return success((async () => {
const device = await cli.getStoredDevice(userId, deviceId);
const device = cli.getStoredDevice(userId, deviceId);
if (!device) {
throw new Error(_t('Unknown (user, session) pair:') + ` (${userId}, ${deviceId})`);
}
@ -834,20 +895,8 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
me: new Command({
name: 'me',
args: '<message>',
description: _td('Displays action'),
category: CommandCategories.messages,
hideCompletionAfterSpace: true,
}),
discardsession: new Command({
name: 'discardsession',
new Command({
command: 'discardsession',
description: _td('Forces the current outbound group session in an encrypted room to be discarded'),
runFn: function(roomId) {
try {
@ -859,9 +908,8 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
rainbow: new Command({
name: "rainbow",
new Command({
command: "rainbow",
description: _td("Sends the given message coloured as a rainbow"),
args: '<message>',
runFn: function(roomId, args) {
@ -870,9 +918,8 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
rainbowme: new Command({
name: "rainbowme",
new Command({
command: "rainbowme",
description: _td("Sends the given emote coloured as a rainbow"),
args: '<message>',
runFn: function(roomId, args) {
@ -881,9 +928,8 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
help: new Command({
name: "help",
new Command({
command: "help",
description: _td("Displays list of commands with usages and descriptions"),
runFn: function() {
const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog');
@ -893,52 +939,119 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
whois: new Command({
name: "whois",
new Command({
command: "whois",
description: _td("Displays information about a user"),
args: '<user-id>',
args: "<user-id>",
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage());
}
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
dis.dispatch({
action: 'view_user',
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the
// receiver wants.
member: member || {userId},
});
return success();
},
category: CommandCategories.advanced,
}),
};
/* eslint-enable babel/no-invalid-this */
new Command({
command: "rageshake",
aliases: ["bugreport"],
description: _td("Send a bug report with logs"),
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!'),
});
}),
);
},
category: CommandCategories.advanced,
}),
new Command({
command: "query",
description: _td("Opens chat with the given user"),
args: "<user-id>",
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage());
}
return success((async () => {
dis.dispatch({
action: 'view_room',
room_id: await ensureDMExists(MatrixClientPeg.get(), userId),
});
})());
},
category: CommandCategories.actions,
}),
new Command({
command: "msg",
description: _td("Sends a message to the given user"),
args: "<user-id> <message>",
runFn: function(_, args) {
if (args) {
// matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
if (matches) {
const [userId, msg] = matches.slice(1);
if (msg && userId && userId.startsWith("@") && userId.includes(":")) {
return success((async () => {
const cli = MatrixClientPeg.get();
const roomId = await ensureDMExists(cli, userId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
cli.sendTextMessage(roomId, msg);
})());
}
}
}
// helpful aliases
const aliases = {
j: "join",
newballsplease: "discardsession",
goto: "join", // because it handles event permalinks magically
roomnick: "myroomnick",
};
return reject(this.getUsage());
},
category: CommandCategories.actions,
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
new Command({
command: "me",
args: '<message>',
description: _td('Displays action'),
category: CommandCategories.messages,
hideCompletionAfterSpace: true,
}),
];
/**
* Process the given text for /commands and return a bound method to perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
* @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
* 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) {
// build a map from names and aliases to the Command objects.
export const CommandMap = new Map();
Commands.forEach(cmd => {
CommandMap.set(cmd.command, cmd);
cmd.aliases.forEach(alias => {
CommandMap.set(alias, cmd);
});
});
export function parseCommandString(input) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/') return null; // not a command
if (input[0] !== '/') return {}; // not a command
const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
let cmd;
@ -950,10 +1063,21 @@ export function getCommand(roomId, input) {
cmd = input;
}
if (aliases[cmd]) {
cmd = aliases[cmd];
}
if (CommandMap[cmd]) {
return () => CommandMap[cmd].run(roomId, args);
return {cmd, args};
}
/**
* Process the given text for /commands and return a bound method to perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
* @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function getCommand(roomId, input) {
const {cmd, args} = parseCommandString(input);
if (CommandMap.has(cmd)) {
return () => CommandMap.get(cmd).run(roomId, args, cmd);
}
}

View file

@ -603,6 +603,7 @@ const stateHandlers = {
'm.room.guest_access': textForGuestAccessEvent,
'm.room.related_groups': textForRelatedGroupsEvent,
// TODO: Enable support for m.widget event type (https://github.com/vector-im/riot-web/issues/13111)
'im.vector.modular.widgets': textForWidgetEvent,
};

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from './dispatcher';
import dis from './dispatcher/dispatcher';
import Timer from './utils/Timer';
// important these are larger than the timeouts of timers

View file

@ -41,9 +41,18 @@ if (!global.mxToWidgetMessaging) {
const OUTBOUND_API_NAME = 'toWidget';
export default class WidgetMessaging {
constructor(widgetId, widgetUrl, isUserWidget, target) {
/**
* @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.widgetUrl = widgetUrl;
this.wurl = wurl;
this.renderedUrl = renderedUrl;
this.isUserWidget = isUserWidget;
this.target = target;
this.fromWidget = global.mxFromWidgetMessaging;
@ -87,12 +96,23 @@ export default class WidgetMessaging {
});
}
/**
* 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.warn('Requesting screenshot for', this.widgetId);
console.log('Requesting screenshot for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "screenshot",
@ -106,12 +126,12 @@ export default class WidgetMessaging {
* @return {Promise} To be resolved with an array of requested widget capabilities
*/
getCapabilities() {
console.warn('Requesting capabilities for', this.widgetId);
console.log('Requesting capabilities for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "capabilities",
}).then((response) => {
console.warn('Got capabilities for', this.widgetId, response.capabilities);
console.log('Got capabilities for', this.widgetId, response.capabilities);
return response.capabilities;
});
}
@ -128,19 +148,19 @@ export default class WidgetMessaging {
}
start() {
this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl);
this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl);
this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
}
stop() {
this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl);
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.widgetUrl, this.isUserWidget);
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget);
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
@ -161,7 +181,7 @@ export default class WidgetMessaging {
// Actually ask for permission to send the user's data
Modal.createTrackedDialog("OpenID widget permissions", '',
WidgetOpenIDPermissionsDialog, {
widgetUrl: this.widgetUrl,
widgetUrl: this.wurl,
widgetId: this.widgetId,
isUserWidget: this.isUserWidget,

View file

@ -34,6 +34,7 @@ export enum Categories {
CALLS = "Calls",
COMPOSER = "Composer",
ROOM_LIST = "Room List",
ROOM = "Room",
AUTOCOMPLETE = "Autocomplete",
}
@ -142,6 +143,34 @@ const shortcuts: Record<Categories, IShortcut[]> = {
},
],
[Categories.ROOM]: [
{
keybinds: [{
key: Key.PAGE_UP,
}, {
key: Key.PAGE_DOWN,
}],
description: _td("Scroll up/down in the timeline"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Dismiss read marker and jump to bottom"),
}, {
keybinds: [{
modifiers: [Modifiers.SHIFT],
key: Key.PAGE_UP,
}],
description: _td("Jump to oldest unread message"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],
key: Key.U,
}],
description: _td("Upload a file"),
}
],
[Categories.ROOM_LIST]: [
{
keybinds: [{
@ -181,13 +210,6 @@ const shortcuts: Record<Categories, IShortcut[]> = {
[Categories.NAVIGATION]: [
{
keybinds: [{
key: Key.PAGE_UP,
}, {
key: Key.PAGE_DOWN,
}],
description: _td("Scroll up/down in the timeline"),
}, {
keybinds: [{
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
key: Key.ARROW_UP,
@ -257,10 +279,11 @@ const shortcuts: Record<Categories, IShortcut[]> = {
const categoryOrder = [
Categories.COMPOSER,
Categories.CALLS,
Categories.ROOM_LIST,
Categories.AUTOCOMPLETE,
Categories.ROOM,
Categories.ROOM_LIST,
Categories.NAVIGATION,
Categories.CALLS,
];
interface IModal {

View file

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

View file

@ -0,0 +1,69 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {IState, RovingTabIndexProvider} from "./RovingTabIndex";
import {Key} from "../Keyboard";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
}
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
const Toolbar: React.FC<IProps> = ({children, ...props}) => {
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
const target = ev.target as HTMLElement;
let handled = true;
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (target.hasAttribute('aria-haspopup')) {
target.click();
}
break;
case Key.ARROW_LEFT:
case Key.ARROW_RIGHT:
if (state.refs.length > 0) {
const i = state.refs.findIndex(r => r === state.activeRef);
const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
}
break;
// HOME and END are handled by RovingTabIndexProvider
default:
handled = false;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
{ children }
</div>}
</RovingTabIndexProvider>;
};
export default Toolbar;

View file

@ -0,0 +1,51 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
// whether or not the context menu is currently open
isExpanded: boolean;
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton: React.FC<IProps> = ({
label,
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,47 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
interface IProps extends React.ComponentProps<typeof AccessibleTooltipButton> {
// whether or not the context menu is currently open
isExpanded: boolean;
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuTooltipButton: React.FC<IProps> = ({
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
return (
<AccessibleTooltipButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleTooltipButton>
);
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
@ -15,21 +16,15 @@ 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 React from "react";
export default createReactClass({
displayName: 'RoomDropTarget',
interface IProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
}
render: function() {
return (
<div className="mx_RoomDropTarget_container">
<div className="mx_RoomDropTarget">
<div className="mx_RoomDropTarget_label">
{ this.props.label }
</div>
</div>
</div>
);
},
});
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup: React.FC<IProps> = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};

View file

@ -0,0 +1,35 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
}
// Semantic component for representing a role=menuitem
export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => {
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
return (
<AccessibleButton
{...props}
role="menuitemcheckbox"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
return (
<AccessibleButton
{...props}
role="menuitemradio"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {Key} from "../../Keyboard";
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledCheckbox>
);
};

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {Key} from "../../Keyboard";
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledRadioButton>
);
};

View file

@ -0,0 +1,32 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import {useRovingTabIndex} from "../RovingTabIndex";
import {Ref} from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "onFocus" | "inputRef" | "tabIndex"> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IProps> = ({inputRef, ...props}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

View file

@ -0,0 +1,32 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
import {useRovingTabIndex} from "../RovingTabIndex";
import {Ref} from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "onFocus" | "inputRef" | "tabIndex"> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({inputRef, ...props}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleTooltipButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

View file

@ -0,0 +1,36 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import {useRovingTabIndex} from "../RovingTabIndex";
import {FocusHandler, Ref} from "./types";
interface IProps {
inputRef?: Ref;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: Ref;
});
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper: React.FC<IProps> = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref});
};

View file

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

View file

@ -1,34 +0,0 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { asyncAction } from './actionCreators';
const GroupActions = {};
/**
* Creates an action thunk that will do an asynchronous request to fetch
* the groups to which a user is joined.
*
* @param {MatrixClient} matrixClient the matrix client to query.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
GroupActions.fetchJoinedGroups = function(matrixClient) {
return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups());
};
export default GroupActions;

View file

@ -0,0 +1,34 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { asyncAction } from './actionCreators';
import { AsyncActionPayload } from "../dispatcher/payloads";
import { MatrixClient } from "matrix-js-sdk/src/client";
export default class GroupActions {
/**
* Creates an action thunk that will do an asynchronous request to fetch
* the groups to which a user is joined.
*
* @param {MatrixClient} matrixClient the matrix client to query.
* @returns {AsyncActionPayload} An async action payload.
* @see asyncAction
*/
public static fetchJoinedGroups(matrixClient: MatrixClient): AsyncActionPayload {
return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups(), null);
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from '../dispatcher';
import dis from '../dispatcher/dispatcher';
// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events
// become dispatches in the same place.

View file

@ -1,145 +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 { asyncAction } from './actionCreators';
import RoomListStore, {TAG_DM} from '../stores/RoomListStore';
import Modal from '../Modal';
import * as Rooms from '../Rooms';
import { _t } from '../languageHandler';
import * as sdk from '../index';
const RoomListActions = {};
/**
* Creates an action thunk that will do an asynchronous request to
* tag room.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {Room} room the room to tag.
* @param {string} oldTag the tag to remove (unless oldTag ==== newTag)
* @param {string} newTag the tag with which to tag the room.
* @param {?number} oldIndex the previous position of the room in the
* list of rooms.
* @param {?number} newIndex the new position of the room in the list
* of rooms.
* @returns {function} an action thunk.
* @see asyncAction
*/
RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, newIndex) {
let metaData = null;
// Is the tag ordered manually?
if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
const lists = RoomListStore.getRoomLists();
const newList = [...lists[newTag]];
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
// If the room was moved "down" (increasing index) in the same list we
// need to use the orders of the tiles with indices shifted by +1
const offset = (
newTag === oldTag && oldIndex < newIndex
) ? 1 : 0;
const indexBefore = offset + newIndex - 1;
const indexAfter = offset + newIndex;
const prevOrder = indexBefore <= 0 ?
0 : newList[indexBefore].tags[newTag].order;
const nextOrder = indexAfter >= newList.length ?
1 : newList[indexAfter].tags[newTag].order;
metaData = {
order: (prevOrder + nextOrder) / 2.0,
};
}
return asyncAction('RoomListActions.tagRoom', () => {
const promises = [];
const roomId = room.roomId;
// Evil hack to get DMs behaving
if ((oldTag === undefined && newTag === TAG_DM) ||
(oldTag === TAG_DM && newTag === undefined)
) {
return Rooms.guessAndSetDMRoom(
room, newTag === TAG_DM,
).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set direct chat tag " + err);
Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, {
title: _t('Failed to set direct chat tag'),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
}
const hasChangedSubLists = oldTag !== newTag;
// More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with TAG_DM.
//
// if we moved lists, remove the old tag
if (oldTag && oldTag !== TAG_DM &&
hasChangedSubLists
) {
const promiseToDelete = matrixClient.deleteRoomTag(
roomId, oldTag,
).catch(function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to remove tag " + oldTag + " from room: " + err);
Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, {
title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
promises.push(promiseToDelete);
}
// if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== TAG_DM &&
(hasChangedSubLists || metaData)
) {
// metaData is the body of the PUT to set the tag, so it must
// at least be an empty object.
metaData = metaData || {};
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to add tag " + newTag + " to room: " + err);
Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, {
title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
throw err;
});
promises.push(promiseToAdd);
}
return Promise.all(promises);
}, () => {
// For an optimistic update
return {
room, oldTag, newTag, metaData,
};
});
};
export default RoomListActions;

View file

@ -0,0 +1,153 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { asyncAction } from './actionCreators';
import Modal from '../Modal';
import * as Rooms from '../Rooms';
import { _t } from '../languageHandler';
import * as sdk from '../index';
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { AsyncActionPayload } from "../dispatcher/payloads";
import RoomListStore from "../stores/room-list/RoomListStore";
import { SortAlgorithm } from "../stores/room-list/algorithms/models";
import { DefaultTagID } from "../stores/room-list/models";
export default class RoomListActions {
/**
* Creates an action thunk that will do an asynchronous request to
* tag room.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {Room} room the room to tag.
* @param {string} oldTag the tag to remove (unless oldTag ==== newTag)
* @param {string} newTag the tag with which to tag the room.
* @param {?number} oldIndex the previous position of the room in the
* list of rooms.
* @param {?number} newIndex the new position of the room in the list
* of rooms.
* @returns {AsyncActionPayload} an async action payload
* @see asyncAction
*/
public static tagRoom(
matrixClient: MatrixClient, room: Room,
oldTag: string, newTag: string,
oldIndex: number | null, newIndex: number | null,
): AsyncActionPayload {
let metaData = null;
// Is the tag ordered manually?
const store = RoomListStore.instance;
if (newTag && store.getTagSorting(newTag) === SortAlgorithm.Manual) {
const newList = [...store.orderedLists[newTag]];
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
// If the room was moved "down" (increasing index) in the same list we
// need to use the orders of the tiles with indices shifted by +1
const offset = (
newTag === oldTag && oldIndex < newIndex
) ? 1 : 0;
const indexBefore = offset + newIndex - 1;
const indexAfter = offset + newIndex;
const prevOrder = indexBefore <= 0 ?
0 : newList[indexBefore].tags[newTag].order;
const nextOrder = indexAfter >= newList.length ?
1 : newList[indexAfter].tags[newTag].order;
metaData = {
order: (prevOrder + nextOrder) / 2.0,
};
}
return asyncAction('RoomListActions.tagRoom', () => {
const promises = [];
const roomId = room.roomId;
// Evil hack to get DMs behaving
if ((oldTag === undefined && newTag === DefaultTagID.DM) ||
(oldTag === DefaultTagID.DM && newTag === undefined)
) {
return Rooms.guessAndSetDMRoom(
room, newTag === DefaultTagID.DM,
).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set direct chat tag " + err);
Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, {
title: _t('Failed to set direct chat tag'),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
}
const hasChangedSubLists = oldTag !== newTag;
// More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with TAG_DM.
//
// if we moved lists, remove the old tag
if (oldTag && oldTag !== DefaultTagID.DM &&
hasChangedSubLists
) {
const promiseToDelete = matrixClient.deleteRoomTag(
roomId, oldTag,
).catch(function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to remove tag " + oldTag + " from room: " + err);
Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, {
title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
promises.push(promiseToDelete);
}
// if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== DefaultTagID.DM &&
(hasChangedSubLists || metaData)
) {
// metaData is the body of the PUT to set the tag, so it must
// at least be an empty object.
metaData = metaData || {};
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to add tag " + newTag + " to room: " + err);
Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, {
title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
throw err;
});
promises.push(promiseToAdd);
}
return Promise.all(promises);
}, () => {
// For an optimistic update
return {
room, oldTag, newTag, metaData,
};
});
}
}

View file

@ -1,109 +0,0 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Analytics from '../Analytics';
import { asyncAction } from './actionCreators';
import TagOrderStore from '../stores/TagOrderStore';
const TagOrderActions = {};
/**
* Creates an action thunk that will do an asynchronous request to
* move a tag in TagOrderStore to destinationIx.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {string} tag the tag to move.
* @param {number} destinationIx the new position of the tag.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
// Only commit tags if the state is ready, i.e. not null
let tags = TagOrderStore.getOrderedTags();
let removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
if (!tags) {
return;
}
tags = tags.filter((t) => t !== tag);
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
removedTags = removedTags.filter((t) => t !== tag);
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {tags, removedTags};
});
};
/**
* Creates an action thunk that will do an asynchronous request to
* label a tag as removed in im.vector.web.tag_ordering account data.
*
* The reason this is implemented with new state `removedTags` is that
* we incrementally and initially populate `tags` with groups that
* have been joined. If we remove a group from `tags`, it will just
* get added (as it looks like a group we've recently joined).
*
* NB: If we ever support adding of tags (which is planned), we should
* take special care to remove the tag from `removedTags` when we add
* it.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {string} tag the tag to remove.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
TagOrderActions.removeTag = function(matrixClient, tag) {
// Don't change tags, just removedTags
const tags = TagOrderStore.getOrderedTags();
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
if (removedTags.includes(tag)) {
// Return a thunk that doesn't do anything, we don't even need
// an asynchronous action here, the tag is already removed.
return () => {};
}
removedTags.push(tag);
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.removeTag', () => {
Analytics.trackEvent('TagOrderActions', 'removeTag');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {removedTags};
});
};
export default TagOrderActions;

View file

@ -0,0 +1,110 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Analytics from '../Analytics';
import { asyncAction } from './actionCreators';
import TagOrderStore from '../stores/TagOrderStore';
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.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {string} tag the tag to move.
* @param {number} destinationIx the new position of the tag.
* @returns {AsyncActionPayload} an async action payload that will
* dispatch actions indicating the status of the request.
* @see asyncAction
*/
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() || [];
if (!tags) {
return;
}
tags = tags.filter((t) => t !== tag);
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
removedTags = removedTags.filter((t) => t !== tag);
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {tags, removedTags};
});
}
/**
* Creates an action thunk that will do an asynchronous request to
* label a tag as removed in im.vector.web.tag_ordering account data.
*
* The reason this is implemented with new state `removedTags` is that
* we incrementally and initially populate `tags` with groups that
* have been joined. If we remove a group from `tags`, it will just
* get added (as it looks like a group we've recently joined).
*
* NB: If we ever support adding of tags (which is planned), we should
* take special care to remove the tag from `removedTags` when we add
* it.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {string} tag the tag to remove.
* @returns {function} an async action payload that will dispatch
* actions indicating the status of the request.
* @see asyncAction
*/
public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload {
// Don't change tags, just removedTags
const tags = TagOrderStore.getOrderedTags();
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
if (removedTags.includes(tag)) {
// Return a thunk that doesn't do anything, we don't even need
// an asynchronous action here, the tag is already removed.
return new AsyncActionPayload(() => {});
}
removedTags.push(tag);
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.removeTag', () => {
Analytics.trackEvent('TagOrderActions', 'removeTag');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {removedTags};
});
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AsyncActionPayload } from "../dispatcher/payloads";
/**
* Create an action thunk that will dispatch actions indicating the current
* status of the Promise returned by fn.
@ -25,9 +28,9 @@ limitations under the License.
* @param {function?} pendingFn a function that returns an object to assign
* to the `request` key of the ${id}.pending
* payload.
* @returns {function} an action thunk - a function that uses its single
* argument as a dispatch function to dispatch the
* following actions:
* @returns {AsyncActionPayload} an async action payload. Includes a function
* that uses its single argument as a dispatch function
* to dispatch the following actions:
* `${id}.pending` and either
* `${id}.success` or
* `${id}.failure`.
@ -41,12 +44,11 @@ limitations under the License.
* result is the result of the promise returned by
* `fn`.
*/
export function asyncAction(id, fn, pendingFn) {
return (dispatch) => {
export function asyncAction(id: string, fn: () => Promise<any>, pendingFn: () => any | null): AsyncActionPayload {
const helper = (dispatch) => {
dispatch({
action: id + '.pending',
request:
typeof pendingFn === 'function' ? pendingFn() : undefined,
request: typeof pendingFn === 'function' ? pendingFn() : undefined,
});
fn().then((result) => {
dispatch({action: id + '.success', result});
@ -54,4 +56,5 @@ export function asyncAction(id, fn, pendingFn) {
dispatch({action: id + '.failure', err});
});
};
return new AsyncActionPayload(helper);
}

View file

@ -1,206 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {Key} from "../../../Keyboard";
import * as sdk from "../../../index";
// XXX: This component is not cross-signing aware.
// https://github.com/vector-im/riot-web/issues/11752 tracks either updating this
// component or taking it out to pasture.
export default createReactClass({
displayName: 'EncryptedEventDialog',
propTypes: {
event: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
},
getInitialState: function() {
return { device: null };
},
componentWillMount: function() {
this._unmounted = false;
const client = MatrixClientPeg.get();
// first try to load the device from our store.
//
this.refreshDevice().then((dev) => {
if (dev) {
return dev;
}
// tell the client to try to refresh the device list for this user
return client.downloadKeys([this.props.event.getSender()], true).then(() => {
return this.refreshDevice();
});
}).then((dev) => {
if (this._unmounted) {
return;
}
this.setState({ device: dev });
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
}, (err)=>{
console.log("Error downloading devices", err);
});
},
componentWillUnmount: function() {
this._unmounted = true;
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
}
},
refreshDevice: function() {
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
return Promise.resolve(MatrixClientPeg.get().getEventSenderDeviceInfo(this.props.event));
},
onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.event.getSender()) {
this.refreshDevice().then((dev) => {
this.setState({ device: dev });
});
}
},
onKeyDown: function(e) {
if (e.key === Key.ESCAPE) {
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
}
},
_renderDeviceInfo: function() {
const device = this.state.device;
if (!device) {
return (<i>{ _t('unknown device') }</i>);
}
let verificationStatus = (<b>{ _t('NOT verified') }</b>);
if (device.isBlocked()) {
verificationStatus = (<b>{ _t('Blacklisted') }</b>);
} else if (device.isVerified()) {
verificationStatus = _t('verified');
}
return (
<table>
<tbody>
<tr>
<td>{ _t('Name') }</td>
<td>{ device.getDisplayName() }</td>
</tr>
<tr>
<td>{ _t('Device ID') }</td>
<td><code>{ device.deviceId }</code></td>
</tr>
<tr>
<td>{ _t('Verification') }</td>
<td>{ verificationStatus }</td>
</tr>
<tr>
<td>{ _t('Ed25519 fingerprint') }</td>
<td><code>{ device.getFingerprint() }</code></td>
</tr>
</tbody>
</table>
);
},
_renderEventInfo: function() {
const event = this.props.event;
return (
<table>
<tbody>
<tr>
<td>{ _t('User ID') }</td>
<td>{ event.getSender() }</td>
</tr>
<tr>
<td>{ _t('Curve25519 identity key') }</td>
<td><code>{ event.getSenderKey() || <i>{ _t('none') }</i> }</code></td>
</tr>
<tr>
<td>{ _t('Claimed Ed25519 fingerprint key') }</td>
<td><code>{ event.getKeysClaimed().ed25519 || <i>{ _t('none') }</i> }</code></td>
</tr>
<tr>
<td>{ _t('Algorithm') }</td>
<td>{ event.getWireContent().algorithm || <i>{ _t('unencrypted') }</i> }</td>
</tr>
{
event.getContent().msgtype === 'm.bad.encrypted' ? (
<tr>
<td>{ _t('Decryption error') }</td>
<td>{ event.getContent().body }</td>
</tr>
) : null
}
<tr>
<td>{ _t('Session ID') }</td>
<td><code>{ event.getWireContent().session_id || <i>{ _t('none') }</i> }</code></td>
</tr>
</tbody>
</table>
);
},
render: function() {
const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
let buttons = null;
if (this.state.device) {
buttons = (
<DeviceVerifyButtons device={this.state.device}
userId={this.props.event.getSender()}
/>
);
}
return (
<div className="mx_EncryptedEventDialog" onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_title">
{ _t('End-to-end encryption information') }
</div>
<div className="mx_Dialog_content">
<h4>{ _t('Event information') }</h4>
{ this._renderEventInfo() }
<h4>{ _t('Sender session information') }</h4>
{ this._renderDeviceInfo() }
</div>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={true}>
{ _t('OK') }
</button>
{ buttons }
</div>
</div>
);
},
});

View file

@ -42,7 +42,8 @@ export default createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._unmounted = false;
this._passphrase1 = createRef();

View file

@ -54,7 +54,8 @@ export default createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._unmounted = false;
this._file = createRef();

View file

@ -17,11 +17,12 @@ limitations under the License.
import React from 'react';
import * as sdk from '../../../../index';
import PropTypes from 'prop-types';
import dis from "../../../../dispatcher";
import dis from "../../../../dispatcher/dispatcher";
import { _t } from '../../../../languageHandler';
import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore";
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
import {Action} from "../../../../dispatcher/actions";
/*
* Allows the user to disable the Event Index.
@ -47,7 +48,7 @@ export default class DisableEventIndexDialog extends React.Component {
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
this.props.onFinished();
dis.dispatch({ action: 'view_user_settings' });
dis.fire(Action.ViewUserSettings);
}
render() {

View file

@ -18,6 +18,7 @@ import React from 'react';
import * as sdk from '../../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../../languageHandler';
import SdkConfig from '../../../../SdkConfig';
import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore";
import Modal from '../../../../Modal';
@ -30,7 +31,7 @@ import EventIndexPeg from "../../../../indexing/EventIndexPeg";
export default class ManageEventIndexDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
};
constructor(props) {
super(props);
@ -82,7 +83,7 @@ export default class ManageEventIndexDialog extends React.Component {
}
}
async componentWillMount(): void {
async componentDidMount(): void {
let eventIndexSize = 0;
let crawlingRoomsCount = 0;
let roomCount = 0;
@ -126,39 +127,35 @@ export default class ManageEventIndexDialog extends React.Component {
import("./DisableEventIndexDialog"),
null, null, /* priority = */ false, /* static = */ true,
);
}
_onDone = () => {
this.props.onFinished(true);
}
};
_onCrawlerSleepTimeChange = (e) => {
this.setState({crawlerSleepTime: e.target.value});
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
}
};
render() {
let crawlerState;
const brand = SdkConfig.get().brand;
const Field = sdk.getComponent('views.elements.Field');
let crawlerState;
if (this.state.currentRoom === null) {
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 })
);
}
const Field = sdk.getComponent('views.elements.Field');
const doneRooms = Math.max(0, (this.state.roomCount - this.state.crawlingRoomsCount));
const eventIndexingSettings = (
<div>
{
_t( "Riot is securely caching encrypted messages locally for them " +
"to appear in search results:",
)
}
{_t(
"%(brand)s is securely caching encrypted messages locally for them " +
"to appear in search results:",
{ brand },
)}
<div className='mx_SettingsTab_subsectionText'>
{crawlerState}<br />
{_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}<br />

View file

@ -15,16 +15,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import FileSaver from 'file-saver';
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import PropTypes from 'prop-types';
import { scorePassword } from '../../../../utils/PasswordScorer';
import { _t } from '../../../../languageHandler';
import {_t, _td} from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager';
import SettingsStore from '../../../../settings/SettingsStore';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import {copyNode} from "../../../../utils/strings";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
const PHASE_PASSPHRASE = 0;
const PHASE_PASSPHRASE_CONFIRM = 1;
@ -35,17 +35,6 @@ const PHASE_DONE = 5;
const PHASE_OPTOUT_CONFIRM = 6;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
// XXX: copied from ShareDialog: factor out into utils
function selectText(target) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/*
* Walks the user through the process of creating an e2e key backup
@ -61,25 +50,23 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
this._recoveryKeyNode = null;
this._keyBackupInfo = null;
this._setZxcvbnResultTimeout = null;
this.state = {
secureSecretStorage: null,
phase: PHASE_PASSPHRASE,
passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '',
copied: false,
downloaded: false,
zxcvbnResult: null,
};
this._passphraseField = createRef();
}
async componentDidMount() {
const cli = MatrixClientPeg.get();
const secureSecretStorage = (
SettingsStore.isFeatureEnabled("feature_cross_signing") &&
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
);
const secureSecretStorage = await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
this.setState({ secureSecretStorage });
// If we're using secret storage, skip ahead to the backing up step, as
@ -90,19 +77,12 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
}
}
componentWillUnmount() {
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
}
_collectRecoveryKeyNode = (n) => {
this._recoveryKeyNode = n;
}
_onCopyClick = () => {
selectText(this._recoveryKeyNode);
const successful = document.execCommand('copy');
const successful = copyNode(this._recoveryKeyNode);
if (successful) {
this.setState({
copied: true,
@ -190,22 +170,16 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_onPassPhraseNextClick = async (e) => {
e.preventDefault();
if (!this._passphraseField.current) return; // unmounting
// If we're waiting for the timeout before updating the result at this point,
// skip ahead and do it now, otherwise we'll deny the attempt to proceed
// even if the user entered a valid passphrase
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
if (this._passPhraseIsValid()) {
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
await this._passphraseField.current.validate({ allowEmpty: false });
if (!this._passphraseField.current.state.valid) {
this._passphraseField.current.focus();
this._passphraseField.current.validate({ allowEmpty: false, focused: true });
return;
}
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
};
_onPassPhraseConfirmNextClick = async (e) => {
@ -224,9 +198,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_onSetAgainClick = () => {
this.setState({
passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '',
phase: PHASE_PASSPHRASE,
zxcvbnResult: null,
});
}
@ -236,23 +210,16 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
});
}
_onPassPhraseValidate = (result) => {
this.setState({
passPhraseValid: result.valid,
});
};
_onPassPhraseChange = (e) => {
this.setState({
passPhrase: e.target.value,
});
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
this._setZxcvbnResultTimeout = setTimeout(() => {
this._setZxcvbnResultTimeout = null;
this.setState({
// precompute this and keep it in state: zxcvbn is fast but
// we use it in a couple of different places so no point recomputing
// it unnecessarily.
zxcvbnResult: scorePassword(this.state.passPhrase),
});
}, PASSPHRASE_FEEDBACK_DELAY);
}
_onPassPhraseConfirmChange = (e) => {
@ -261,35 +228,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
});
}
_passPhraseIsValid() {
return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE;
}
_renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let strengthMeter;
let helpText;
if (this.state.zxcvbnResult) {
if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) {
helpText = _t("Great! This passphrase looks strong enough.");
} else {
const suggestions = [];
for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) {
suggestions.push(<div key={i}>{this.state.zxcvbnResult.feedback.suggestions[i]}</div>);
}
const suggestionBlock = <div>{suggestions.length > 0 ? suggestions : _t("Keep going...")}</div>;
helpText = <div>
{this.state.zxcvbnResult.feedback.warning}
{suggestionBlock}
</div>;
}
strengthMeter = <div>
<progress max={PASSWORD_MIN_SCORE} value={this.state.zxcvbnResult.score} />
</div>;
}
return <form onSubmit={this._onPassPhraseNextClick}>
<p>{_t(
"<b>Warning</b>: You should only set up key backup from a trusted computer.", {},
@ -297,23 +238,25 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
)}</p>
<p>{_t(
"We'll store an encrypted copy of your keys on our server. " +
"Protect your backup with a passphrase to keep it secure.",
"Secure your backup with a recovery passphrase.",
)}</p>
<p>{_t("For maximum security, this should be different from your account password.")}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
<input type="password"
onChange={this._onPassPhraseChange}
value={this.state.passPhrase}
<PassphraseField
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Enter a passphrase...")}
onChange={this._onPassPhraseChange}
minScore={PASSWORD_MIN_SCORE}
value={this.state.passPhrase}
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.")}
/>
<div className="mx_CreateKeyBackupDialog_passPhraseHelp">
{strengthMeter}
{helpText}
</div>
</div>
</div>
@ -321,7 +264,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
primaryButton={_t('Next')}
onPrimaryButtonClick={this._onPassPhraseNextClick}
hasCancel={false}
disabled={!this._passPhraseIsValid()}
disabled={!this.state.passPhraseValid}
/>
<details>
@ -337,8 +280,10 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let matchText;
let changeText;
if (this.state.passPhraseConfirm === this.state.passPhrase) {
matchText = _t("That matches!");
changeText = _t("Use a different passphrase?");
} else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) {
// only tell them they're wrong if they've actually gone wrong.
// Security concious readers will note that if you left riot-web unattended
@ -348,6 +293,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
// Note that not having typed anything at all will not hit this clause and
// fall through so empty box === no hint.
matchText = _t("That doesn't match.");
changeText = _t("Go back to set it again.");
}
let passPhraseMatch = null;
@ -356,7 +302,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<div>{matchText}</div>
<div>
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}>
{_t("Go back to set it again.")}
{changeText}
</AccessibleButton>
</div>
</div>;
@ -364,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 passphrase a second time to confirm.",
"Please enter your recovery passphrase a second time to confirm.",
)}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
@ -373,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 passphrase...")}
placeholder={_t("Repeat your recovery passphrase...")}
autoFocus={true}
/>
</div>
@ -393,7 +339,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
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 passphrase.",
"access to your encrypted messages if you forget your recovery passphrase.",
)}</p>
<p>{_t(
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
@ -487,9 +433,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_titleForPhase(phase) {
switch (phase) {
case PHASE_PASSPHRASE:
return _t('Secure your backup with a passphrase');
return _t('Secure your backup with a recovery passphrase');
case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm your passphrase');
return _t('Confirm your recovery passphrase');
case PHASE_OPTOUT_CONFIRM:
return _t('Warning!');
case PHASE_SHOWKEY:

View file

@ -19,9 +19,10 @@ import React from "react";
import PropTypes from "prop-types";
import * as sdk from "../../../../index";
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import dis from "../../../../dispatcher";
import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import {Action} from "../../../../dispatcher/actions";
export default class NewRecoveryMethodDialog extends React.PureComponent {
static propTypes = {
@ -36,7 +37,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
onGoToSettingsClick = () => {
this.props.onFinished();
dis.dispatch({ action: 'view_user_settings' });
dis.fire(Action.ViewUserSettings);
}
onSetupClick = async () => {
@ -57,8 +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 recovery passphrase and key for Secure Messages have been detected.",
)}</p>;
const hackWarning = <p className="warning">{_t(

View file

@ -18,9 +18,10 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import * as sdk from "../../../../index";
import dis from "../../../../dispatcher";
import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import {Action} from "../../../../dispatcher/actions";
export default class RecoveryMethodRemovedDialog extends React.PureComponent {
static propTypes = {
@ -29,7 +30,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent {
onGoToSettingsClick = () => {
this.props.onFinished();
dis.dispatch({ action: 'view_user_settings' });
dis.fire(Action.ViewUserSettings);
}
onSetupClick = () => {

View file

@ -15,38 +15,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { scorePassword } from '../../../../utils/PasswordScorer';
import FileSaver from 'file-saver';
import { _t } from '../../../../languageHandler';
import {_t, _td} from '../../../../languageHandler';
import Modal from '../../../../Modal';
import { promptForBackupPassphrase } from '../../../../CrossSigningManager';
import {copyNode} from "../../../../utils/strings";
import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
import StyledRadioButton from '../../../../components/views/elements/StyledRadioButton';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
const PHASE_LOADING = 0;
const PHASE_MIGRATE = 1;
const PHASE_PASSPHRASE = 2;
const PHASE_PASSPHRASE_CONFIRM = 3;
const PHASE_SHOWKEY = 4;
const PHASE_KEEPITSAFE = 5;
const PHASE_STORING = 6;
const PHASE_DONE = 7;
const PHASE_CONFIRM_SKIP = 8;
const PHASE_LOADERROR = 1;
const PHASE_CHOOSE_KEY_PASSPHRASE = 2;
const PHASE_MIGRATE = 3;
const PHASE_PASSPHRASE = 4;
const PHASE_PASSPHRASE_CONFIRM = 5;
const PHASE_SHOWKEY = 6;
const PHASE_STORING = 8;
const PHASE_CONFIRM_SKIP = 10;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
// XXX: copied from ShareDialog: factor out into utils
function selectText(target) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// these end up as strings from being values in the radio buttons, so just use strings
const CREATE_STORAGE_OPTION_KEY = 'key';
const CREATE_STORAGE_OPTION_PASSPHRASE = 'passphrase';
/*
* Walks the user through the process of creating a passphrase to guard Secure
@ -67,18 +66,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
constructor(props) {
super(props);
this._keyInfo = null;
this._encodedRecoveryKey = null;
this._recoveryKey = null;
this._recoveryKeyNode = null;
this._setZxcvbnResultTimeout = null;
this._backupKey = null;
this.state = {
phase: PHASE_LOADING,
passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '',
copied: false,
downloaded: false,
zxcvbnResult: null,
setPassphrase: false,
backupInfo: null,
backupSigStatus: null,
// does the server offer a UI auth flow with just m.login.password
@ -86,43 +85,54 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
canUploadKeysWithPasswordOnly: null,
accountPassword: props.accountPassword || "",
accountPasswordCorrect: null,
// status of the key backup toggle switch
useKeyBackup: true,
passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY,
};
this._passphraseField = createRef();
this._fetchBackupInfo();
this._queryKeyUploadAuth();
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
// signing key upload as well. This avoids hitting the server to
// test auth flows, which may be slow under high load.
this.state.canUploadKeysWithPasswordOnly = true;
} else {
this._queryKeyUploadAuth();
}
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
}
componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
}
async _fetchBackupInfo() {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = (
// we may not have started crypto yet, in which case we definitely don't trust the backup
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
);
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = (
// we may not have started crypto yet, in which case we definitely don't trust the backup
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
);
const { force } = this.props;
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE;
const { force } = this.props;
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE;
this.setState({
phase,
backupInfo,
backupSigStatus,
});
this.setState({
phase,
backupInfo,
backupSigStatus,
});
return {
backupInfo,
backupSigStatus,
};
return {
backupInfo,
backupSigStatus,
};
} catch (e) {
this.setState({phase: PHASE_LOADERROR});
}
}
async _queryKeyUploadAuth() {
@ -133,8 +143,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
// no keys which would be a no-op.
console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
} catch (error) {
if (!error.data.flows) {
if (!error.data || !error.data.flows) {
console.log("uploadDeviceSigningKeys advertised no flows!");
return;
}
const canUploadKeysWithPasswordOnly = error.data.flows.some(f => {
return f.stages.length === 1 && f.stages[0] === 'm.login.password';
@ -149,14 +160,33 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo();
}
_onKeyPassphraseChange = e => {
this.setState({
passPhraseKeySelected: e.target.value,
});
}
_collectRecoveryKeyNode = (n) => {
this._recoveryKeyNode = n;
}
_onUseKeyBackupChange = (enabled) => {
this.setState({
useKeyBackup: enabled,
});
_onChooseKeyPassphraseFormSubmit = async () => {
if (this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY) {
this._recoveryKey =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase();
this.setState({
copied: false,
downloaded: false,
setPassphrase: false,
phase: PHASE_SHOWKEY,
});
} else {
this.setState({
copied: false,
downloaded: false,
phase: PHASE_PASSPHRASE,
});
}
}
_onMigrateFormSubmit = (e) => {
@ -169,25 +199,22 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_onCopyClick = () => {
selectText(this._recoveryKeyNode);
const successful = document.execCommand('copy');
const successful = copyNode(this._recoveryKeyNode);
if (successful) {
this.setState({
copied: true,
phase: PHASE_KEEPITSAFE,
});
}
}
_onDownloadClick = () => {
const blob = new Blob([this._encodedRecoveryKey], {
const blob = new Blob([this._recoveryKey.encodedPrivateKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
this.setState({
downloaded: true,
phase: PHASE_KEEPITSAFE,
});
}
@ -199,18 +226,39 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
type: 'm.id.user',
user: MatrixClientPeg.get().getUserId(),
},
// https://github.com/matrix-org/synapse/issues/5665
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
user: MatrixClientPeg.get().getUserId(),
password: this.state.accountPassword,
});
} else {
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("To continue, use Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm encryption setup"),
body: _t("Click the button below to confirm setting up encryption."),
continueText: _t("Confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createTrackedDialog(
'Cross-signing keys dialog', '', InteractiveAuthDialog,
{
title: _t("Setting up keys"),
matrixClient: MatrixClientPeg.get(),
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
},
);
const [confirmed] = await finished;
@ -232,24 +280,31 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
try {
if (force) {
console.log("Forcing secret storage reset"); // log something so we can debug this later
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._keyInfo,
createSecretStorageKey: async () => this._recoveryKey,
setupNewKeyBackup: true,
setupNewSecretStorage: true,
});
} else {
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._keyInfo,
createSecretStorageKey: async () => this._recoveryKey,
keyBackupInfo: this.state.backupInfo,
setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup,
getKeyBackupPassphrase: promptForBackupPassphrase,
setupNewKeyBackup: !this.state.backupInfo,
getKeyBackupPassphrase: () => {
// We may already have the backup key if we earlier went
// through the restore backup path, so pass it along
// rather than prompting again.
if (this._backupKey) {
return this._backupKey;
}
return promptForBackupPassphrase();
},
});
}
this.setState({
phase: PHASE_DONE,
});
this.props.onFinished(true);
} catch (e) {
if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) {
this.setState({
@ -273,10 +328,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_restoreBackup = async () => {
// It's possible we'll need the backup key later on for bootstrapping,
// 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, {showSummary: false}, null,
/* priority = */ false, /* static = */ false,
'Restore Backup', '', RestoreKeyBackupDialog,
{
showSummary: false,
keyCallback,
},
null, /* priority = */ false, /* static = */ false,
);
await finished;
@ -290,44 +353,35 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
}
_onSkipSetupClick = () => {
_onLoadRetryClick = () => {
this.setState({phase: PHASE_LOADING});
this._fetchBackupInfo();
}
_onShowKeyContinueClick = () => {
this._bootstrapSecretStorage();
}
_onCancelClick = () => {
this.setState({phase: PHASE_CONFIRM_SKIP});
}
_onSetUpClick = () => {
this.setState({phase: PHASE_PASSPHRASE});
}
_onSkipPassPhraseClick = async () => {
const [keyInfo, encodedRecoveryKey] =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase();
this._keyInfo = keyInfo;
this._encodedRecoveryKey = encodedRecoveryKey;
this.setState({
copied: false,
downloaded: false,
phase: PHASE_SHOWKEY,
});
_onGoBackClick = () => {
this.setState({phase: PHASE_CHOOSE_KEY_PASSPHRASE});
}
_onPassPhraseNextClick = async (e) => {
e.preventDefault();
if (!this._passphraseField.current) return; // unmounting
// If we're waiting for the timeout before updating the result at this point,
// skip ahead and do it now, otherwise we'll deny the attempt to proceed
// even if the user entered a valid passphrase
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
if (this._passPhraseIsValid()) {
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
await this._passphraseField.current.validate({ allowEmpty: false });
if (!this._passphraseField.current.state.valid) {
this._passphraseField.current.focus();
this._passphraseField.current.validate({ allowEmpty: false, focused: true });
return;
}
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
};
_onPassPhraseConfirmNextClick = async (e) => {
@ -335,13 +389,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
if (this.state.passPhrase !== this.state.passPhraseConfirm) return;
const [keyInfo, encodedRecoveryKey] =
this._recoveryKey =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase);
this._keyInfo = keyInfo;
this._encodedRecoveryKey = encodedRecoveryKey;
this.setState({
copied: false,
downloaded: false,
setPassphrase: true,
phase: PHASE_SHOWKEY,
});
}
@ -349,35 +402,22 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_onSetAgainClick = () => {
this.setState({
passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '',
phase: PHASE_PASSPHRASE,
zxcvbnResult: null,
});
}
_onKeepItSafeBackClick = () => {
_onPassPhraseValidate = (result) => {
this.setState({
phase: PHASE_SHOWKEY,
passPhraseValid: result.valid,
});
}
};
_onPassPhraseChange = (e) => {
this.setState({
passPhrase: e.target.value,
});
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
this._setZxcvbnResultTimeout = setTimeout(() => {
this._setZxcvbnResultTimeout = null;
this.setState({
// precompute this and keep it in state: zxcvbn is fast but
// we use it in a couple of different places so no point recomputing
// it unnecessarily.
zxcvbnResult: scorePassword(this.state.passPhrase),
});
}, PASSPHRASE_FEEDBACK_DELAY);
}
_onPassPhraseConfirmChange = (e) => {
@ -386,23 +426,61 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
});
}
_passPhraseIsValid() {
return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE;
}
_onAccountPasswordChange = (e) => {
this.setState({
accountPassword: e.target.value,
});
}
_renderPhaseChooseKeyPassphrase() {
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>
<DialogButtons
primaryButton={_t("Continue")}
onPrimaryButtonClick={this._onChooseKeyPassphraseFormSubmit}
onCancel={this._onCancelClick}
hasCancel={true}
/>
</form>;
}
_renderPhaseMigrate() {
// TODO: This is a temporary screen so people who have the labs flag turned on and
// click the button are aware they're making a change to their account.
// Once we're confident enough in this (and it's supported enough) we can do
// it automatically.
// https://github.com/vector-im/riot-web/issues/11696
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
let authPrompt;
@ -415,7 +493,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
label={_t("Password")}
value={this.state.accountPassword}
onChange={this._onAccountPasswordChange}
flagInvalid={this.state.accountPasswordCorrect === false}
forceValidity={this.state.accountPasswordCorrect === false ? false : null}
autoFocus={true}
/></div>
</div>;
@ -443,7 +521,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
hasCancel={false}
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
>
<button type="button" className="danger" onClick={this._onSkipSetupClick}>
<button type="button" className="danger" onClick={this._onCancelClick}>
{_t('Skip')}
</button>
</DialogButtons>
@ -451,97 +529,50 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
let strengthMeter;
let helpText;
if (this.state.zxcvbnResult) {
if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) {
helpText = _t("Great! This passphrase looks strong enough.");
} else {
// We take the warning from zxcvbn or failing that, the first
// suggestion. In practice The first is generally the most relevant
// and it's probably better to present the user with one thing to
// improve about their password than a whole collection - it can
// spit out a warning and multiple suggestions which starts getting
// very information-dense.
const suggestion = (
this.state.zxcvbnResult.feedback.warning ||
this.state.zxcvbnResult.feedback.suggestions[0]
);
const suggestionBlock = <div>{suggestion || _t("Keep going...")}</div>;
helpText = <div>
{suggestionBlock}
</div>;
}
strengthMeter = <div>
<progress max={PASSWORD_MIN_SCORE} value={this.state.zxcvbnResult.score} />
</div>;
}
return <form onSubmit={this._onPassPhraseNextClick}>
<p>{_t(
"Set up encryption on this session to allow it to verify other sessions, " +
"granting them access to encrypted messages and marking them as trusted for other users.",
)}</p>
<p>{_t(
"Secure your encryption keys with a passphrase. For maximum security " +
"this should be different to your account password:",
"Enter a security phrase only you know, as its used to safeguard your data. " +
"To be secure, you shouldnt re-use your account password.",
)}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field
type="password"
<PassphraseField
className="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseChange}
minScore={PASSWORD_MIN_SCORE}
value={this.state.passPhrase}
label={_t("Enter a passphrase")}
onValidate={this._onPassPhraseValidate}
fieldRef={this._passphraseField}
autoFocus={true}
autoComplete="new-password"
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.")}
/>
<div className="mx_CreateSecretStorageDialog_passPhraseHelp">
{strengthMeter}
{helpText}
</div>
</div>
<LabelledToggleSwitch
label={ _t("Back up my encryption keys, securing them with the same passphrase")}
onChange={this._onUseKeyBackupChange} value={this.state.useKeyBackup}
/>
<DialogButtons
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNextClick}
hasCancel={false}
disabled={!this._passPhraseIsValid()}
disabled={!this.state.passPhraseValid}
>
<button type="button"
onClick={this._onSkipSetupClick}
onClick={this._onCancelClick}
className="danger"
>{_t("Skip")}</button>
>{_t("Cancel")}</button>
</DialogButtons>
<details>
<summary>{_t("Advanced")}</summary>
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a recovery key")}
</AccessibleButton>
</details>
</form>;
}
_renderPhasePassPhraseConfirm() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Field = sdk.getComponent('views.elements.Field');
let matchText;
let changeText;
if (this.state.passPhraseConfirm === this.state.passPhrase) {
matchText = _t("That matches!");
changeText = _t("Use a different passphrase?");
} else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) {
// only tell them they're wrong if they've actually gone wrong.
// Security concious readers will note that if you left riot-web unattended
@ -551,6 +582,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
// Note that not having typed anything at all will not hit this clause and
// fall through so empty box === no hint.
matchText = _t("That doesn't match.");
changeText = _t("Go back to set it again.");
}
let passPhraseMatch = null;
@ -559,15 +591,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div>{matchText}</div>
<div>
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}>
{_t("Go back to set it again.")}
{changeText}
</AccessibleButton>
</div>
</div>;
}
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t(
"Enter your passphrase a second time to confirm it.",
"Enter your recovery passphrase a second time to confirm it.",
)}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field
@ -575,7 +606,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
onChange={this._onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm}
className="mx_CreateSecretStorageDialog_passPhraseField"
label={_t("Confirm your passphrase")}
label={_t("Confirm your recovery passphrase")}
autoFocus={true}
autoComplete="new-password"
/>
@ -590,7 +621,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
>
<button type="button"
onClick={this._onSkipSetupClick}
onClick={this._onCancelClick}
className="danger"
>{_t("Skip")}</button>
</DialogButtons>
@ -598,62 +629,48 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_renderPhaseShowKey() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let continueButton;
if (this.state.phase === PHASE_SHOWKEY) {
continueButton = <DialogButtons primaryButton={_t("Continue")}
disabled={!this.state.downloaded && !this.state.copied && !this.state.setPassphrase}
onPrimaryButtonClick={this._onShowKeyContinueClick}
hasCancel={false}
/>;
} else {
continueButton = <div className="mx_CreateSecretStorageDialog_continueSpinner">
<InlineSpinner />
</div>;
}
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 passphrase.",
)}</p>
<p>{_t(
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
"Store your Security Key somewhere safe, like a password manager or a safe, " +
"as its used to safeguard your encrypted data.",
)}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKeyHeader">
{_t("Your recovery key")}
</div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKey">
<code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code>
<code ref={this._collectRecoveryKeyNode}>{this._recoveryKey.encodedPrivateKey}</code>
</div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
<AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onCopyClick}>
{_t("Copy")}
</AccessibleButton>
<AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onDownloadClick}>
<AccessibleButton kind='primary' className="mx_Dialog_primary"
onClick={this._onDownloadClick}
disabled={this.state.phase === PHASE_STORING}
>
{_t("Download")}
</AccessibleButton>
<span>{_t("or")}</span>
<AccessibleButton
kind='primary'
className="mx_Dialog_primary mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn"
onClick={this._onCopyClick}
disabled={this.state.phase === PHASE_STORING}
>
{this.state.copied ? _t("Copied!") : _t("Copy")}
</AccessibleButton>
</div>
</div>
</div>
</div>;
}
_renderPhaseKeepItSafe() {
let introText;
if (this.state.copied) {
introText = _t(
"Your recovery 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.",
{}, {b: s => <b>{s}</b>},
);
}
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
{introText}
<ul>
<li>{_t("<b>Print it</b> and store it somewhere safe", {}, {b: s => <b>{s}</b>})}</li>
<li>{_t("<b>Save it</b> on a USB key or backup drive", {}, {b: s => <b>{s}</b>})}</li>
<li>{_t("<b>Copy it</b> to your personal cloud storage", {}, {b: s => <b>{s}</b>})}</li>
</ul>
<DialogButtons primaryButton={_t("Continue")}
onPrimaryButtonClick={this._bootstrapSecretStorage}
hasCancel={false}>
<button onClick={this._onKeepItSafeBackClick}>{_t("Back")}</button>
</DialogButtons>
{continueButton}
</div>;
}
@ -664,53 +681,52 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div>;
}
_renderPhaseDone() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
_renderPhaseLoadError() {
return <div>
<p>{_t(
"You can now verify your other devices, " +
"and other users to keep your chats safe.",
)}</p>
<DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this._onDone}
hasCancel={false}
/>
<p>{_t("Unable to query secret storage status")}</p>
<div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._onLoadRetryClick}
hasCancel={true}
onCancel={this._onCancel}
/>
</div>
</div>;
}
_renderPhaseSkipConfirm() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
)}
<p>{_t(
"If you cancel now, you may lose encrypted messages & data if you lose access to your logins.",
)}</p>
<p>{_t(
"You can also set up Secure Backup & manage your keys in Settings.",
)}</p>
<DialogButtons primaryButton={_t('Go back')}
onPrimaryButtonClick={this._onSetUpClick}
onPrimaryButtonClick={this._onGoBackClick}
hasCancel={false}
>
<button type="button" className="danger" onClick={this._onCancel}>{_t('Skip')}</button>
<button type="button" className="danger" onClick={this._onCancel}>{_t('Cancel')}</button>
</DialogButtons>
</div>;
}
_titleForPhase(phase) {
switch (phase) {
case PHASE_CHOOSE_KEY_PASSPHRASE:
return _t('Set up Secure backup');
case PHASE_MIGRATE:
return _t('Upgrade your encryption');
case PHASE_PASSPHRASE:
return _t('Set up encryption');
return _t('Set a Security Phrase');
case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm passphrase');
return _t('Confirm Security Phrase');
case PHASE_CONFIRM_SKIP:
return _t('Are you sure?');
case PHASE_SHOWKEY:
case PHASE_KEEPITSAFE:
return _t('Make a copy of your recovery key');
return _t('Save your Security Key');
case PHASE_STORING:
return _t('Setting up keys');
case PHASE_DONE:
return _t("You're done!");
default:
return '';
}
@ -721,7 +737,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
let content;
if (this.state.error) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
content = <div>
<p>{_t("Unable to set up secret storage")}</p>
<div className="mx_Dialog_buttons">
@ -737,6 +752,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
case PHASE_LOADING:
content = this._renderBusyPhase();
break;
case PHASE_LOADERROR:
content = this._renderPhaseLoadError();
break;
case PHASE_CHOOSE_KEY_PASSPHRASE:
content = this._renderPhaseChooseKeyPassphrase();
break;
case PHASE_MIGRATE:
content = this._renderPhaseMigrate();
break;
@ -749,31 +770,40 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
case PHASE_SHOWKEY:
content = this._renderPhaseShowKey();
break;
case PHASE_KEEPITSAFE:
content = this._renderPhaseKeepItSafe();
break;
case PHASE_STORING:
content = this._renderBusyPhase();
break;
case PHASE_DONE:
content = this._renderPhaseDone();
break;
case PHASE_CONFIRM_SKIP:
content = this._renderPhaseSkipConfirm();
break;
}
}
let headerImage;
if (this._titleForPhase(this.state.phase)) {
headerImage = require("../../../../../res/img/e2e/normal.svg");
let titleClass = null;
switch (this.state.phase) {
case PHASE_PASSPHRASE:
case PHASE_PASSPHRASE_CONFIRM:
titleClass = [
'mx_CreateSecretStorageDialog_titleWithIcon',
'mx_CreateSecretStorageDialog_securePhraseTitle',
];
break;
case PHASE_SHOWKEY:
titleClass = [
'mx_CreateSecretStorageDialog_titleWithIcon',
'mx_CreateSecretStorageDialog_secureBackupTitle',
];
break;
case PHASE_CHOOSE_KEY_PASSPHRASE:
titleClass = 'mx_CreateSecretStorageDialog_centeredTitle';
break;
}
return (
<BaseDialog className='mx_CreateSecretStorageDialog'
onFinished={this.props.onFinished}
title={this._titleForPhase(this.state.phase)}
headerImage={headerImage}
titleClass={titleClass}
hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)}
fixedWidth={false}
>

View file

@ -17,9 +17,20 @@ limitations under the License.
*/
import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter';
import type {ICompletion, ISelectionRange} from './Autocompleter';
export interface ICommand {
command: string | null;
range: {
start: number;
end: number;
};
}
export default class AutocompleteProvider {
commandRegex: RegExp;
forcedCommandRegex: RegExp;
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) {
if (!commandRegex.global) {
@ -42,25 +53,25 @@ export default class AutocompleteProvider {
/**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
* @param {string} query The query string
* @param {SelectionRange} selection Selection to search
* @param {ISelectionRange} selection Selection to search
* @param {boolean} force True if the user is forcing completion
* @return {object} { command, range } where both objects fields are null if no match
*/
getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false) {
getCurrentCommand(query: string, selection: ISelectionRange, force = false) {
let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) {
commandRegex = this.forcedCommandRegex || /\S+/g;
}
if (commandRegex == null) {
if (!commandRegex) {
return null;
}
commandRegex.lastIndex = 0;
let match;
while ((match = commandRegex.exec(query)) != null) {
while ((match = commandRegex.exec(query)) !== null) {
const start = match.index;
const end = start + match[0].length;
if (selection.start <= end && selection.end >= start) {
@ -82,7 +93,7 @@ export default class AutocompleteProvider {
};
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
return [];
}
@ -90,7 +101,7 @@ export default class AutocompleteProvider {
return 'Default Provider';
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
console.error('stub; should be implemented in subclasses');
return null;
}

View file

@ -15,10 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// @flow
import type {Component} from 'react';
import {Room} from 'matrix-js-sdk';
import {ReactElement} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
@ -27,22 +25,26 @@ import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import {timeout} from "../utils/promise";
import AutocompleteProvider, {ICommand} from "./AutocompleteProvider";
export type SelectionRange = {
beginning: boolean, // whether the selection is in the first block of the editor or not
start: number, // byte offset relative to the start anchor of the current editor selection.
end: number, // byte offset relative to the end anchor of the current editor selection.
};
export interface ISelectionRange {
beginning?: boolean; // whether the selection is in the first block of the editor or not
start: number; // byte offset relative to the start anchor of the current editor selection.
end: number; // byte offset relative to the end anchor of the current editor selection.
}
export type Completion = {
completion: string,
component: ?Component,
range: SelectionRange,
command: ?string,
export interface ICompletion {
type: "at-room" | "command" | "community" | "room" | "user";
completion: string;
completionId?: string;
component?: ReactElement;
range: ISelectionRange;
command?: string;
suffix?: string;
// If provided, apply a LINK entity to the completion with the
// data = { url: href }.
href: ?string,
};
href?: string;
}
const PROVIDERS = [
UserProvider,
@ -57,7 +59,16 @@ const PROVIDERS = [
// Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000;
export interface IProviderCompletions {
completions: ICompletion[];
provider: AutocompleteProvider;
command: ICommand;
}
export default class Autocompleter {
room: Room;
providers: AutocompleteProvider[];
constructor(room: Room) {
this.room = room;
this.providers = PROVIDERS.map((Prov) => {
@ -71,13 +82,14 @@ export default class Autocompleter {
});
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force = false): 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
*/
const completionsList = await Promise.all(this.providers.map(provider => {
// 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);
}));

View file

@ -22,22 +22,23 @@ import {_t} from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher';
import {TextualCompletion} from './Components';
import type {Completion, SelectionRange} from "./Autocompleter";
import {CommandMap} from '../SlashCommands';
const COMMANDS = Object.values(CommandMap);
import {ICompletion, ISelectionRange} from "./Autocompleter";
import {Command, Commands, CommandMap} from '../SlashCommands';
const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider {
matcher: QueryMatcher<Command>;
constructor() {
super(COMMAND_RE);
this.matcher = new QueryMatcher(COMMANDS, {
keys: ['command', 'args', 'description'],
this.matcher = new QueryMatcher(Commands, {
keys: ['command', 'args', 'description'],
funcs: [({aliases}) => aliases.join(" ")], // aliases
});
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection);
if (!command) return [];
@ -46,38 +47,47 @@ 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[name]) {
if (CommandMap.has(name)) {
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
if (CommandMap[name].hideCompletionAfterSpace) return [];
matches = [CommandMap[name]];
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
matches = [CommandMap.get(name)];
}
} else {
if (query === '/') {
// If they have just entered `/` show everything
matches = COMMANDS;
matches = Commands;
} else {
// otherwise fuzzy match against all of the fields
matches = this.matcher.match(command[1]);
}
}
return matches.map((result) => ({
// If the command is the same as the one they entered, we don't want to discard their arguments
completion: result.command === command[1] ? command[0] : (result.command + ' '),
type: "command",
component: <TextualCompletion
title={result.command}
subtitle={result.args}
description={_t(result.description)} />,
range,
}));
return matches.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
if (usedAlias || result.getCommand() === command[1]) {
completion = command[0];
}
return {
completion,
type: "command",
component: <TextualCompletion
title={`/${usedAlias || result.command}`}
subtitle={result.args}
description={_t(result.description)} />,
range,
};
});
}
getName() {
return '*️⃣ ' + _t('Commands');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}>
{ completions }

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import Group from "matrix-js-sdk/src/models/group";
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg';
@ -24,7 +25,7 @@ import {PillCompletion} from './Components';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore";
const COMMUNITY_REGEX = /\B\+\S*/g;
@ -39,6 +40,8 @@ function score(query, space) {
}
export default class CommunityProvider extends AutocompleteProvider {
matcher: QueryMatcher<Group>;
constructor() {
super(COMMUNITY_REGEX);
this.matcher = new QueryMatcher([], {
@ -46,7 +49,7 @@ export default class CommunityProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
// Disable autocompletions when composing commands because of various issues
@ -87,11 +90,12 @@ export default class CommunityProvider extends AutocompleteProvider {
type: "community",
href: makeGroupPermalink(groupId),
component: (
<PillCompletion initialComponent={
<PillCompletion title={name} description={groupId}>
<BaseAvatar name={name || groupId}
width={24} height={24}
width={24}
height={24}
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} />
} title={name} description={groupId} />
</PillCompletion>
),
range,
}))
@ -104,7 +108,7 @@ export default class CommunityProvider extends AutocompleteProvider {
return '💬 ' + _t('Communities');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -1,78 +0,0 @@
/*
Copyright 2016 Aviral Dasgupta
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 classNames from 'classnames';
/* These were earlier stateless functional components but had to be converted
since we need to use refs/findDOMNode to access the underlying DOM node to focus the correct completion,
something that is not entirely possible with stateless functional components. One could
presumably wrap them in a <div> before rendering but I think this is the better way to do it.
*/
export class TextualCompletion extends React.Component {
render() {
const {
title,
subtitle,
description,
className,
...restProps
} = this.props;
return (
<div className={classNames('mx_Autocomplete_Completion_block', className)} role="option" {...restProps}>
<span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
<span className="mx_Autocomplete_Completion_description">{ description }</span>
</div>
);
}
}
TextualCompletion.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
className: PropTypes.string,
};
export class PillCompletion extends React.Component {
render() {
const {
title,
subtitle,
description,
initialComponent,
className,
...restProps
} = this.props;
return (
<div className={classNames('mx_Autocomplete_Completion_pill', className)} role="option" {...restProps}>
{ initialComponent }
<span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
<span className="mx_Autocomplete_Completion_description">{ description }</span>
</div>
);
}
}
PillCompletion.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
initialComponent: PropTypes.element,
className: PropTypes.string,
};

View file

@ -0,0 +1,66 @@
/*
Copyright 2016 Aviral Dasgupta
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, {forwardRef} from 'react';
import classNames from 'classnames';
/* These were earlier stateless functional components but had to be converted
since we need to use refs/findDOMNode to access the underlying DOM node to focus the correct completion,
something that is not entirely possible with stateless functional components. One could
presumably wrap them in a <div> before rendering but I think this is the better way to do it.
*/
interface ITextualCompletionProps {
title?: string;
subtitle?: string;
description?: string;
className?: string;
}
export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props, ref) => {
const {title, subtitle, description, className, ...restProps} = props;
return (
<div {...restProps}
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>
<span className="mx_Autocomplete_Completion_description">{ description }</span>
</div>
);
});
interface IPillCompletionProps extends ITextualCompletionProps {
children?: React.ReactNode;
}
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}
>
{ children }
<span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
<span className="mx_Autocomplete_Completion_description">{ description }</span>
</div>
);
});

View file

@ -21,7 +21,7 @@ import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {TextualCompletion} from './Components';
import type {SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
@ -31,12 +31,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
super(DDG_REGEX);
}
static getQueryUri(query: String) {
static getQueryUri(query: string) {
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false) {
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];
@ -95,7 +95,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
return '🔍 ' + _t('Results from DuckDuckGo');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_block"

View file

@ -22,36 +22,37 @@ import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import type {Completion, SelectionRange} from './Autocompleter';
import {ICompletion, ISelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils';
import { EMOJI, IEmoji } from '../emoji';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import EMOJIBASE from 'emojibase-data/en/compact.json';
const LIMIT = 20;
// Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', 'g');
// XXX: it's very unclear why we bother with this generated emojidata file.
// all it means is that we end up bloating the bundle with precomputed stuff
// which would be trivial to calculate and cache on demand.
const EMOJI_SHORTNAMES = EMOJIBASE.sort((a, b) => {
interface IEmojiShort {
emoji: IEmoji;
shortname: string;
_orderBy: number;
}
const EMOJI_SHORTNAMES: IEmojiShort[] = EMOJI.sort((a, b) => {
if (a.group === b.group) {
return a.order - b.order;
}
return a.group - b.group;
}).map((emoji, index) => {
return {
emoji,
shortname: `:${emoji.shortcodes[0]}:`,
// Include the index so that we can preserve the original order
_orderBy: index,
};
});
}).map((emoji, index) => ({
emoji,
shortname: `:${emoji.shortcodes[0]}:`,
// Include the index so that we can preserve the original order
_orderBy: index,
}));
function score(query, space) {
const index = space.indexOf(query);
@ -63,9 +64,12 @@ function score(query, space) {
}
export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher<IEmojiShort>;
nameMatcher: QueryMatcher<IEmojiShort>;
constructor() {
super(EMOJI_REGEX);
this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, {
this.matcher = new QueryMatcher<IEmojiShort>(EMOJI_SHORTNAMES, {
keys: ['emoji.emoticon', 'shortname'],
funcs: [
(o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases
@ -80,7 +84,7 @@ export default class EmojiProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them
}
@ -100,6 +104,8 @@ export default class EmojiProvider extends AutocompleteProvider {
// then sort by score (Infinity if matchedString not in shortname)
sorters.push((c) => score(matchedString, c.shortname));
// then sort by max score of all shortcodes, trim off the `:`
sorters.push((c) => Math.min(...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s))));
// If the matchedString is not empty, sort by length of shortname. Example:
// matchedString = ":bookmark"
// completions = [":bookmark:", ":bookmark_tabs:", ...]
@ -115,9 +121,9 @@ export default class EmojiProvider extends AutocompleteProvider {
return {
completion: unicode,
component: (
<PillCompletion title={shortname} aria-label={unicode} initialComponent={
<span style={{maxWidth: '1em'}}>{ unicode }</span>
} />
<PillCompletion title={shortname} aria-label={unicode}>
<span>{ unicode }</span>
</PillCompletion>
),
range,
};
@ -130,7 +136,7 @@ export default class EmojiProvider extends AutocompleteProvider {
return '😃 ' + _t('Emoji');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}>
{ completions }

View file

@ -15,22 +15,25 @@ limitations under the License.
*/
import React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler';
import {MatrixClientPeg} from '../MatrixClientPeg';
import {PillCompletion} from './Components';
import * as sdk from '../index';
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
const AT_ROOM_REGEX = /@\S*/g;
export default class NotifProvider extends AutocompleteProvider {
room: Room;
constructor(room) {
super(AT_ROOM_REGEX);
this.room = room;
}
async getCompletions(query: string, selection: SelectionRange, force:boolean = false): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();
@ -45,7 +48,9 @@ export default class NotifProvider extends AutocompleteProvider {
type: "at-room",
suffix: ' ',
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
<PillCompletion title="@room" description={_t("Notify the whole room")}>
<RoomAvatar width={24} height={24} room={this.room} />
</PillCompletion>
),
range,
}];
@ -57,7 +62,7 @@ export default class NotifProvider extends AutocompleteProvider {
return '❗️ ' + _t('Room Notification');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -1,4 +1,3 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
@ -18,19 +17,24 @@ limitations under the License.
*/
import _at from 'lodash/at';
import _flatMap from 'lodash/flatMap';
import _sortBy from 'lodash/sortBy';
import _uniq from 'lodash/uniq';
import {removeHiddenChars} from "matrix-js-sdk/src/utils";
function stripDiacritics(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
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;
}
/**
* Simple search matcher that matches any results with the query string anywhere
* in the search string. Returns matches in the order the query string appears
* in the search key, earliest first, then in the order the items appeared in
* the source array.
* in the search key, earliest first, then in the order the search key appears
* in the provided array of keys, then in the order the items appeared in the
* source array.
*
* @param {Object[]} objects Initial list of objects. Equivalent to calling
* setObjects() after construction
@ -39,11 +43,12 @@ function stripDiacritics(str: string): string {
* @param {function[]} options.funcs List of functions that when called with the
* object as an arg will return a string to use as an index
*/
export default class QueryMatcher {
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
export default class QueryMatcher<T extends Object> {
private _options: IOptions<T>;
private _items: Map<string, {object: T, keyWeight: number}[]>;
constructor(objects: T[], options: IOptions<T> = { keys: [] }) {
this._options = options;
this._keys = options.keys;
this._funcs = options.funcs || [];
this.setObjects(objects);
@ -60,61 +65,85 @@ export default class QueryMatcher {
}
}
setObjects(objects: Array<Object>) {
setObjects(objects: T[]) {
this._items = new Map();
for (const object of objects) {
const keyValues = _at(object, this._keys);
// Need to use unsafe coerce here because the objects can have any
// 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);
for (const f of this._funcs) {
keyValues.push(f(object));
if (this._options.funcs) {
for (const f of this._options.funcs) {
keyValues.push(f(object));
}
}
for (const keyValue of keyValues) {
for (const [index, keyValue] of Object.entries(keyValues)) {
if (!keyValue) continue; // skip falsy keyValues
const key = stripDiacritics(keyValue).toLowerCase();
const key = this.processQuery(keyValue);
if (!this._items.has(key)) {
this._items.set(key, []);
}
this._items.get(key).push(object);
this._items.get(key).push({
keyWeight: Number(index),
object,
});
}
}
}
match(query: String): Array<Object> {
query = stripDiacritics(query).toLowerCase();
match(query: string): T[] {
query = this.processQuery(query);
if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
}
if (query.length === 0) {
return [];
}
const results = [];
const matches = [];
// Iterate through the map & check each key.
// ES6 Map iteration order is defined to be insertion order, so results
// here will come out in the order they were put in.
for (const key of this._items.keys()) {
for (const [key, candidates] of this._items.entries()) {
let resultKey = key;
if (this._options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) {
results.push({key, index});
matches.push(
...candidates.map((candidate) => ({index, ...candidate})),
);
}
}
// Sort them by where the query appeared in the search key
// lodash sortBy is a stable sort, so results where the query
// appeared in the same place will retain their order with
// respect to each other.
const sortedResults = _sortBy(results, (candidate) => {
return candidate.index;
// Sort matches by where the query appeared in the search key, then by
// where the matched key appeared in the provided array of keys.
matches.sort((a, b) => {
if (a.index < b.index) {
return -1;
} else if (a.index === b.index) {
if (a.keyWeight < b.keyWeight) {
return -1;
} else if (a.keyWeight === b.keyWeight) {
return 0;
}
}
return 1;
});
// Now map the keys to the result objects. Each result object is a list, so
// flatMap will flatten those lists out into a single list. Also remove any
// duplicates.
return _uniq(_flatMap(sortedResults, (candidate) => this._items.get(candidate.key)));
// Now map the keys to the result objects. Also remove any duplicates.
return _uniq(matches.map((match) => match.object));
}
private processQuery(query: string): string {
if (this._options.fuzzy !== false) {
return removeHiddenChars(query).toLowerCase();
}
return query.toLowerCase();
}
}

View file

@ -18,19 +18,20 @@ limitations under the License.
*/
import React from 'react';
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 _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
import { uniqBy, sortBy } from 'lodash';
const ROOM_REGEX = /\B#\S*/g;
function score(query, space) {
function score(query: string, space: string) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
@ -39,7 +40,7 @@ function score(query, space) {
}
}
function matcherObject(room, displayedAlias, matchName = "") {
function matcherObject(room: Room, displayedAlias: string, matchName = "") {
return {
room,
matchName,
@ -48,6 +49,8 @@ function matcherObject(room, displayedAlias, matchName = "") {
}
export default class RoomProvider extends AutocompleteProvider {
matcher: QueryMatcher<Room>;
constructor() {
super(ROOM_REGEX);
this.matcher = new QueryMatcher([], {
@ -55,7 +58,7 @@ export default class RoomProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();
@ -88,10 +91,11 @@ export default class RoomProvider extends AutocompleteProvider {
this.matcher.setObjects(matcherObjects);
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [
completions = sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
]);
completions = uniqBy(completions, (match) => match.room);
completions = completions.map((room) => {
return {
completion: room.displayedAlias,
@ -100,7 +104,9 @@ export default class RoomProvider extends AutocompleteProvider {
suffix: ' ',
href: makeRoomPermalink(room.displayedAlias),
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.room.name} description={room.displayedAlias} />
<PillCompletion title={room.room.name} description={room.displayedAlias}>
<RoomAvatar width={24} height={24} room={room.room} />
</PillCompletion>
),
range,
};
@ -115,7 +121,7 @@ export default class RoomProvider extends AutocompleteProvider {
return '💬 ' + _t('Rooms');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -1,4 +1,3 @@
//@flow
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
@ -27,9 +26,13 @@ import QueryMatcher from './QueryMatcher';
import _sortBy from 'lodash/sortBy';
import {MatrixClientPeg} from '../MatrixClientPeg';
import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk';
import MatrixEvent from "matrix-js-sdk/src/models/event";
import Room from "matrix-js-sdk/src/models/room";
import RoomMember from "matrix-js-sdk/src/models/room-member";
import RoomState from "matrix-js-sdk/src/models/room-state";
import EventTimeline from "matrix-js-sdk/src/models/event-timeline";
import {makeUserPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
const USER_REGEX = /\B@\S*/g;
@ -37,9 +40,15 @@ const USER_REGEX = /\B@\S*/g;
// to allow you to tab-complete /mat into /(matthew)
const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g;
interface IRoomTimelineData {
timeline: EventTimeline;
liveEvent?: boolean;
}
export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null;
room: Room = null;
matcher: QueryMatcher<RoomMember>;
users: RoomMember[];
room: Room;
constructor(room: Room) {
super(USER_REGEX, FORCED_USER_REGEX);
@ -51,21 +60,19 @@ export default class UserProvider extends AutocompleteProvider {
shouldMatchWordsOnly: false,
});
this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
this._onRoomStateMemberBound = this._onRoomStateMember.bind(this);
MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
}
destroy() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
}
}
_onRoomTimeline(ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean, data: Object) {
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;
@ -79,9 +86,9 @@ export default class UserProvider extends AutocompleteProvider {
// TODO: lazyload if we have no ev.sender room member?
this.onUserSpoke(ev.sender);
}
};
_onRoomStateMember(ev: MatrixEvent, state: RoomState, member: RoomMember) {
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => {
// ignore members in other rooms
if (member.roomId !== this.room.roomId) {
return;
@ -89,16 +96,16 @@ export default class UserProvider extends AutocompleteProvider {
// blow away the users cache
this.users = null;
}
};
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// lazy-load user list into matcher
if (this.users === null) this._makeUsers();
if (!this.users) this._makeUsers();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force);
const {command, range} = this.getCurrentCommand(rawQuery, selection, force);
if (!command) return completions;
@ -118,10 +125,9 @@ export default class UserProvider extends AutocompleteProvider {
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId),
component: (
<PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24} />}
title={displayName}
description={user.userId} />
<PillCompletion title={displayName} description={user.userId}>
<MemberAvatar member={user} width={24} height={24} />
</PillCompletion>
),
range,
};
@ -151,7 +157,7 @@ export default class UserProvider extends AutocompleteProvider {
}
onUserSpoke(user: RoomMember) {
if (this.users === null) return;
if (!this.users) return;
if (!user) return;
if (user.userId === MatrixClientPeg.get().credentials.userId) return;
@ -163,7 +169,7 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher.setObjects(this.users);
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}>
{ completions }

View file

@ -38,12 +38,13 @@ export default class AutoHideScrollbar extends React.Component {
render() {
return (<div
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
>
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,6 +20,7 @@ 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',
@ -38,14 +39,25 @@ export default createReactClass({
},
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 Riot.", {}, { 'b': (sub) => <b>{sub}</b> }) } </p>
<p>{_t(
"Sorry, your browser is <b>not</b> able to run %(brand)s.",
{
brand,
},
{
'b': (sub) => <b>{sub}</b>,
})
}</p>
<p>
{ _t(
"Riot uses many advanced browser features, some of which are not available " +
"%(brand)s uses many advanced browser features, some of which are not available " +
"or experimental in your current browser.",
{ brand },
) }
</p>
<p>

View file

@ -16,13 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useRef, useState} from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import React, {CSSProperties, useRef, useState} from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import {Key} from "../../Keyboard";
import * as sdk from "../../index";
import AccessibleButton from "../views/elements/AccessibleButton";
import {Writeable} from "../../@types/common";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -30,8 +29,8 @@ import AccessibleButton from "../views/elements/AccessibleButton";
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
function getOrCreateContainer() {
let container = document.getElementById(ContextualMenuContainerId);
function getOrCreateContainer(): HTMLDivElement {
let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
if (!container) {
container = document.createElement("div");
@ -43,50 +42,70 @@ function getOrCreateContainer() {
}
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
interface IPosition {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
export enum ChevronFace {
Top = "top",
Bottom = "bottom",
Left = "left",
Right = "right",
None = "none",
}
interface IProps extends IPosition {
menuWidth?: number;
menuHeight?: number;
chevronOffset?: number;
chevronFace?: ChevronFace;
menuPaddingTop?: number;
menuPaddingBottom?: number;
menuPaddingLeft?: number;
menuPaddingRight?: number;
zIndex?: number;
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
hasBackground?: boolean;
// whether this context menu should be focus managed. If false it must handle itself
managed?: boolean;
// Function to be called on menu close
onFinished();
// on resize callback
windowResize?();
}
interface IState {
contextMenuElem: HTMLDivElement;
}
// 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.
export class ContextMenu extends React.Component {
static propTypes = {
top: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number,
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func.isRequired,
menuPaddingTop: PropTypes.number,
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
zIndex: PropTypes.number,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
hasBackground: PropTypes.bool,
// on resize callback
windowResize: PropTypes.func,
managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
};
export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement;
static defaultProps = {
hasBackground: true,
managed: true,
};
constructor() {
super();
constructor(props, context) {
super(props, context);
this.state = {
contextMenuElem: null,
};
// persist what had focus when we got initialized so we can return it after
this.initialFocus = document.activeElement;
this.initialFocus = document.activeElement as HTMLElement;
}
componentWillUnmount() {
@ -94,7 +113,7 @@ export class ContextMenu extends React.Component {
this.initialFocus.focus();
}
collectContextMenuRect = (element) => {
private collectContextMenuRect = (element) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
@ -111,11 +130,12 @@ export class ContextMenu extends React.Component {
});
};
onContextMenu = (e) => {
private onContextMenu = (e) => {
if (this.props.onFinished) {
this.props.onFinished();
e.preventDefault();
e.stopPropagation();
const x = e.clientX;
const y = e.clientY;
@ -133,7 +153,20 @@ export class ContextMenu extends React.Component {
}
};
_onMoveFocus = (element, up) => {
private onContextMenuPreventBubbling = (e) => {
// stop propagation so that any context menu handlers don't leak out of this context menu
// but do not inhibit the default browser menu
e.stopPropagation();
};
// Prevent clicks on the background from going through to the component which opened the menu.
private onFinished = (ev: React.MouseEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (this.props.onFinished) this.props.onFinished();
};
private onMoveFocus = (element: Element, up: boolean) => {
let descending = false; // are we currently descending or ascending through the DOM tree?
do {
@ -167,25 +200,25 @@ export class ContextMenu extends React.Component {
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
if (element) {
element.focus();
(element as HTMLElement).focus();
}
};
_onMoveFocusHomeEnd = (element, up) => {
private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
let results = element.querySelectorAll('[role^="menuitem"]');
if (!results) {
results = element.querySelectorAll('[tab-index]');
}
if (results && results.length) {
if (up) {
results[0].focus();
(results[0] as HTMLElement).focus();
} else {
results[results.length - 1].focus();
(results[results.length - 1] as HTMLElement).focus();
}
}
};
_onKeyDown = (ev) => {
private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.props.managed) {
if (ev.key === Key.ESCAPE) {
this.props.onFinished();
@ -200,19 +233,22 @@ export class ContextMenu extends React.Component {
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_RIGHT:
this.props.onFinished();
break;
case Key.ARROW_UP:
this._onMoveFocus(ev.target, true);
this.onMoveFocus(ev.target as Element, true);
break;
case Key.ARROW_DOWN:
this._onMoveFocus(ev.target, false);
this.onMoveFocus(ev.target as Element, false);
break;
case Key.HOME:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
break;
case Key.END:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
break;
default:
handled = false;
@ -225,9 +261,8 @@ export class ContextMenu extends React.Component {
}
};
renderMenu(hasBackground=this.props.hasBackground) {
const position = {};
let chevronFace = null;
protected renderMenu(hasBackground = this.props.hasBackground) {
const position: Partial<Writeable<DOMRect>> = {};
const props = this.props;
if (props.top) {
@ -236,24 +271,24 @@ export class ContextMenu extends React.Component {
position.bottom = props.bottom;
}
let chevronFace: ChevronFace;
if (props.left) {
position.left = props.left;
chevronFace = 'left';
chevronFace = ChevronFace.Left;
} else {
position.right = props.right;
chevronFace = 'right';
chevronFace = ChevronFace.Right;
}
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const padding = 10;
const chevronOffset = {};
const chevronOffset: CSSProperties = {};
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
const hasChevron = chevronFace && chevronFace !== "none";
const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
if (chevronFace === 'top' || chevronFace === 'bottom') {
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
chevronOffset.left = props.chevronOffset;
} else if (position.top !== undefined) {
const target = position.top;
@ -264,7 +299,8 @@ export class ContextMenu extends React.Component {
// If we know the dimensions of the context menu, adjust its position
// such that it does not leave the (padded) window.
if (contextMenuRect) {
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
const padding = 10;
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding);
}
position.top = adjusted;
@ -282,13 +318,13 @@ export class ContextMenu extends React.Component {
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
});
const menuStyle = {};
const menuStyle: CSSProperties = {};
if (props.menuWidth) {
menuStyle.width = props.menuWidth;
}
@ -319,13 +355,28 @@ export class ContextMenu extends React.Component {
let background;
if (hasBackground) {
background = (
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={props.onFinished} onContextMenu={this.onContextMenu} />
<div
className="mx_ContextualMenu_background"
style={wrapperStyle}
onClick={this.onFinished}
onContextMenu={this.onContextMenu}
/>
);
}
return (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
<div
className="mx_ContextualMenu_wrapper"
style={{...position, ...wrapperStyle}}
onKeyDown={this.onKeyDown}
onContextMenu={this.onContextMenuPreventBubbling}
>
<div
className={menuClasses}
style={menuStyle}
ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined}
>
{ chevron }
{ props.children }
</div>
@ -334,91 +385,13 @@ export class ContextMenu extends React.Component {
);
}
render() {
render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} title={label} aria-label={label} aria-haspopup={true} aria-expanded={isExpanded}>
{ children }
</AccessibleButton>
);
};
ContextMenuButton.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
};
// Semantic component for representing a role=menuitem
export const MenuItem = ({children, label, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItem.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};
MenuGroup.propTypes = {
label: PropTypes.string.isRequired,
className: PropTypes.string, // optional
};
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemCheckbox.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemRadio.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect, chevronOffset=12) => {
export const toRightOf = (elementRect: DOMRect, 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
@ -426,8 +399,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect, chevronFace="none") => {
const menuOptions = { chevronFace };
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
@ -485,3 +458,13 @@ export function createMenu(ElementClass, props) {
return {close: onFinished};
}
// re-export the semantic helper components for simplicity
export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
export {ContextMenuTooltipButton} from "../../accessibility/context_menu/ContextMenuTooltipButton";
export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
export {MenuItem} from "../../accessibility/context_menu/MenuItem";
export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";

View file

@ -18,7 +18,7 @@ import React from 'react';
import CustomRoomTagStore from '../../stores/CustomRoomTagStore';
import AutoHideScrollbar from './AutoHideScrollbar';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import classNames from 'classnames';
import * as FormattingUtils from '../../utils/FormattingUtils';
@ -30,7 +30,7 @@ class CustomRoomTagPanel extends React.Component {
};
}
componentWillMount() {
componentDidMount() {
this._tagStoreToken = CustomRoomTagStore.addListener(() => {
this.setState({tags: CustomRoomTagStore.getSortedTags()});
});
@ -72,17 +72,17 @@ class CustomRoomTagTile extends React.Component {
const tag = this.props.tag;
const avatarHeight = 40;
const className = classNames({
CustomRoomTagPanel_tileSelected: tag.selected,
"CustomRoomTagPanel_tileSelected": tag.selected,
});
const name = tag.name;
const badge = tag.badge;
const badgeNotifState = tag.badgeNotifState;
let badgeElement;
if (badge) {
if (badgeNotifState) {
const badgeClasses = classNames({
"mx_TagTile_badge": true,
"mx_TagTile_badgeHighlight": badge.highlight,
"mx_TagTile_badgeHighlight": badgeNotifState.hasMentions,
});
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badgeNotifState.count)}</div>);
}
return (

View file

@ -23,7 +23,7 @@ import PropTypes from 'prop-types';
import request from 'browser-request';
import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import classnames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
@ -37,6 +37,8 @@ export default class EmbeddedPage extends React.PureComponent {
className: PropTypes.string,
// Whether to wrap the page in a scrollbar
scrollbar: PropTypes.bool,
// Map of keys to replace with values, e.g {$placeholder: "value"}
replaceMap: PropTypes.object,
};
static contextType = MatrixClientContext;
@ -56,7 +58,7 @@ export default class EmbeddedPage extends React.PureComponent {
return sanitizeHtml(_t(s));
}
componentWillMount() {
componentDidMount() {
this._unmounted = false;
if (!this.props.url) {
@ -81,6 +83,13 @@ export default class EmbeddedPage extends React.PureComponent {
}
body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1)=>this.translate(g1));
if (this.props.replaceMap) {
Object.keys(this.props.replaceMap).forEach(key => {
body = body.split(key).join(this.props.replaceMap[key]);
});
}
this.setState({ page: body });
},
);

View file

@ -21,7 +21,7 @@ import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import { getHostingLink } from '../../utils/HostingLink';
import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t, _td } from '../../languageHandler';
@ -92,7 +92,7 @@ const CategoryRoomList = createReactClass({
Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, {
title: _t('Add rooms to the community summary'),
description: _t("Which rooms would you like to add to this summary?"),
placeholder: _t("Room name or alias"),
placeholder: _t("Room name or address"),
button: _t("Add to summary"),
pickerType: 'room',
validAddressTypes: ['mx-room-id'],
@ -428,12 +428,11 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this._unmounted = false;
this._matrixClient = MatrixClientPeg.get();
this._matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this._changeAvatarComponent = null;
this._initGroupStore(this.props.groupId, true);
this._dispatcherRef = dis.register(this._onAction);
@ -451,8 +450,9 @@ export default createReactClass({
}
},
componentWillReceiveProps: function(newProps) {
if (this.props.groupId != newProps.groupId) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
if (this.props.groupId !== newProps.groupId) {
this.setState({
summary: null,
error: null,

View file

@ -0,0 +1,67 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import AutoHideScrollbar from './AutoHideScrollbar';
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";
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
const HomePage = () => {
const config = SdkConfig.get();
const pageUrl = getHomePageUrl(config);
if (pageUrl) {
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
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;
}
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 || "Riot"} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Riot" }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
<div className="mx_HomePage_default_buttons">
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
{ _t("Send a Direct Message") }
</AccessibleButton>
<AccessibleButton onClick={onClickExplore} className="mx_HomePage_button_explore">
{ _t("Explore Public Rooms") }
</AccessibleButton>
<AccessibleButton onClick={onClickNewRoom} className="mx_HomePage_button_createGroup">
{ _t("Create a Group Chat") }
</AccessibleButton>
</div>
</div>
</AutoHideScrollbar>;
};
export default HomePage;

View file

@ -192,7 +192,7 @@ export default class IndicatorScrollbar extends React.Component {
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
onWheel={this.onMouseWheel}
{... this.props}
{...this.props}
>
{ leftOverflowIndicator }
{ this.props.children }

View file

@ -1,6 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -24,6 +24,8 @@ import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryCom
import * as sdk from '../../index';
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
export default createReactClass({
displayName: 'InteractiveAuth',
@ -47,7 +49,7 @@ export default createReactClass({
// @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object
// if successful, otherwise the error object.
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
@ -75,6 +77,15 @@ export default createReactClass({
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged: PropTypes.bool,
// Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric).
onStagePhaseChange: PropTypes.func,
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText: PropTypes.string,
continueKind: PropTypes.string,
},
getInitialState: function() {
@ -87,7 +98,8 @@ export default createReactClass({
};
},
componentWillMount: function() {
// 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,
@ -204,6 +216,16 @@ export default createReactClass({
this._authLogic.submitAuthDict(authData);
},
_onPhaseChange: function(newPhase) {
if (this.props.onStagePhaseChange) {
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
}
},
_onStageCancel: function() {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
},
_renderCurrentStage: function() {
const stage = this.state.authStage;
if (!stage) {
@ -232,6 +254,10 @@ export default createReactClass({
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this._onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this._onStageCancel}
/>
);
},

View file

@ -1,301 +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 React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Key } from '../../Keyboard';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import * as VectorConferenceHandler from '../../VectorConferenceHandler';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
import Analytics from "../../Analytics";
const LeftPanel = createReactClass({
displayName: 'LeftPanel',
// NB. If you add props, don't forget to update
// shouldComponentUpdate!
propTypes: {
collapsed: PropTypes.bool.isRequired,
},
getInitialState: function() {
return {
searchFilter: '',
breadcrumbs: false,
};
},
componentWillMount: function() {
this.focusedElement = null;
this._breadcrumbsWatcherRef = SettingsStore.watchSetting(
"breadcrumbs", null, this._onBreadcrumbsChanged);
this._tagPanelWatcherRef = SettingsStore.watchSetting(
"TagPanel.enableTagPanel", null, () => this.forceUpdate());
const useBreadcrumbs = !!SettingsStore.getValue("breadcrumbs");
Analytics.setBreadcrumbs(useBreadcrumbs);
this.setState({breadcrumbs: useBreadcrumbs});
},
componentWillUnmount: function() {
SettingsStore.unwatchSetting(this._breadcrumbsWatcherRef);
SettingsStore.unwatchSetting(this._tagPanelWatcherRef);
},
shouldComponentUpdate: function(nextProps, nextState) {
// MatrixChat will update whenever the user switches
// rooms, but propagating this change all the way down
// the react tree is quite slow, so we cut this off
// here. The RoomTiles listen for the room change
// events themselves to know when to update.
// We just need to update if any of these things change.
if (
this.props.collapsed !== nextProps.collapsed ||
this.props.disabled !== nextProps.disabled
) {
return true;
}
if (this.state.searchFilter !== nextState.searchFilter) {
return true;
}
if (this.state.searchExpanded !== nextState.searchExpanded) {
return true;
}
return false;
},
componentDidUpdate(prevProps, prevState) {
if (prevState.breadcrumbs !== this.state.breadcrumbs) {
Analytics.setBreadcrumbs(this.state.breadcrumbs);
}
},
_onBreadcrumbsChanged: function(settingName, roomId, level, valueAtLevel, value) {
// Features are only possible at a single level, so we can get away with using valueAtLevel.
// The SettingsStore runs on the same tick as the update, so `value` will be wrong.
this.setState({breadcrumbs: valueAtLevel});
// For some reason the setState doesn't trigger a render of the component, so force one.
// Probably has to do with the change happening outside of a change detector cycle.
this.forceUpdate();
},
_onFocus: function(ev) {
this.focusedElement = ev.target;
},
_onBlur: function(ev) {
this.focusedElement = null;
},
_onFilterKeyDown: function(ev) {
if (!this.focusedElement) return;
switch (ev.key) {
// On enter of rooms filter select and activate first room if such one exists
case Key.ENTER: {
const firstRoom = ev.target.closest(".mx_LeftPanel").querySelector(".mx_RoomTile");
if (firstRoom) {
firstRoom.click();
}
break;
}
}
},
_onKeyDown: function(ev) {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.ARROW_UP:
this._onMoveFocus(ev, true, true);
break;
case Key.ARROW_DOWN:
this._onMoveFocus(ev, false, true);
break;
}
},
_onMoveFocus: function(ev, up, trap) {
let element = this.focusedElement;
// unclear why this isn't needed
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
// this.focusDirection = up;
let descending = false; // are we currently descending or ascending through the DOM tree?
let classes;
do {
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
} else if (sibling) {
element = sibling;
} else {
descending = false;
element = element.parentElement;
}
} else {
if (sibling) {
element = sibling;
descending = true;
} else {
element = element.parentElement;
}
}
if (element) {
classes = element.classList;
}
} while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_RoomSubList_label") ||
classes.contains("mx_LeftPanel_filterRooms")));
if (element) {
ev.stopPropagation();
ev.preventDefault();
element.focus();
this.focusedElement = element;
} else if (trap) {
// if navigation is via up/down arrow-keys, trap in the widget so it doesn't send to composer
ev.stopPropagation();
ev.preventDefault();
}
},
onSearch: function(term) {
this.setState({ searchFilter: term });
},
onSearchCleared: function(source) {
if (source === "keyboard") {
dis.dispatch({action: 'focus_composer'});
}
this.setState({searchExpanded: false});
},
collectRoomList: function(ref) {
this._roomList = ref;
},
_onSearchFocus: function() {
this.setState({searchExpanded: true});
},
_onSearchBlur: function(event) {
if (event.target.value.length === 0) {
this.setState({searchExpanded: false});
}
},
render: function() {
const RoomList = sdk.getComponent('rooms.RoomList');
const RoomBreadcrumbs = sdk.getComponent('rooms.RoomBreadcrumbs');
const TagPanel = sdk.getComponent('structures.TagPanel');
const CustomRoomTagPanel = sdk.getComponent('structures.CustomRoomTagPanel');
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
const SearchBox = sdk.getComponent('structures.SearchBox');
const CallPreview = sdk.getComponent('voip.CallPreview');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const tagPanelEnabled = SettingsStore.getValue("TagPanel.enableTagPanel");
let tagPanelContainer;
const isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
if (tagPanelEnabled) {
tagPanelContainer = (<div className="mx_LeftPanel_tagPanelContainer">
<TagPanel />
{ isCustomTagsEnabled ? <CustomRoomTagPanel /> : undefined }
</div>);
}
const containerClasses = classNames(
"mx_LeftPanel_container", "mx_fadable",
{
"collapsed": this.props.collapsed,
"mx_LeftPanel_container_hasTagPanel": tagPanelEnabled,
"mx_fadable_faded": this.props.disabled,
},
);
let exploreButton;
if (!this.props.collapsed) {
exploreButton = (
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton>
</div>
);
}
const searchBox = (<SearchBox
className="mx_LeftPanel_filterRooms"
enableRoomSearchFocus={true}
blurredPlaceholder={ _t('Filter') }
placeholder={ _t('Filter rooms…') }
onKeyDown={this._onFilterKeyDown}
onSearch={ this.onSearch }
onCleared={ this.onSearchCleared }
onFocus={this._onSearchFocus}
onBlur={this._onSearchBlur}
collapsed={this.props.collapsed} />);
let breadcrumbs;
if (this.state.breadcrumbs) {
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
}
return (
<div className={containerClasses}>
{ tagPanelContainer }
<aside className="mx_LeftPanel dark-panel">
<TopLeftMenuButton collapsed={this.props.collapsed} />
{ breadcrumbs }
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<div className="mx_LeftPanel_exploreAndFilterRow" onKeyDown={this._onKeyDown} onFocus={this._onFocus} onBlur={this._onBlur}>
{ exploreButton }
{ searchBox }
</div>
<RoomList
onKeyDown={this._onKeyDown}
onFocus={this._onFocus}
onBlur={this._onBlur}
ref={this.collectRoomList}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />
</aside>
</div>
);
},
});
export default LeftPanel;

View file

@ -0,0 +1,436 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { createRef } from "react";
import TagPanel from "./TagPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
interface IProps {
isMinimized: boolean;
resizeNotifier: ResizeNotifier;
}
interface IState {
searchFilter: string;
showBreadcrumbs: boolean;
showTagPanel: boolean;
}
// List of CSS classes which should be included in keyboard navigation within the room list
const cssClasses = [
"mx_RoomSearch_input",
"mx_RoomSearch_icon", // minimized <RoomSearch />
"mx_RoomSublist_headerText",
"mx_RoomTile",
"mx_RoomSublist_showNButton",
];
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string;
private bgImageWatcherRef: string;
private focusedElement = null;
private isDoingStickyHeaders = false;
constructor(props: IProps) {
super(props);
this.state = {
searchFilter: "",
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
});
// We watch the middle panel because we don't actually get resized, the middle panel does.
// We listen to the noisy channel to avoid choppy reaction times.
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
}
private onSearch = (term: string): void => {
this.setState({searchFilter: term});
};
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
};
private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible;
if (newVal !== this.state.showBreadcrumbs) {
this.setState({showBreadcrumbs: newVal});
// Update the sticky headers too as the breadcrumbs will be popping in or out.
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
}
};
private onBackgroundImageUpdate = () => {
// Note: we do this in the LeftPanel as it uses this variable most prominently.
const avatarSize = 32; // arbitrary
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
if (settingBgMxc) {
avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize);
}
const avatarUrlProp = `url(${avatarUrl})`;
if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
document.body.style.setProperty("--avatar-url", avatarUrlProp);
}
};
private handleStickyHeaders(list: HTMLDivElement) {
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
});
}
private doStickyHeaders(list: HTMLDivElement) {
const topEdge = list.scrollTop;
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<HTMLDivElement, {
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}>();
let lastTopHeader;
let firstBottomHeader;
for (const sublist of sublists) {
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
header.style.removeProperty("display"); // always clear display:none first
// When an element is <=40% off screen, make it take over
const offScreenFactor = 0.4;
const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
if (isOffTop || sublist === sublists[0]) {
targetStyles.set(header, { stickyTop: true });
if (lastTopHeader) {
lastTopHeader.style.display = "none";
targetStyles.set(lastTopHeader, { makeInvisible: true });
}
lastTopHeader = header;
} else if (isOffBottom && !firstBottomHeader) {
targetStyles.set(header, { stickyBottom: true });
firstBottomHeader = header;
} else {
targetStyles.set(header, {}); // nothing == clear
}
}
// Run over the style changes and make them reality. We check to see if we're about to
// cause a no-op update, as adding/removing properties that are/aren't there cause
// layout updates.
for (const header of targetStyles.keys()) {
const style = targetStyles.get(header);
if (style.makeInvisible) {
// we will have already removed the 'display: none', so add it back.
header.style.display = "none";
continue; // nothing else to do, even if sticky somehow
}
if (style.stickyTop) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyTop");
}
const newTop = `${list.parentElement.offsetTop}px`;
if (header.style.top !== newTop) {
header.style.top = newTop;
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyTop");
}
if (header.style.top) {
header.style.removeProperty('top');
}
}
if (style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
}
}
if (style.stickyTop || style.stickyBottom) {
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.add("mx_RoomSublist_headerContainer_sticky");
}
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
} else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
}
if (header.style.width) {
header.style.removeProperty('width');
}
}
}
// add appropriate sticky classes to wrapper so it has
// the necessary top/bottom padding to put the sticky header in
const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper
if (lastTopHeader) {
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop");
} else {
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop");
}
if (firstBottomHeader) {
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom");
} else {
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom");
}
}
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list);
};
private onResize = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
private onFocus = (ev: React.FocusEvent) => {
this.focusedElement = ev.target;
};
private onBlur = () => {
this.focusedElement = null;
};
private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
ev.stopPropagation();
ev.preventDefault();
this.onMoveFocus(ev.key === Key.ARROW_UP);
break;
}
};
private onEnter = () => {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile");
if (firstRoom) {
firstRoom.click();
return true; // to get the field to clear
}
};
private onMoveFocus = (up: boolean) => {
let element = this.focusedElement;
let descending = false; // are we currently descending or ascending through the DOM tree?
let classes: DOMTokenList;
do {
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
} else if (sibling) {
element = sibling;
} else {
descending = false;
element = element.parentElement;
}
} else {
if (sibling) {
element = sibling;
descending = true;
} else {
element = element.parentElement;
}
}
if (element) {
classes = element.classList;
}
} while (element && !cssClasses.some(c => classes.contains(c)));
if (element) {
element.focus();
this.focusedElement = element;
}
};
private renderHeader(): React.ReactNode {
return (
<div className="mx_LeftPanel_userHeader">
<UserMenu isMinimized={this.props.isMinimized} />
</div>
);
}
private renderBreadcrumbs(): React.ReactNode {
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
return (
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>
);
}
}
private renderSearchExplore(): React.ReactNode {
return (
<div
className="mx_LeftPanel_filterContainer"
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
>
<RoomSearch
onQueryUpdate={this.onSearch}
isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown}
onEnter={this.onEnter}
/>
<AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/>
</div>
);
}
public render(): React.ReactNode {
const tagPanel = !this.state.showTagPanel ? null : (
<div className="mx_LeftPanel_tagPanelContainer">
<TagPanel/>
{SettingsStore.isFeatureEnabled("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
</div>
);
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={null}
collapsed={false}
searchFilter={this.state.searchFilter}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
onResize={this.onResize}
/>;
const containerClasses = classNames({
"mx_LeftPanel": true,
"mx_LeftPanel_hasTagPanel": !!tagPanel,
"mx_LeftPanel_minimized": this.props.isMinimized,
});
const roomListClasses = classNames(
"mx_LeftPanel_actualRoomListContainer",
"mx_AutoHideScrollbar",
);
return (
<div className={containerClasses}>
{tagPanel}
<aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
{this.renderBreadcrumbs()}
<div className="mx_LeftPanel_roomListWrapper">
<div
className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{roomList}
</div>
</div>
</aside>
</div>
);
}
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2017, 2018, 2020 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.
@ -16,23 +16,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from 'matrix-js-sdk';
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import { Key, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import dis from '../../dispatcher/dispatcher';
import sessionStore from '../../stores/SessionStore';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import { getHomePageUrl } from '../../utils/pages';
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
@ -40,6 +37,24 @@ import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
import HomePage from "./HomePage";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg";
import { DefaultTagID } from "../../stores/room-list/models";
import {
showToast as showSetPasswordToast,
hideToast as hideSetPasswordToast
} from "../../toasts/SetPasswordToast";
import {
showToast as showServerLimitToast,
hideToast as hideServerLimitToast
} from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions";
import LeftPanel from "./LeftPanel";
import CallContainer from '../views/voip/CallContainer';
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
import RoomListStore from "../../stores/room-list/RoomListStore";
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general.
@ -52,6 +67,54 @@ function canElementReceiveInput(el) {
!!el.getAttribute("contenteditable");
}
interface IProps {
matrixClient: MatrixClient;
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
viaServers?: string[];
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
middleDisabled: boolean;
initialEventPixelOffset: number;
leftDisabled: boolean;
rightDisabled: boolean;
page_type: string;
autoJoin: boolean;
thirdPartyInvite?: object;
roomOobData?: object;
currentRoomId: string;
ConferenceHandler?: object;
collapseLhs: boolean;
config: {
piwik: {
policyUrl: string;
},
[key: string]: any,
};
currentUserId?: string;
currentGroupId?: string;
currentGroupIsNew?: boolean;
}
interface IUsageLimit {
limit_type: "monthly_active_user" | string;
admin_contact?: string;
}
interface IState {
mouseDown?: {
x: number;
y: number;
};
syncErrorData?: {
error: {
data: IUsageLimit;
errcode: string;
};
};
usageLimitEventContent?: IUsageLimit;
useCompactLayout: boolean;
}
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
@ -61,10 +124,10 @@ function canElementReceiveInput(el) {
*
* Components mounted below us can access the matrix client via the react context.
*/
const LoggedInView = createReactClass({
displayName: 'LoggedInView',
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
propTypes: {
static propTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
@ -77,24 +140,26 @@ const LoggedInView = createReactClass({
viaServers: PropTypes.arrayOf(PropTypes.string),
// and lots and lots of other stuff.
},
};
getInitialState: function() {
return {
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _sessionStore: sessionStore;
protected readonly _sessionStoreToken: { remove: () => void };
protected readonly _compactLayoutWatcherRef: string;
protected resizer: Resizer;
constructor(props, context) {
super(props, context);
this.state = {
mouseDown: undefined,
syncErrorData: undefined,
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
// any currently active server notice events
serverNoticeEvents: [],
};
},
componentDidMount: function() {
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
},
componentWillMount: function() {
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
@ -114,33 +179,33 @@ const LoggedInView = createReactClass({
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
fixupColorFonts();
this._roomView = createRef();
},
this._roomView = React.createRef();
this._resizeContainer = React.createRef();
}
componentDidUpdate(prevProps) {
// attempt to guess when a banner was opened or closed
if (
(prevProps.showCookieBar !== this.props.showCookieBar) ||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
(prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) ||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
) {
this.props.resizeNotifier.notifyBannersChanged();
}
},
componentDidMount() {
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
}
componentWillUnmount: function() {
componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
this.resizer.detach();
},
}
// Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy.
@ -148,22 +213,24 @@ const LoggedInView = createReactClass({
// This is required because `LoggedInView` maintains its own state and if this state
// updates after the client peg has been made null (during logout), then it will
// attempt to re-render and the children will throw errors.
shouldComponentUpdate: function() {
shouldComponentUpdate() {
return Boolean(MatrixClientPeg.get());
},
}
canResetTimelineInRoom: function(roomId) {
canResetTimelineInRoom = (roomId) => {
if (!this._roomView.current) {
return true;
}
return this._roomView.current.canResetTimeline();
},
};
_setStateFromSessionStore() {
this.setState({
userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()),
});
},
_setStateFromSessionStore = () => {
if (this._sessionStore.getCachedPassword()) {
showSetPasswordToast();
} else {
hideSetPasswordToast();
}
};
_createResizer() {
const classNames = {
@ -187,35 +254,34 @@ const LoggedInView = createReactClass({
},
};
const resizer = new Resizer(
this.resizeContainer,
this._resizeContainer.current,
CollapseDistributor,
collapseConfig);
resizer.setClassNames(classNames);
return resizer;
},
}
_loadResizerPreferences() {
let lhsSize = window.localStorage.getItem("mx_lhs_size");
if (lhsSize !== null) {
lhsSize = parseInt(lhsSize, 10);
} else {
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10);
if (isNaN(lhsSize)) {
lhsSize = 350;
}
this.resizer.forHandleAt(0).resize(lhsSize);
},
}
onAccountData: function(event) {
if (event.getType() === "im.vector.web.settings") {
this.setState({
useCompactLayout: event.getContent().useCompactLayout,
});
}
onAccountData = (event) => {
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({action: "ignore_state_changed"});
}
},
};
onSync: function(syncState, oldSyncState, data) {
onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => {
this.setState({
useCompactLayout: valueAtLevel,
});
};
onSync = (syncState, oldSyncState, data) => {
const oldErrCode = (
this.state.syncErrorData &&
this.state.syncErrorData.error &&
@ -236,22 +302,37 @@ const LoggedInView = createReactClass({
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
this._updateServerNoticeEvents();
} else {
this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
}
},
};
onRoomStateEvents: function(ev, state) {
const roomLists = RoomListStore.getRoomLists();
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
onRoomStateEvents = (ev, state) => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
}
},
};
_updateServerNoticeEvents: async function() {
const roomLists = RoomListStore.getRoomLists();
if (!roomLists['m.server_notice']) return [];
_calculateServerLimitToast(syncErrorData: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
const error = syncErrorData && syncErrorData.error && syncErrorData.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
if (error) {
usageLimitEventContent = syncErrorData.error.data;
}
const pinnedEvents = [];
for (const room of roomLists['m.server_notice']) {
if (usageLimitEventContent) {
showServerLimitToast(usageLimitEventContent.limit_type, usageLimitEventContent.admin_contact, error);
} else {
hideServerLimitToast();
}
}
_updateServerNoticeEvents = async () => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (!serverNoticeList) return [];
const events = [];
for (const room of serverNoticeList) {
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
@ -259,16 +340,23 @@ const LoggedInView = createReactClass({
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
const ev = timeline.getEvents().find(ev => ev.getId() === eventId);
if (ev) pinnedEvents.push(ev);
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
if (event) events.push(event);
}
}
this.setState({
serverNoticeEvents: pinnedEvents,
});
},
_onPaste: function(ev) {
const usageLimitEvent = events.find((e) => {
return (
e && e.getType() === 'm.room.message' &&
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
);
});
const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent();
this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
this.setState({ usageLimitEventContent });
};
_onPaste = (ev) => {
let canReceiveInput = false;
let element = ev.target;
// test for all parents because the target can be a child of a contenteditable element
@ -280,9 +368,9 @@ const LoggedInView = createReactClass({
// refocusing during a paste event will make the
// paste end up in the newly focused element,
// so dispatch synchronously before paste happens
dis.dispatch({action: 'focus_composer'}, true);
dis.fire(Action.FocusComposer, true);
}
},
};
/*
SOME HACKERY BELOW:
@ -306,36 +394,22 @@ const LoggedInView = createReactClass({
We also listen with a native listener on the document to get keydown events when no element is focused.
Bubbling is irrelevant here as the target is the body element.
*/
_onReactKeyDown: function(ev) {
_onReactKeyDown = (ev) => {
// events caught while bubbling up on the root element
// of this component, so something must be focused.
this._onKeyDown(ev);
},
};
_onNativeKeyDown: function(ev) {
_onNativeKeyDown = (ev) => {
// only pass this if there is no focused element.
// if there is, _onKeyDown will be called by the
// react keydown handler that respects the react bubbling order.
if (ev.target === document.body) {
this._onKeyDown(ev);
}
},
_onKeyDown: function(ev) {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
// Will need to find a better meta key if anyone actually cares about using this.
if (ev.altKey && ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) {
dis.dispatch({
action: 'view_indexed_room',
roomIndex: ev.keyCode - 49,
});
ev.stopPropagation();
ev.preventDefault();
return;
}
*/
};
_onKeyDown = (ev) => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
@ -372,15 +446,13 @@ const LoggedInView = createReactClass({
// composer, so CTRL+` it is
if (ctrlCmdOnly) {
dis.dispatch({
action: 'toggle_top_left_menu',
});
dis.fire(Action.ToggleUserMenu);
handled = true;
}
break;
case Key.SLASH:
if (ctrlCmdOnly) {
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) {
KeyboardShortcuts.toggleDialog();
handled = true;
}
@ -389,8 +461,8 @@ const LoggedInView = createReactClass({
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
dis.dispatch({
action: 'view_room_delta',
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: ev.key === Key.ARROW_UP ? -1 : 1,
unread: ev.shiftKey,
});
@ -406,6 +478,11 @@ const LoggedInView = createReactClass({
});
handled = true;
}
break;
default:
// if we do not have a handler for it, pass it to the platform which might
handled = PlatformPeg.get().onKeyDown(ev);
}
if (handled) {
@ -425,25 +502,25 @@ const LoggedInView = createReactClass({
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input
dis.dispatch({action: 'focus_composer'}, true);
dis.fire(Action.FocusComposer, true);
ev.stopPropagation();
// we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer
}
}
},
};
/**
* dispatch a page-up/page-down/etc to the appropriate component
* @param {Object} ev The key event
*/
_onScrollKeyPressed: function(ev) {
_onScrollKeyPressed = (ev) => {
if (this._roomView.current) {
this._roomView.current.handleScrollKey(ev);
}
},
};
_onDragEnd: function(result) {
_onDragEnd = (result) => {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
@ -466,9 +543,9 @@ const LoggedInView = createReactClass({
} else if (dest.startsWith('room-sub-list-droppable_')) {
this._onRoomTileEndDrag(result);
}
},
};
_onRoomTileEndDrag: function(result) {
_onRoomTileEndDrag = (result) => {
let newTag = result.destination.droppableId.split('_')[1];
let prevTag = result.source.droppableId.split('_')[1];
if (newTag === 'undefined') newTag = undefined;
@ -485,9 +562,9 @@ const LoggedInView = createReactClass({
prevTag, newTag,
oldIndex, newIndex,
), true);
},
};
_onMouseDown: function(ev) {
_onMouseDown = (ev) => {
// When the panels are disabled, clicking on them results in a mouse event
// which bubbles to certain elements in the tree. When this happens, close
// any settings page that is currently open (user/room/group).
@ -506,9 +583,9 @@ const LoggedInView = createReactClass({
});
}
}
},
};
_onMouseUp: function(ev) {
_onMouseUp = (ev) => {
if (!this.state.mouseDown) return;
const deltaX = ev.pageX - this.state.mouseDown.x;
@ -527,26 +604,14 @@ const LoggedInView = createReactClass({
// Always clear the mouseDown state to ensure we don't accidentally
// use stale values due to the mouseDown checks.
this.setState({mouseDown: null});
},
};
_setResizeContainerRef(div) {
this.resizeContainer = div;
},
render: function() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
render() {
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const ToastContainer = sdk.getComponent('structures.ToastContainer');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const CookieBar = sdk.getComponent('globals.CookieBar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
let pageElement;
@ -576,13 +641,7 @@ const LoggedInView = createReactClass({
break;
case PageTypes.HomePage:
{
const pageUrl = getHomePageUrl(this.props.config);
pageElement = <EmbeddedPage className="mx_HomePage"
url={pageUrl}
scrollbar={true}
/>;
}
pageElement = <HomePage />;
break;
case PageTypes.UserView:
@ -596,50 +655,18 @@ const LoggedInView = createReactClass({
break;
}
const usageLimitEvent = this.state.serverNoticeEvents.find((e) => {
return (
e && e.getType() === 'm.room.message' &&
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
);
});
let topBar;
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
topBar = <ServerLimitBar kind='hard'
adminContact={this.state.syncErrorData.error.data.admin_contact}
limitType={this.state.syncErrorData.error.data.limit_type}
/>;
} else if (usageLimitEvent) {
topBar = <ServerLimitBar kind='soft'
adminContact={usageLimitEvent.getContent().admin_contact}
limitType={usageLimitEvent.getContent().limit_type}
/>;
} else if (this.props.showCookieBar &&
this.props.config.piwik &&
navigator.doNotTrack !== "1"
) {
const policyUrl = this.props.config.piwik.policyUrl || null;
topBar = <CookieBar policyUrl={policyUrl} />;
} else if (this.props.hasNewVersion) {
topBar = <NewVersionBar version={this.props.version} newVersion={this.props.newVersion}
releaseNotes={this.props.newVersionReleaseNotes}
/>;
} else if (this.props.checkingForUpdate) {
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
} else if (this.state.userHasGeneratedPassword) {
topBar = <PasswordNagBar />;
} else if (this.props.showNotifierToolbar) {
topBar = <MatrixToolbar />;
}
let bodyClasses = 'mx_MatrixChat';
if (topBar) {
bodyClasses += ' mx_MatrixChat_toolbarShowing';
}
if (this.state.useCompactLayout) {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const leftPanel = (
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
);
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
@ -650,23 +677,19 @@ const LoggedInView = createReactClass({
onMouseDown={this._onMouseDown}
onMouseUp={this._onMouseUp}
>
{ topBar }
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<div ref={this._resizeContainer} className={bodyClasses}>
{ leftPanel }
<ResizeHandle />
{ pageElement }
</div>
</DragDropContext>
</div>
<CallContainer />
</MatrixClientContext.Provider>
);
},
});
}
}
export default LoggedInView;

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