Merge remote-tracking branch 'upstream/develop' into SuppressSpeechWhenSending
This commit is contained in:
commit
d8373576f8
1188 changed files with 53232 additions and 22354 deletions
20
src/@types/common.ts
Normal file
20
src/@types/common.ts
Normal 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] };
|
77
src/@types/global.d.ts
vendored
Normal file
77
src/@types/global.d.ts
vendored
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
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 { RoomListStore2 } from "../stores/room-list/RoomListStore2";
|
||||
import { PlatformPeg } from "../PlatformPeg";
|
||||
import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Modernizr: ModernizrStatic;
|
||||
mxMatrixClientPeg: IMatrixClientPeg;
|
||||
Olm: {
|
||||
init: () => Promise<void>;
|
||||
};
|
||||
|
||||
mx_ContentMessages: ContentMessages;
|
||||
mx_ToastStore: ToastStore;
|
||||
mx_DeviceListener: DeviceListener;
|
||||
mx_RebrandListener: RebrandListener;
|
||||
mx_RoomListStore2: RoomListStore2;
|
||||
mx_RoomListLayoutStore: RoomListLayoutStore;
|
||||
mxPlatformPeg: PlatformPeg;
|
||||
|
||||
// TODO: Remove flag before launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
mx_LoudRoomListLogging: boolean;
|
||||
}
|
||||
|
||||
// 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
38
src/@types/polyfill.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,167 +0,0 @@
|
|||
// @flow
|
||||
|
||||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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[]) {}
|
||||
}
|
284
src/BasePlatform.ts
Normal file
284
src/BasePlatform.ts
Normal 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> {
|
||||
}
|
||||
}
|
|
@ -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 {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
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 Devices'),
|
||||
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");
|
||||
|
@ -395,41 +355,15 @@ function _onAction(payload) {
|
|||
}
|
||||
|
||||
async function _startCallApp(roomId, type) {
|
||||
// check for a working integration manager. Technically we could put
|
||||
// the state event in anyway, but the resulting widget would then not
|
||||
// work for us. Better that the user knows before everyone else in the
|
||||
// room sees it.
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
let haveScalar = false;
|
||||
if (managers.hasManager()) {
|
||||
try {
|
||||
const scalarClient = managers.getPrimaryManager().getScalarClient();
|
||||
await scalarClient.connect();
|
||||
haveScalar = scalarClient.hasCredentials();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!haveScalar) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, {
|
||||
title: _t('Could not connect to the integration server'),
|
||||
description: _t('A conference call could not be started because the integrations server is not available'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: 'appsDrawer',
|
||||
show: true,
|
||||
});
|
||||
|
||||
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, {
|
||||
|
@ -439,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 +
|
||||
|
@ -456,31 +387,22 @@ async function _startCallApp(roomId, type) {
|
|||
return;
|
||||
}
|
||||
|
||||
// This inherits its poor naming from the field of the same name that goes into
|
||||
// the event. It's just a random string to make the Jitsi URLs unique.
|
||||
const widgetSessionId = Math.random().toString(36).substring(2);
|
||||
const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId;
|
||||
// NB. we can't just encodeURICompoent all of these because the $ signs need to be there
|
||||
// (but currently the only thing that needs encoding is the confId)
|
||||
const queryString = [
|
||||
'confId='+encodeURIComponent(confId),
|
||||
'isAudioConf='+(type === 'voice' ? 'true' : 'false'),
|
||||
'displayName=$matrix_display_name',
|
||||
'avatarUrl=$matrix_avatar_url',
|
||||
'email=$matrix_user_id',
|
||||
].join('&');
|
||||
const confId = `JitsiConference${generateHumanReadableId()}`;
|
||||
const jitsiDomain = Jitsi.getInstance().preferredDomain;
|
||||
|
||||
let widgetUrl;
|
||||
if (SdkConfig.get().integrations_jitsi_widget_url) {
|
||||
// Try this config key. This probably isn't ideal as a way of discovering this
|
||||
// URL, but this will at least allow the integration manager to not be hardcoded.
|
||||
widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString;
|
||||
} else {
|
||||
const apiUrl = IntegrationManagers.sharedInstance().getPrimaryManager().apiUrl;
|
||||
widgetUrl = apiUrl + '/widgets/jitsi.html?' + queryString;
|
||||
}
|
||||
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
|
||||
|
||||
const widgetData = { widgetSessionId };
|
||||
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
|
||||
const parsedUrl = new URL(widgetUrl);
|
||||
parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
|
||||
parsedUrl.searchParams.set('confId', confId);
|
||||
widgetUrl = parsedUrl.toString();
|
||||
|
||||
const widgetData = {
|
||||
conferenceId: confId,
|
||||
isAudioOnly: type === 'voice',
|
||||
domain: jitsiDomain,
|
||||
};
|
||||
|
||||
const widgetId = (
|
||||
'jitsi_' +
|
||||
|
@ -489,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') {
|
||||
|
|
|
@ -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('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]: [boolean] = 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('Upload Failure', '', UploadFailureDialog, {
|
||||
badFiles: tooBigFiles,
|
||||
totalFiles: files.length,
|
||||
contentMessages: this,
|
||||
});
|
||||
const shouldContinue = await uploadFailureDialogPromise;
|
||||
const [shouldContinue]: [boolean] = 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('Upload Files confirmation', '', UploadConfirmDialog, {
|
||||
file,
|
||||
currentIndex: i,
|
||||
totalFiles: okFiles.length,
|
||||
});
|
||||
const [shouldContinue, shouldUploadAll]: [boolean, boolean] = 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.mx_ContentMessages === undefined) {
|
||||
window.mx_ContentMessages = new ContentMessages();
|
||||
}
|
||||
return window.mx_ContentMessages;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ import {MatrixClientPeg} from './MatrixClientPeg';
|
|||
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
|
||||
import { _t } from './languageHandler';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
|
||||
|
||||
// This stores the secret storage private keys in memory for the JS SDK. This is
|
||||
// only meant to act as a cache to avoid prompting the user multiple times
|
||||
|
@ -31,10 +31,7 @@ let secretStorageKeys = {};
|
|||
let secretStorageBeingAccessed = false;
|
||||
|
||||
function isCachingAllowed() {
|
||||
return (
|
||||
secretStorageBeingAccessed ||
|
||||
SettingsStore.getValue("keepSecretStoragePassphraseForSession")
|
||||
);
|
||||
return secretStorageBeingAccessed;
|
||||
}
|
||||
|
||||
export class AccessCancelledError extends Error {
|
||||
|
@ -50,7 +47,7 @@ async function confirmToDismiss(name) {
|
|||
} 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.");
|
||||
description = _t("If you cancel now, you won't complete your operation.");
|
||||
}
|
||||
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
@ -96,7 +93,7 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
|||
keyInfo: info,
|
||||
checkPrivateKey: async (input) => {
|
||||
const key = await inputToKey(input);
|
||||
return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey);
|
||||
return await MatrixClientPeg.get().checkSecretStorageKey(key, info);
|
||||
},
|
||||
},
|
||||
/* className= */ null,
|
||||
|
@ -125,10 +122,74 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
|||
return [name, key];
|
||||
}
|
||||
|
||||
const onSecretRequested = async function({
|
||||
user_id: userId,
|
||||
device_id: deviceId,
|
||||
request_id: requestId,
|
||||
name,
|
||||
device_trust: deviceTrust,
|
||||
}) {
|
||||
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (userId !== client.getUserId()) {
|
||||
return;
|
||||
}
|
||||
if (!deviceTrust || !deviceTrust.isVerified()) {
|
||||
console.log(`CrossSigningManager: Ignoring request from untrusted device ${deviceId}`);
|
||||
return;
|
||||
}
|
||||
if (name.startsWith("m.cross_signing")) {
|
||||
const callbacks = client.getCrossSigningCacheCallbacks();
|
||||
if (!callbacks.getCrossSigningKeyCache) return;
|
||||
/* Explicit enumeration here is deliberate – never share the master key! */
|
||||
if (name === "m.cross_signing.self_signing") {
|
||||
const key = await callbacks.getCrossSigningKeyCache("self_signing");
|
||||
if (!key) {
|
||||
console.log(
|
||||
`self_signing requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
return key && encodeBase64(key);
|
||||
} else if (name === "m.cross_signing.user_signing") {
|
||||
const key = await callbacks.getCrossSigningKeyCache("user_signing");
|
||||
if (!key) {
|
||||
console.log(
|
||||
`user_signing requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
return key && encodeBase64(key);
|
||||
}
|
||||
} else if (name === "m.megolm_backup.v1") {
|
||||
const key = await client._crypto.getSessionBackupPrivateKey();
|
||||
if (!key) {
|
||||
console.log(
|
||||
`session backup key requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
return key && encodeBase64(key);
|
||||
}
|
||||
console.warn("onSecretRequested didn't recognise the secret named ", name);
|
||||
};
|
||||
|
||||
export const crossSigningCallbacks = {
|
||||
getSecretStorageKey,
|
||||
onSecretRequested,
|
||||
};
|
||||
|
||||
export async function promptForBackupPassphrase() {
|
||||
let key;
|
||||
|
||||
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
|
||||
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
|
||||
showSummary: false, keyCallback: k => key = k,
|
||||
}, null, /* priority = */ false, /* static = */ true);
|
||||
|
||||
const success = await finished;
|
||||
if (!success) throw new Error("Key backup prompt cancelled");
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper should be used whenever you need to access secret storage. It
|
||||
* ensures that secret storage (and also cross-signing since they each depend on
|
||||
|
@ -148,19 +209,19 @@ export const crossSigningCallbacks = {
|
|||
*
|
||||
* @param {Function} [func] An operation to perform once secret storage has been
|
||||
* bootstrapped. Optional.
|
||||
* @param {bool} [force] Reset secret storage even if it's already set up
|
||||
* @param {bool} [forceReset] Reset secret storage even if it's already set up
|
||||
*/
|
||||
export async function accessSecretStorage(func = async () => { }, force = false) {
|
||||
export async function accessSecretStorage(func = async () => { }, forceReset = false) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
secretStorageBeingAccessed = true;
|
||||
try {
|
||||
if (!await cli.hasSecretStorageKey() || force) {
|
||||
if (!await cli.hasSecretStorageKey() || forceReset) {
|
||||
// This dialog calls bootstrap itself after guiding the user through
|
||||
// passphrase creation.
|
||||
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
|
||||
import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"),
|
||||
{
|
||||
force,
|
||||
force: forceReset,
|
||||
},
|
||||
null, /* priority = */ false, /* static = */ true,
|
||||
);
|
||||
|
@ -185,6 +246,7 @@ export async function accessSecretStorage(func = async () => { }, force = false)
|
|||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
},
|
||||
getBackupPassphrase: promptForBackupPassphrase,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,178 +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);
|
||||
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);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return;
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
if (!cli.isCryptoEnabled()) return;
|
||||
if (!cli.getCrossSigningId()) {
|
||||
if (this._dismissedThisDeviceToast) {
|
||||
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
|
||||
}
|
||||
|
||||
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 session"),
|
||||
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
266
src/DeviceListener.ts
Normal 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.mx_DeviceListener) window.mx_DeviceListener = new DeviceListener();
|
||||
return window.mx_DeviceListener;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -17,13 +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} from "./widgets/WidgetApi";
|
||||
import {objectClone} from "./utils/objects";
|
||||
|
||||
const WIDGET_API_VERSION = '0.0.2'; // Current API version
|
||||
const SUPPORTED_WIDGET_API_VERSIONS = [
|
||||
|
@ -99,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);
|
||||
}
|
||||
}
|
||||
|
@ -164,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,
|
||||
|
@ -213,7 +215,7 @@ export default class FromWidgetPostMessageApi {
|
|||
const data = event.data.data;
|
||||
const val = data.value;
|
||||
|
||||
if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) {
|
||||
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
|
||||
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
|
||||
}
|
||||
} else if (action === 'get_openid') {
|
||||
|
@ -246,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);
|
||||
}
|
||||
|
@ -259,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,
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -17,24 +17,20 @@ 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 highlight from 'highlight.js';
|
||||
import * as linkify from 'linkifyjs';
|
||||
import linkifyMatrix from './linkify-matrix';
|
||||
import _linkifyElement from 'linkifyjs/element';
|
||||
import _linkifyString from 'linkifyjs/string';
|
||||
import classNames from 'classnames';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import 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);
|
||||
|
||||
|
@ -65,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);
|
||||
}
|
||||
|
||||
|
@ -75,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]}:` : '');
|
||||
}
|
||||
|
@ -86,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;
|
||||
|
@ -101,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;
|
||||
|
@ -123,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.
|
||||
|
@ -137,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;
|
||||
|
@ -148,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
|
||||
|
||||
|
@ -163,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.
|
||||
|
@ -177,7 +180,7 @@ 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) {
|
||||
|
@ -187,7 +190,7 @@ const transformTags = { // custom to matrix
|
|||
}
|
||||
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;
|
||||
|
@ -221,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
|
||||
|
@ -248,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) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -271,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
|
||||
|
@ -319,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
|
||||
*
|
||||
|
@ -349,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>;
|
||||
|
@ -365,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
|
||||
*
|
||||
|
@ -379,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;
|
||||
|
||||
|
@ -388,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
|
||||
|
@ -447,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:"))
|
||||
);
|
||||
|
@ -467,11 +481,12 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
/**
|
||||
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
* @param {string} str string to linkify
|
||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string} Linkified string
|
||||
*/
|
||||
export function linkifyString(str) {
|
||||
return _linkifyString(str);
|
||||
export function linkifyString(str: string, options = linkifyMatrix.options) {
|
||||
return _linkifyString(str, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -481,7 +496,7 @@ export function linkifyString(str) {
|
|||
* @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);
|
||||
}
|
||||
|
||||
|
@ -489,10 +504,11 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
|
|||
* Linkify the given string and sanitize the HTML afterwards.
|
||||
*
|
||||
* @param {string} dirtyHtml The HTML string to sanitize and linkify
|
||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string}
|
||||
*/
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml) {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams);
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -502,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml) {
|
|||
* @param {Node} node
|
||||
* @returns {bool}
|
||||
*/
|
||||
export function checkBlockNode(node) {
|
||||
export function checkBlockNode(node: Node) {
|
||||
switch (node.nodeName) {
|
||||
case "H1":
|
||||
case "H2":
|
|
@ -181,24 +181,12 @@ export default class IdentityAuthClient {
|
|||
}
|
||||
|
||||
async registerForToken(check=true) {
|
||||
try {
|
||||
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
|
||||
// XXX: The spec is `token`, but we used `access_token` for a Sydent release.
|
||||
const { access_token: accessToken, token } =
|
||||
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken);
|
||||
const identityAccessToken = token ? token : accessToken;
|
||||
if (check) await this._checkToken(identityAccessToken);
|
||||
return identityAccessToken;
|
||||
} catch (e) {
|
||||
if (e.cors === "rejected" || e.httpStatus === 404) {
|
||||
// Assume IS only supports deprecated v1 API for now
|
||||
// TODO: Remove this path once v2 is only supported version
|
||||
// See https://github.com/vector-im/riot-web/issues/10443
|
||||
console.warn("IS doesn't support v2 auth");
|
||||
this.authEnabled = false;
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
|
||||
// XXX: The spec is `token`, but we used `access_token` for a Sydent release.
|
||||
const { access_token: accessToken, token } =
|
||||
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken);
|
||||
const identityAccessToken = token ? token : accessToken;
|
||||
if (check) await this._checkToken(identityAccessToken);
|
||||
return identityAccessToken;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export const Key = {
|
|||
PAGE_UP: "PageUp",
|
||||
PAGE_DOWN: "PageDown",
|
||||
BACKSPACE: "Backspace",
|
||||
DELETE: "Delete",
|
||||
ARROW_UP: "ArrowUp",
|
||||
ARROW_DOWN: "ArrowDown",
|
||||
ARROW_LEFT: "ArrowLeft",
|
||||
|
@ -36,10 +37,14 @@ export const Key = {
|
|||
CONTEXT_MENU: "ContextMenu",
|
||||
|
||||
COMMA: ",",
|
||||
PERIOD: ".",
|
||||
LESS_THAN: "<",
|
||||
GREATER_THAN: ">",
|
||||
BACKTICK: "`",
|
||||
SPACE: " ",
|
||||
SLASH: "/",
|
||||
SQUARE_BRACKET_LEFT: "[",
|
||||
SQUARE_BRACKET_RIGHT: "]",
|
||||
A: "a",
|
||||
B: "b",
|
||||
C: "c",
|
||||
|
@ -68,8 +73,9 @@ export const Key = {
|
|||
Z: "z",
|
||||
};
|
||||
|
||||
export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
export function isOnlyCtrlOrCmdKeyEvent(ev) {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
if (isMac) {
|
||||
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
|
||||
} else {
|
||||
|
@ -78,7 +84,6 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) {
|
|||
}
|
||||
|
||||
export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
if (isMac) {
|
||||
return ev.metaKey && !ev.altKey && !ev.ctrlKey;
|
||||
} else {
|
|
@ -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 {
|
||||
|
@ -313,7 +324,7 @@ async function _restoreFromLocalStorage(opts) {
|
|||
}
|
||||
}
|
||||
|
||||
function _handleLoadSessionFailure(e) {
|
||||
async function _handleLoadSessionFailure(e) {
|
||||
console.error("Unable to load session", e);
|
||||
|
||||
const SessionRestoreErrorDialog =
|
||||
|
@ -323,16 +334,15 @@ function _handleLoadSessionFailure(e) {
|
|||
error: e.message,
|
||||
});
|
||||
|
||||
return modal.finished.then(([success]) => {
|
||||
if (success) {
|
||||
// user clicked continue.
|
||||
_clearStorage();
|
||||
return false;
|
||||
}
|
||||
const [success] = await modal.finished;
|
||||
if (success) {
|
||||
// user clicked continue.
|
||||
await _clearStorage();
|
||||
return false;
|
||||
}
|
||||
|
||||
// try, try again
|
||||
return loadSession();
|
||||
});
|
||||
// try, try again
|
||||
return loadSession();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -348,9 +358,13 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -479,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);
|
||||
|
@ -516,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
|
||||
|
@ -575,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();
|
||||
|
@ -604,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.
|
||||
|
@ -638,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
|
||||
|
@ -662,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();
|
||||
|
|
30
src/Login.js
30
src/Login.js
|
@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd
|
|||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -19,8 +20,6 @@ limitations under the License.
|
|||
|
||||
import Matrix from "matrix-js-sdk";
|
||||
|
||||
import url from 'url';
|
||||
|
||||
export default class Login {
|
||||
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
|
||||
this._hsUrl = hsUrl;
|
||||
|
@ -29,6 +28,7 @@ export default class Login {
|
|||
this._currentFlowIndex = 0;
|
||||
this._flows = [];
|
||||
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||
this._tempClient = null; // memoize
|
||||
}
|
||||
|
||||
getHomeserverUrl() {
|
||||
|
@ -40,10 +40,12 @@ export default class Login {
|
|||
}
|
||||
|
||||
setHomeserverUrl(hsUrl) {
|
||||
this._tempClient = null; // clear memoization
|
||||
this._hsUrl = hsUrl;
|
||||
}
|
||||
|
||||
setIdentityServerUrl(isUrl) {
|
||||
this._tempClient = null; // clear memoization
|
||||
this._isUrl = isUrl;
|
||||
}
|
||||
|
||||
|
@ -52,8 +54,9 @@ export default class Login {
|
|||
* requests.
|
||||
* @returns {MatrixClient}
|
||||
*/
|
||||
_createTemporaryClient() {
|
||||
return Matrix.createClient({
|
||||
createTemporaryClient() {
|
||||
if (this._tempClient) return this._tempClient; // use memoization
|
||||
return this._tempClient = Matrix.createClient({
|
||||
baseUrl: this._hsUrl,
|
||||
idBaseUrl: this._isUrl,
|
||||
});
|
||||
|
@ -61,7 +64,7 @@ export default class Login {
|
|||
|
||||
getFlows() {
|
||||
const self = this;
|
||||
const client = this._createTemporaryClient();
|
||||
const client = this.createTemporaryClient();
|
||||
return client.loginFlows().then(function(result) {
|
||||
self._flows = result.flows;
|
||||
self._currentFlowIndex = 0;
|
||||
|
@ -92,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) {
|
||||
|
@ -139,21 +144,6 @@ export default class Login {
|
|||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
getSsoLoginUrl(loginType) {
|
||||
const client = this._createTemporaryClient();
|
||||
const parsedUrl = url.parse(window.location.href, true);
|
||||
|
||||
// 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.
|
||||
parsedUrl.hash = "";
|
||||
|
||||
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
|
||||
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
|
||||
return client.getSsoLoginUrl(url.format(parsedUrl), loginType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
@ -148,6 +189,9 @@ class _MatrixClientPeg {
|
|||
// check that we have a version of the js-sdk which includes initCrypto
|
||||
if (!SettingsStore.getValue("lowBandwidth") && this.matrixClient.initCrypto) {
|
||||
await this.matrixClient.initCrypto();
|
||||
this.matrixClient.setCryptoTrustCrossSignedDevices(
|
||||
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
|
||||
);
|
||||
StorageManager.setCryptoInitialised(true);
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -155,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.
|
||||
|
@ -168,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);
|
||||
|
@ -176,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`);
|
||||
|
@ -184,7 +227,7 @@ class _MatrixClientPeg {
|
|||
console.log(`MatrixClientPeg: MatrixClient started`);
|
||||
}
|
||||
|
||||
getCredentials(): MatrixClientCreds {
|
||||
public getCredentials(): IMatrixClientCreds {
|
||||
return {
|
||||
homeserverUrl: this.matrixClient.baseUrl,
|
||||
identityServerUrl: this.matrixClient.idBaseUrl,
|
||||
|
@ -195,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!");
|
||||
|
@ -208,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'),
|
||||
|
@ -225,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.
|
||||
|
@ -250,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;
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Analytics from './Analytics';
|
||||
import dis from './dispatcher';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import {defer} from './utils/promise';
|
||||
import AsyncWrapper from './AsyncWrapper';
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
|
@ -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
173
src/RebrandListener.tsx
Normal 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.mx_RebrandListener) window.mx_RebrandListener = new RebrandListener();
|
||||
return window.mx_RebrandListener;
|
||||
}
|
||||
|
||||
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("We’re 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
@ -39,6 +39,8 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
|
|||
* If true, goes to the home page if the user cancels the action
|
||||
* @param {bool} options.go_welcome_on_cancel
|
||||
* If true, goes to the welcome page if the user cancels the action
|
||||
* @param {bool} options.screen_after
|
||||
* If present the screen to redirect to after a successful login or register.
|
||||
*/
|
||||
export async function startAnyRegistrationFlow(options) {
|
||||
if (options === undefined) options = {};
|
||||
|
@ -66,13 +68,21 @@ export async function startAnyRegistrationFlow(options) {
|
|||
// });
|
||||
//} else {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
|
||||
title: _t("Registration Required"),
|
||||
description: _t("You need to register to do this. Would you like to register now?"),
|
||||
button: _t("Register"),
|
||||
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
|
||||
hasCancelButton: true,
|
||||
quitOnly: true,
|
||||
title: _t("Sign In or Create Account"),
|
||||
description: _t("Use your account or create a new one to continue."),
|
||||
button: _t("Create Account"),
|
||||
extraButtons: [
|
||||
<button key="start_login" onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
|
||||
}}>{ _t('Sign In') }</button>,
|
||||
],
|
||||
onFinished: (proceed) => {
|
||||
if (proceed) {
|
||||
dis.dispatch({action: 'start_registration'});
|
||||
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
|
||||
} else if (options.go_home_on_cancel) {
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
} else if (options.go_welcome_on_cancel) {
|
||||
|
@ -101,4 +111,3 @@ export async function startAnyRegistrationFlow(options) {
|
|||
// }
|
||||
// throw new Error("Register request succeeded when it should have returned 401!");
|
||||
// }
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
24
src/RoomNotifsTypes.ts
Normal 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;
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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,12 +20,19 @@ 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
|
||||
integrations_rest_url: "https://scalar.vector.im/api",
|
||||
// Where to send bug reports. If not specified, bugs cannot be sent.
|
||||
bug_report_endpoint_url: null,
|
||||
// Jitsi conference options
|
||||
jitsi: {
|
||||
// Default conference domain
|
||||
preferredDomain: "jitsi.riot.im",
|
||||
},
|
||||
};
|
||||
|
||||
export default class SdkConfig {
|
||||
|
|
542
src/Searching.js
542
src/Searching.js
|
@ -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,9 +155,54 @@ 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: [],
|
||||
};
|
||||
|
||||
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: {
|
||||
|
@ -97,13 +210,324 @@ async function localSearch(searchTerm, roomId = undefined) {
|
|||
},
|
||||
};
|
||||
|
||||
const emptyResult = {
|
||||
results: [],
|
||||
highlights: [],
|
||||
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 result = MatrixClientPeg.get()._processRoomEventsSearch(
|
||||
emptyResult, response);
|
||||
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;
|
||||
}
|
||||
|
@ -115,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
|
||||
|
@ -130,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);
|
||||
}
|
||||
|
|
|
@ -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,25 @@ 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";
|
||||
|
||||
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 +77,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 +131,7 @@ function reject(error) {
|
|||
return {error};
|
||||
}
|
||||
|
||||
function success(promise) {
|
||||
function success(promise?: Promise<any>) {
|
||||
return {promise};
|
||||
}
|
||||
|
||||
|
@ -103,11 +139,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 +153,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 +162,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 +187,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 +257,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 +269,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 +288,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 +305,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 +331,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 +348,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 +358,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 +368,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 +386,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) {
|
||||
|
@ -385,17 +421,20 @@ export const CommandMap = {
|
|||
button: _t("Continue"),
|
||||
},
|
||||
));
|
||||
|
||||
finished = finished.then(([useDefault]: any) => {
|
||||
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(finished.then(() => {
|
||||
return inviter.invite([address]);
|
||||
}).then(() => {
|
||||
if (inviter.getCompletionState(address) !== "invited") {
|
||||
|
@ -408,12 +447,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 +495,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 +559,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 +593,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 +606,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 +621,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 +636,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 +652,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 +682,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 +713,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,14 +724,15 @@ 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 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, userId, powerLevel, powerLevelEvent));
|
||||
}
|
||||
}
|
||||
|
@ -712,10 +741,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 +751,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 +762,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 +772,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 +839,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 +890,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 +903,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 +913,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 +923,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,28 +934,115 @@ export const CommandMap = {
|
|||
},
|
||||
category: CommandCategories.advanced,
|
||||
}),
|
||||
};
|
||||
/* eslint-enable babel/no-invalid-this */
|
||||
new Command({
|
||||
command: "whois",
|
||||
description: _td("Displays information about a user"),
|
||||
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<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,
|
||||
}),
|
||||
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());
|
||||
}
|
||||
|
||||
// helpful aliases
|
||||
const aliases = {
|
||||
j: "join",
|
||||
newballsplease: "discardsession",
|
||||
goto: "join", // because it handles event permalinks magically
|
||||
roomnick: "myroomnick",
|
||||
};
|
||||
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);
|
||||
})());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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,
|
||||
}),
|
||||
];
|
||||
|
||||
// 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+$/, '');
|
||||
|
@ -930,10 +1058,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);
|
||||
}
|
||||
}
|
|
@ -127,6 +127,13 @@ function textForRoomNameEvent(ev) {
|
|||
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
|
||||
return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName});
|
||||
}
|
||||
if (ev.getPrevContent().name) {
|
||||
return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
|
||||
senderDisplayName,
|
||||
oldRoomName: ev.getPrevContent().name,
|
||||
newRoomName: ev.getContent().name,
|
||||
});
|
||||
}
|
||||
return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
|
||||
senderDisplayName,
|
||||
roomName: ev.getContent().name,
|
||||
|
@ -269,85 +276,55 @@ function textForMessageEvent(ev) {
|
|||
return message;
|
||||
}
|
||||
|
||||
function textForRoomAliasesEvent(ev) {
|
||||
// An alternative implementation of this as a first-class event can be found at
|
||||
// https://github.com/matrix-org/matrix-react-sdk/blob/dc7212ec2bd12e1917233ed7153b3e0ef529a135/src/components/views/messages/RoomAliasesEvent.js
|
||||
// This feels a bit overkill though, and it's not clear the i18n really needs it
|
||||
// so instead it's landing as a simple textual event.
|
||||
|
||||
const maxShown = 3;
|
||||
|
||||
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const oldAliases = ev.getPrevContent().aliases || [];
|
||||
const newAliases = ev.getContent().aliases || [];
|
||||
|
||||
const addedAliases = newAliases.filter((x) => !oldAliases.includes(x));
|
||||
const removedAliases = oldAliases.filter((x) => !newAliases.includes(x));
|
||||
|
||||
if (!addedAliases.length && !removedAliases.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (addedAliases.length && !removedAliases.length) {
|
||||
if (addedAliases.length > maxShown) {
|
||||
return _t("%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room", {
|
||||
senderName: senderName,
|
||||
count: addedAliases.length - maxShown,
|
||||
addedAddresses: addedAliases.slice(0, maxShown).join(', '),
|
||||
});
|
||||
}
|
||||
return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', {
|
||||
senderName: senderName,
|
||||
count: addedAliases.length,
|
||||
addedAddresses: addedAliases.join(', '),
|
||||
});
|
||||
} else if (!addedAliases.length && removedAliases.length) {
|
||||
if (removedAliases.length > maxShown) {
|
||||
return _t("%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room", {
|
||||
senderName: senderName,
|
||||
count: removedAliases.length - maxShown,
|
||||
removedAddresses: removedAliases.slice(0, maxShown).join(', '),
|
||||
});
|
||||
}
|
||||
return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', {
|
||||
senderName: senderName,
|
||||
count: removedAliases.length,
|
||||
removedAddresses: removedAliases.join(', '),
|
||||
});
|
||||
} else {
|
||||
const combined = addedAliases.length + removedAliases.length;
|
||||
if (combined > maxShown) {
|
||||
return _t("%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room", {
|
||||
senderName: senderName,
|
||||
countAdded: addedAliases.length,
|
||||
countRemoved: removedAliases.length,
|
||||
});
|
||||
}
|
||||
return _t(
|
||||
'%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', {
|
||||
senderName: senderName,
|
||||
addedAddresses: addedAliases.join(', '),
|
||||
removedAddresses: removedAliases.join(', '),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function textForCanonicalAliasEvent(ev) {
|
||||
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const oldAlias = ev.getPrevContent().alias;
|
||||
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
|
||||
const newAlias = ev.getContent().alias;
|
||||
const newAltAliases = ev.getContent().alt_aliases || [];
|
||||
const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias));
|
||||
const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias));
|
||||
|
||||
if (newAlias) {
|
||||
return _t('%(senderName)s set the main address for this room to %(address)s.', {
|
||||
senderName: senderName,
|
||||
address: ev.getContent().alias,
|
||||
});
|
||||
} else if (oldAlias) {
|
||||
return _t('%(senderName)s removed the main address for this room.', {
|
||||
if (!removedAltAliases.length && !addedAltAliases.length) {
|
||||
if (newAlias) {
|
||||
return _t('%(senderName)s set the main address for this room to %(address)s.', {
|
||||
senderName: senderName,
|
||||
address: ev.getContent().alias,
|
||||
});
|
||||
} else if (oldAlias) {
|
||||
return _t('%(senderName)s removed the main address for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
} else if (newAlias === oldAlias) {
|
||||
if (addedAltAliases.length && !removedAltAliases.length) {
|
||||
return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
|
||||
senderName: senderName,
|
||||
addresses: addedAltAliases.join(", "),
|
||||
count: addedAltAliases.length,
|
||||
});
|
||||
} if (removedAltAliases.length && !addedAltAliases.length) {
|
||||
return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
|
||||
senderName: senderName,
|
||||
addresses: removedAltAliases.join(", "),
|
||||
count: removedAltAliases.length,
|
||||
});
|
||||
} if (removedAltAliases.length && addedAltAliases.length) {
|
||||
return _t('%(senderName)s changed the alternative addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// both alias and alt_aliases where modified
|
||||
return _t('%(senderName)s changed the main and alternative addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
// in case there is no difference between the two events,
|
||||
// say something as we can't simply hide the tile from here
|
||||
return _t('%(senderName)s changed the addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
|
||||
function textForCallAnswerEvent(event) {
|
||||
|
@ -612,7 +589,6 @@ const handlers = {
|
|||
};
|
||||
|
||||
const stateHandlers = {
|
||||
'm.room.aliases': textForRoomAliasesEvent,
|
||||
'm.room.canonical_alias': textForCanonicalAliasEvent,
|
||||
'm.room.name': textForRoomNameEvent,
|
||||
'm.room.topic': textForTopicEvent,
|
||||
|
@ -627,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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -27,6 +27,7 @@ import {MatrixClientPeg} from "./MatrixClientPeg";
|
|||
import SettingsStore from "./settings/SettingsStore";
|
||||
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
|
||||
import WidgetUtils from "./utils/WidgetUtils";
|
||||
import {KnownWidgetActions} from "./widgets/WidgetApi";
|
||||
|
||||
if (!global.mxFromWidgetMessaging) {
|
||||
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
|
||||
|
@ -40,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;
|
||||
|
@ -75,12 +85,34 @@ export default class WidgetMessaging {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget that the client is ready to handle further widget requests.
|
||||
* @returns {Promise<*>} Resolves after the widget has acknowledged the ready message.
|
||||
*/
|
||||
flagReadyToContinue() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.ClientReady,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget that it should terminate now.
|
||||
* @returns {Promise<*>} Resolves when widget has acknowledged the message.
|
||||
*/
|
||||
terminate() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.Terminate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a screenshot from a widget
|
||||
* @return {Promise} To be resolved with screenshot data when it has been generated
|
||||
*/
|
||||
getScreenshot() {
|
||||
console.warn('Requesting screenshot for', this.widgetId);
|
||||
console.log('Requesting screenshot for', this.widgetId);
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "screenshot",
|
||||
|
@ -94,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;
|
||||
});
|
||||
}
|
||||
|
@ -116,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)) {
|
||||
|
@ -149,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,
|
||||
|
||||
|
|
383
src/accessibility/KeyboardShortcuts.tsx
Normal file
383
src/accessibility/KeyboardShortcuts.tsx
Normal file
|
@ -0,0 +1,383 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
|
||||
import * as sdk from "../index";
|
||||
import Modal from "../Modal";
|
||||
import { _t, _td } from "../languageHandler";
|
||||
import {isMac, Key} from "../Keyboard";
|
||||
|
||||
// TS: once languageHandler is TS we can probably inline this into the enum
|
||||
_td("Navigation");
|
||||
_td("Calls");
|
||||
_td("Composer");
|
||||
_td("Room List");
|
||||
_td("Autocomplete");
|
||||
|
||||
export enum Categories {
|
||||
NAVIGATION = "Navigation",
|
||||
CALLS = "Calls",
|
||||
COMPOSER = "Composer",
|
||||
ROOM_LIST = "Room List",
|
||||
ROOM = "Room",
|
||||
AUTOCOMPLETE = "Autocomplete",
|
||||
}
|
||||
|
||||
// TS: once languageHandler is TS we can probably inline this into the enum
|
||||
_td("Alt");
|
||||
_td("Alt Gr");
|
||||
_td("Shift");
|
||||
_td("Super");
|
||||
_td("Ctrl");
|
||||
|
||||
export enum Modifiers {
|
||||
ALT = "Alt", // Option on Mac and displayed as an Icon
|
||||
ALT_GR = "Alt Gr",
|
||||
SHIFT = "Shift",
|
||||
SUPER = "Super", // should this be "Windows"?
|
||||
// Instead of using below, consider CMD_OR_CTRL
|
||||
COMMAND = "Command", // This gets displayed as an Icon
|
||||
CONTROL = "Ctrl",
|
||||
}
|
||||
|
||||
// Meta-modifier: isMac ? CMD : CONTROL
|
||||
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
|
||||
|
||||
interface IKeybind {
|
||||
modifiers?: Modifiers[];
|
||||
key: string; // TS: fix this once Key is an enum
|
||||
}
|
||||
|
||||
interface IShortcut {
|
||||
keybinds: IKeybind[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
const shortcuts: Record<Categories, IShortcut[]> = {
|
||||
[Categories.COMPOSER]: [
|
||||
{
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.B,
|
||||
}],
|
||||
description: _td("Toggle Bold"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.I,
|
||||
}],
|
||||
description: _td("Toggle Italics"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.GREATER_THAN,
|
||||
}],
|
||||
description: _td("Toggle Quote"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [Modifiers.SHIFT],
|
||||
key: Key.ENTER,
|
||||
}],
|
||||
description: _td("New line"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ARROW_UP,
|
||||
}, {
|
||||
key: Key.ARROW_DOWN,
|
||||
}],
|
||||
description: _td("Navigate recent messages to edit"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.HOME,
|
||||
}, {
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.END,
|
||||
}],
|
||||
description: _td("Jump to start/end of the composer"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [Modifiers.CONTROL, Modifiers.ALT],
|
||||
key: Key.ARROW_UP,
|
||||
}, {
|
||||
modifiers: [Modifiers.CONTROL, Modifiers.ALT],
|
||||
key: Key.ARROW_DOWN,
|
||||
}],
|
||||
description: _td("Navigate composer history"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ESCAPE,
|
||||
}],
|
||||
description: _td("Cancel replying to a message"),
|
||||
},
|
||||
],
|
||||
|
||||
[Categories.CALLS]: [
|
||||
{
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.D,
|
||||
}],
|
||||
description: _td("Toggle microphone mute"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.E,
|
||||
}],
|
||||
description: _td("Toggle video on/off"),
|
||||
},
|
||||
],
|
||||
|
||||
[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: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.K,
|
||||
}],
|
||||
description: _td("Jump to room search"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ARROW_UP,
|
||||
}, {
|
||||
key: Key.ARROW_DOWN,
|
||||
}],
|
||||
description: _td("Navigate up/down in the room list"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ENTER,
|
||||
}],
|
||||
description: _td("Select room from the room list"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ARROW_LEFT,
|
||||
}],
|
||||
description: _td("Collapse room list section"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ARROW_RIGHT,
|
||||
}],
|
||||
description: _td("Expand room list section"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ESCAPE,
|
||||
}],
|
||||
description: _td("Clear room list filter field"),
|
||||
},
|
||||
],
|
||||
|
||||
[Categories.NAVIGATION]: [
|
||||
{
|
||||
keybinds: [{
|
||||
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
|
||||
key: Key.ARROW_UP,
|
||||
}, {
|
||||
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
|
||||
key: Key.ARROW_DOWN,
|
||||
}],
|
||||
description: _td("Previous/next unread room or DM"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [Modifiers.ALT],
|
||||
key: Key.ARROW_UP,
|
||||
}, {
|
||||
modifiers: [Modifiers.ALT],
|
||||
key: Key.ARROW_DOWN,
|
||||
}],
|
||||
description: _td("Previous/next room or DM"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.BACKTICK,
|
||||
}],
|
||||
description: _td("Toggle the top left menu"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ESCAPE,
|
||||
}],
|
||||
description: _td("Close dialog or context menu"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ENTER,
|
||||
}, {
|
||||
key: Key.SPACE,
|
||||
}],
|
||||
description: _td("Activate selected button"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.PERIOD,
|
||||
}],
|
||||
description: _td("Toggle right panel"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL],
|
||||
key: Key.SLASH,
|
||||
}],
|
||||
description: _td("Toggle this dialog"),
|
||||
},
|
||||
],
|
||||
|
||||
[Categories.AUTOCOMPLETE]: [
|
||||
{
|
||||
keybinds: [{
|
||||
key: Key.ARROW_UP,
|
||||
}, {
|
||||
key: Key.ARROW_DOWN,
|
||||
}],
|
||||
description: _td("Move autocomplete selection up/down"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
key: Key.ESCAPE,
|
||||
}],
|
||||
description: _td("Cancel autocomplete"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const categoryOrder = [
|
||||
Categories.COMPOSER,
|
||||
Categories.AUTOCOMPLETE,
|
||||
Categories.ROOM,
|
||||
Categories.ROOM_LIST,
|
||||
Categories.NAVIGATION,
|
||||
Categories.CALLS,
|
||||
];
|
||||
|
||||
interface IModal {
|
||||
close: () => void;
|
||||
finished: Promise<any[]>;
|
||||
}
|
||||
|
||||
const modifierIcon: Record<string, string> = {
|
||||
[Modifiers.COMMAND]: "⌘",
|
||||
};
|
||||
|
||||
if (isMac) {
|
||||
modifierIcon[Modifiers.ALT] = "⌥";
|
||||
}
|
||||
|
||||
const alternateKeyName: Record<string, string> = {
|
||||
[Key.PAGE_UP]: _td("Page Up"),
|
||||
[Key.PAGE_DOWN]: _td("Page Down"),
|
||||
[Key.ESCAPE]: _td("Esc"),
|
||||
[Key.ENTER]: _td("Enter"),
|
||||
[Key.SPACE]: _td("Space"),
|
||||
[Key.HOME]: _td("Home"),
|
||||
[Key.END]: _td("End"),
|
||||
};
|
||||
const keyIcon: Record<string, string> = {
|
||||
[Key.ARROW_UP]: "↑",
|
||||
[Key.ARROW_DOWN]: "↓",
|
||||
[Key.ARROW_LEFT]: "←",
|
||||
[Key.ARROW_RIGHT]: "→",
|
||||
};
|
||||
|
||||
const Shortcut: React.FC<{
|
||||
shortcut: IShortcut;
|
||||
}> = ({shortcut}) => {
|
||||
const classes = classNames({
|
||||
"mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0),
|
||||
});
|
||||
|
||||
return <div className={classes}>
|
||||
<h5>{ _t(shortcut.description) }</h5>
|
||||
{ shortcut.keybinds.map(s => {
|
||||
let text = s.key;
|
||||
if (alternateKeyName[s.key]) {
|
||||
text = _t(alternateKeyName[s.key]);
|
||||
} else if (keyIcon[s.key]) {
|
||||
text = keyIcon[s.key];
|
||||
}
|
||||
|
||||
return <div key={s.key}>
|
||||
{ s.modifiers && s.modifiers.map(m => {
|
||||
return <React.Fragment key={m}>
|
||||
<kbd>{ modifierIcon[m] || _t(m) }</kbd>+
|
||||
</React.Fragment>;
|
||||
}) }
|
||||
<kbd>{ text }</kbd>
|
||||
</div>;
|
||||
}) }
|
||||
</div>;
|
||||
};
|
||||
|
||||
let activeModal: IModal = null;
|
||||
export const toggleDialog = () => {
|
||||
if (activeModal) {
|
||||
activeModal.close();
|
||||
activeModal = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = categoryOrder.map(category => {
|
||||
const list = shortcuts[category];
|
||||
return <div className="mx_KeyboardShortcutsDialog_category" key={category}>
|
||||
<h3>{_t(category)}</h3>
|
||||
<div>{list.map(shortcut => <Shortcut key={shortcut.description} shortcut={shortcut} />)}</div>
|
||||
</div>;
|
||||
});
|
||||
|
||||
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
||||
activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, {
|
||||
className: "mx_KeyboardShortcutsDialog",
|
||||
title: _t("Keyboard Shortcuts"),
|
||||
description: sections,
|
||||
hasCloseButton: true,
|
||||
onKeyDown: (ev) => {
|
||||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.SLASH) { // Ctrl + /
|
||||
ev.stopPropagation();
|
||||
activeModal.close();
|
||||
}
|
||||
},
|
||||
onFinished: () => {
|
||||
activeModal = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const registerShortcut = (category: Categories, defn: IShortcut) => {
|
||||
shortcuts[category].push(defn);
|
||||
};
|
|
@ -22,9 +22,13 @@ import React, {
|
|||
useMemo,
|
||||
useRef,
|
||||
useReducer,
|
||||
Reducer,
|
||||
RefObject,
|
||||
Dispatch,
|
||||
} from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import {Key} from "../Keyboard";
|
||||
import AccessibleButton from "../components/views/elements/AccessibleButton";
|
||||
|
||||
/**
|
||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||
|
@ -41,7 +45,19 @@ import {Key} from "../Keyboard";
|
|||
|
||||
const DOCUMENT_POSITION_PRECEDING = 2;
|
||||
|
||||
const RovingTabIndexContext = createContext({
|
||||
type Ref = RefObject<HTMLElement>;
|
||||
|
||||
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 +66,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 +114,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 +139,7 @@ const reducer = (state, action) => {
|
|||
refs,
|
||||
};
|
||||
}
|
||||
case types.SET_FOCUS: {
|
||||
case Type.SetFocus: {
|
||||
// update active ref
|
||||
return {
|
||||
...state,
|
||||
|
@ -129,13 +151,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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -171,19 +201,17 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) =>
|
|||
{ children({onKeyDownHandler}) }
|
||||
</RovingTabIndexContext.Provider>;
|
||||
};
|
||||
RovingTabIndexProvider.propTypes = {
|
||||
handleHomeEnd: PropTypes.bool,
|
||||
onKeyDown: PropTypes.func,
|
||||
};
|
||||
|
||||
type FocusHandler = () => void;
|
||||
|
||||
// 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 +221,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 +235,7 @@ export const useRovingTabIndex = (inputRef) => {
|
|||
|
||||
const onFocus = useCallback(() => {
|
||||
context.dispatch({
|
||||
type: types.SET_FOCUS,
|
||||
type: Type.SetFocus,
|
||||
payload: {ref},
|
||||
});
|
||||
}, [ref, context]);
|
||||
|
@ -216,9 +244,28 @@ export const useRovingTabIndex = (inputRef) => {
|
|||
return [onFocus, isActive, ref];
|
||||
};
|
||||
|
||||
interface IRovingTabIndexWrapperProps {
|
||||
inputRef?: Ref;
|
||||
children(renderProps: {
|
||||
onFocus: FocusHandler;
|
||||
isActive: boolean;
|
||||
ref: Ref;
|
||||
});
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||
export const RovingTabIndexWrapper = ({children, inputRef}) => {
|
||||
export const RovingTabIndexWrapper: React.FC<IRovingTabIndexWrapperProps> = ({children, inputRef}) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return children({onFocus, isActive, ref});
|
||||
};
|
||||
|
||||
interface IRovingAccessibleButtonProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||
inputRef?: Ref;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
|
||||
export const RovingAccessibleButton: React.FC<IRovingAccessibleButtonProps> = ({inputRef, ...props}) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
|
||||
};
|
||||
|
51
src/accessibility/context_menu/ContextMenuButton.tsx
Normal file
51
src/accessibility/context_menu/ContextMenuButton.tsx
Normal 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, {IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton";
|
||||
|
||||
interface IProps extends IAccessibleButtonProps {
|
||||
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>
|
||||
);
|
||||
};
|
30
src/accessibility/context_menu/MenuGroup.tsx
Normal file
30
src/accessibility/context_menu/MenuGroup.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
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";
|
||||
|
||||
interface IProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Semantic component for representing a role=group for grouping menu radios/checkboxes
|
||||
export const MenuGroup: React.FC<IProps> = ({children, label, ...props}) => {
|
||||
return <div {...props} role="group" aria-label={label}>
|
||||
{ children }
|
||||
</div>;
|
||||
};
|
35
src/accessibility/context_menu/MenuItem.tsx
Normal file
35
src/accessibility/context_menu/MenuItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
43
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal file
43
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
43
src/accessibility/context_menu/MenuItemRadio.tsx
Normal file
43
src/accessibility/context_menu/MenuItemRadio.tsx
Normal 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>
|
||||
);
|
||||
};
|
64
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal file
64
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
64
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal file
64
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
34
src/actions/GroupActions.ts
Normal file
34
src/actions/GroupActions.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
152
src/actions/RoomListActions.ts
Normal file
152
src/actions/RoomListActions.ts
Normal file
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
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 { TAG_DM } from '../stores/RoomListStore';
|
||||
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 { RoomListStoreTempProxy } from "../stores/room-list/RoomListStoreTempProxy";
|
||||
|
||||
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?
|
||||
if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
|
||||
const lists = RoomListStoreTempProxy.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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
111
src/actions/TagOrderActions.ts
Normal file
111
src/actions/TagOrderActions.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
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};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,47 +127,44 @@ 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 downloading messages for any room.");
|
||||
crawlerState = _t("Not currently indexing messages for any room.");
|
||||
} else {
|
||||
crawlerState = (
|
||||
_t("Downloading mesages for %(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 />
|
||||
{_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}<br />
|
||||
{_t("Indexed rooms:")} {_t("%(crawlingRooms)s out of %(totalRooms)s", {
|
||||
crawlingRooms: formatCountLong(this.state.crawlingRoomsCount),
|
||||
{_t("Indexed rooms:")} {_t("%(doneRooms)s out of %(totalRooms)s", {
|
||||
doneRooms: formatCountLong(doneRooms),
|
||||
totalRooms: formatCountLong(this.state.roomCount),
|
||||
})} <br />
|
||||
{crawlerState}<br />
|
||||
<Field
|
||||
id={"crawlerSleepTimeMs"}
|
||||
label={_t('Message downloading sleep time(ms)')}
|
||||
type='number'
|
||||
value={this.state.crawlerSleepTime}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -15,37 +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
|
||||
|
@ -66,57 +66,73 @@ 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
|
||||
// for /keys/device_signing/upload?
|
||||
canUploadKeysWithPasswordOnly: null,
|
||||
accountPassword: props.accountPassword,
|
||||
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,
|
||||
};
|
||||
} catch (e) {
|
||||
this.setState({phase: PHASE_LOADERROR});
|
||||
}
|
||||
}
|
||||
|
||||
async _queryKeyUploadAuth() {
|
||||
|
@ -127,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';
|
||||
|
@ -143,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) => {
|
||||
|
@ -163,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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -193,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;
|
||||
|
@ -226,23 +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,
|
||||
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({
|
||||
|
@ -266,16 +328,24 @@ 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 = */ true,
|
||||
'Restore Backup', '', RestoreKeyBackupDialog,
|
||||
{
|
||||
showSummary: false,
|
||||
keyCallback,
|
||||
},
|
||||
null, /* priority = */ false, /* static = */ false,
|
||||
);
|
||||
|
||||
await finished;
|
||||
await this._fetchBackupInfo();
|
||||
const { backupSigStatus } = await this._fetchBackupInfo();
|
||||
if (
|
||||
this.state.backupSigStatus.usable &&
|
||||
backupSigStatus.usable &&
|
||||
this.state.canUploadKeysWithPasswordOnly &&
|
||||
this.state.accountPassword
|
||||
) {
|
||||
|
@ -283,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) => {
|
||||
|
@ -328,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,
|
||||
});
|
||||
}
|
||||
|
@ -342,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) => {
|
||||
|
@ -379,45 +426,82 @@ 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("We’ll 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;
|
||||
let nextCaption = _t("Next");
|
||||
if (!this.state.backupSigStatus.usable) {
|
||||
authPrompt = <div>
|
||||
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
|
||||
</div>;
|
||||
nextCaption = _t("Restore");
|
||||
} else if (this.state.canUploadKeysWithPasswordOnly) {
|
||||
if (this.state.canUploadKeysWithPasswordOnly) {
|
||||
authPrompt = <div>
|
||||
<div>{_t("Enter your account password to confirm the upgrade:")}</div>
|
||||
<div><Field
|
||||
type="password"
|
||||
id="mx_CreateSecretStorage_accountPassword"
|
||||
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>;
|
||||
} else if (!this.state.backupSigStatus.usable) {
|
||||
authPrompt = <div>
|
||||
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
|
||||
</div>;
|
||||
nextCaption = _t("Restore");
|
||||
} else {
|
||||
authPrompt = <p>
|
||||
{_t("You'll need to authenticate with the server to confirm the upgrade.")}
|
||||
|
@ -433,10 +517,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
<div>{authPrompt}</div>
|
||||
<DialogButtons
|
||||
primaryButton={nextCaption}
|
||||
onPrimaryButtonClick={this._onMigrateFormSubmit}
|
||||
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>
|
||||
|
@ -444,98 +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 it’s used to safeguard your data. " +
|
||||
"To be secure, you shouldn’t re-use your account password.",
|
||||
)}</p>
|
||||
|
||||
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
|
||||
<Field
|
||||
type="password"
|
||||
id="mx_CreateSecretStorageDialog_passPhraseField"
|
||||
<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
|
||||
|
@ -545,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;
|
||||
|
@ -553,24 +591,22 @@ 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
|
||||
type="password"
|
||||
id="mx_CreateSecretStorageDialog_passPhraseField"
|
||||
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"
|
||||
/>
|
||||
|
@ -585,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>
|
||||
|
@ -593,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 it’s 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>;
|
||||
}
|
||||
|
||||
|
@ -659,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 won’t 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 '';
|
||||
}
|
||||
|
@ -716,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">
|
||||
|
@ -732,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;
|
||||
|
@ -744,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}
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}));
|
||||
|
|
@ -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 }
|
|
@ -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"
|
|
@ -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,
|
||||
};
|
66
src/autocomplete/Components.tsx
Normal file
66
src/autocomplete/Components.tsx
Normal 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>
|
||||
);
|
||||
});
|
|
@ -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"
|
|
@ -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 }
|
|
@ -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"
|
|
@ -1,4 +1,3 @@
|
|||
//@flow
|
||||
/*
|
||||
Copyright 2017 Aviral Dasgupta
|
||||
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
@ -18,19 +17,25 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import _at from 'lodash/at';
|
||||
import _flatMap from 'lodash/flatMap';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import _uniq from 'lodash/uniq';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,8 +44,13 @@ 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 _keys: IOptions<T>["keys"];
|
||||
private _funcs: Required<IOptions<T>["funcs"]>;
|
||||
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 || [];
|
||||
|
@ -60,28 +70,35 @@ 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._keys);
|
||||
|
||||
for (const f of this._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();
|
||||
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> {
|
||||
match(query: string): T[] {
|
||||
query = stripDiacritics(query).toLowerCase();
|
||||
if (this._options.shouldMatchWordsOnly) {
|
||||
query = query.replace(/[^\w]/g, '');
|
||||
|
@ -89,32 +106,40 @@ export default class QueryMatcher {
|
|||
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));
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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 }
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
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.
|
||||
|
@ -16,93 +17,10 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
// derived from code from github.com/noeldelgado/gemini-scrollbar
|
||||
// Copyright (c) Noel Delgado <pixelia.me@gmail.com> (pixelia.me)
|
||||
function getScrollbarWidth(alternativeOverflow) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mx_AutoHideScrollbar'; //to get width of css scrollbar
|
||||
div.style.position = 'absolute';
|
||||
div.style.top = '-9999px';
|
||||
div.style.width = '100px';
|
||||
div.style.height = '100px';
|
||||
div.style.overflow = "scroll";
|
||||
if (alternativeOverflow) {
|
||||
div.style.overflow = alternativeOverflow;
|
||||
}
|
||||
div.style.msOverflowStyle = '-ms-autohiding-scrollbar';
|
||||
document.body.appendChild(div);
|
||||
const scrollbarWidth = (div.offsetWidth - div.clientWidth);
|
||||
document.body.removeChild(div);
|
||||
return scrollbarWidth;
|
||||
}
|
||||
|
||||
function install() {
|
||||
const scrollbarWidth = getScrollbarWidth();
|
||||
if (scrollbarWidth !== 0) {
|
||||
const hasForcedOverlayScrollbar = getScrollbarWidth('overlay') === 0;
|
||||
// overflow: overlay on webkit doesn't auto hide the scrollbar
|
||||
if (hasForcedOverlayScrollbar) {
|
||||
document.body.classList.add("mx_scrollbar_overlay_noautohide");
|
||||
} else {
|
||||
document.body.classList.add("mx_scrollbar_nooverlay");
|
||||
const style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
style.innerText =
|
||||
`body.mx_scrollbar_nooverlay { --scrollbar-width: ${scrollbarWidth}px; }`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const installBodyClassesIfNeeded = (function() {
|
||||
let installed = false;
|
||||
return function() {
|
||||
if (!installed) {
|
||||
install();
|
||||
installed = true;
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
export default class AutoHideScrollbar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onOverflow = this.onOverflow.bind(this);
|
||||
this.onUnderflow = this.onUnderflow.bind(this);
|
||||
this._collectContainerRef = this._collectContainerRef.bind(this);
|
||||
this._needsOverflowListener = null;
|
||||
}
|
||||
|
||||
onOverflow() {
|
||||
this.containerRef.classList.add("mx_AutoHideScrollbar_overflow");
|
||||
this.containerRef.classList.remove("mx_AutoHideScrollbar_underflow");
|
||||
}
|
||||
|
||||
onUnderflow() {
|
||||
this.containerRef.classList.remove("mx_AutoHideScrollbar_overflow");
|
||||
this.containerRef.classList.add("mx_AutoHideScrollbar_underflow");
|
||||
}
|
||||
|
||||
checkOverflow() {
|
||||
if (!this._needsOverflowListener) {
|
||||
return;
|
||||
}
|
||||
if (this.containerRef.scrollHeight > this.containerRef.clientHeight) {
|
||||
this.onOverflow();
|
||||
} else {
|
||||
this.onUnderflow();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.checkOverflow();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
installBodyClassesIfNeeded();
|
||||
this._needsOverflowListener =
|
||||
document.body.classList.contains("mx_scrollbar_nooverlay");
|
||||
this.checkOverflow();
|
||||
}
|
||||
|
||||
_collectContainerRef(ref) {
|
||||
|
@ -126,9 +44,7 @@ export default class AutoHideScrollbar extends React.Component {
|
|||
onScroll={this.props.onScroll}
|
||||
onWheel={this.props.onWheel}
|
||||
>
|
||||
<div className="mx_AutoHideScrollbar_offset">
|
||||
{ this.props.children }
|
||||
</div>
|
||||
{ this.props.children }
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
@ -203,16 +236,16 @@ export class ContextMenu extends React.Component {
|
|||
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 +258,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 +268,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 +296,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 +315,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 +352,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,92 +382,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.isRequired,
|
||||
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 = {
|
||||
...AccessibleButton.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
|
||||
|
@ -427,8 +396,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;
|
||||
|
@ -486,3 +455,12 @@ export function createMenu(ElementClass, props) {
|
|||
|
||||
return {close: onFinished};
|
||||
}
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
|
||||
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";
|
|
@ -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()});
|
||||
});
|
||||
|
|
|
@ -23,11 +23,11 @@ import PropTypes from 'prop-types';
|
|||
import request from 'browser-request';
|
||||
import { _t } from '../../languageHandler';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import classnames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
export default class EmbeddedPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
|
@ -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 });
|
||||
},
|
||||
);
|
||||
|
@ -117,10 +126,9 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
</div>;
|
||||
|
||||
if (this.props.scrollbar) {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
return <GeminiScrollbarWrapper autoshow={true} className={classes}>
|
||||
return <AutoHideScrollbar className={classes}>
|
||||
{content}
|
||||
</GeminiScrollbarWrapper>;
|
||||
</AutoHideScrollbar>;
|
||||
} else {
|
||||
return <div className={classes}>
|
||||
{content}
|
||||
|
|
|
@ -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';
|
||||
|
@ -39,6 +39,7 @@ import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Perm
|
|||
import {Group} from "matrix-js-sdk";
|
||||
import {allSettled, sleep} from "../../utils/promise";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
`<h1>HTML for your community's page</h1>
|
||||
|
@ -91,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'],
|
||||
|
@ -423,28 +424,35 @@ export default createReactClass({
|
|||
membershipBusy: false,
|
||||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
};
|
||||
},
|
||||
|
||||
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);
|
||||
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this._unmounted = true;
|
||||
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
dis.unregister(this._dispatcherRef);
|
||||
|
||||
// Remove RightPanelStore listener
|
||||
if (this._rightPanelStoreToken) {
|
||||
this._rightPanelStoreToken.remove();
|
||||
}
|
||||
},
|
||||
|
||||
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,
|
||||
|
@ -454,6 +462,12 @@ export default createReactClass({
|
|||
}
|
||||
},
|
||||
|
||||
_onRightPanelStoreUpdate: function() {
|
||||
this.setState({
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
});
|
||||
},
|
||||
|
||||
_onGroupMyMembership: function(group) {
|
||||
if (this._unmounted || group.groupId !== this.props.groupId) return;
|
||||
if (group.myMembership === 'leave') {
|
||||
|
@ -481,7 +495,7 @@ export default createReactClass({
|
|||
group_id: groupId,
|
||||
},
|
||||
});
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}});
|
||||
willDoOnboarding = true;
|
||||
}
|
||||
if (stateKey === GroupStore.STATE_KEY.Summary) {
|
||||
|
@ -554,10 +568,6 @@ export default createReactClass({
|
|||
GROUP_JOINPOLICY_INVITE,
|
||||
},
|
||||
});
|
||||
dis.dispatch({
|
||||
action: 'panel_disable',
|
||||
sideDisabled: true,
|
||||
});
|
||||
},
|
||||
|
||||
_onShareClick: function() {
|
||||
|
@ -580,10 +590,6 @@ export default createReactClass({
|
|||
profileForm: null,
|
||||
});
|
||||
break;
|
||||
case 'after_right_panel_phase_change':
|
||||
// We don't keep state on the right panel, so just re-render to update
|
||||
this.forceUpdate();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -726,7 +732,7 @@ export default createReactClass({
|
|||
|
||||
_onJoinClick: async function() {
|
||||
if (this._matrixClient.isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1173,7 +1179,6 @@ export default createReactClass({
|
|||
render: function() {
|
||||
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
|
||||
return <Spinner />;
|
||||
|
@ -1299,9 +1304,7 @@ export default createReactClass({
|
|||
);
|
||||
}
|
||||
|
||||
const rightPanel = RightPanelStore.getSharedInstance().isOpenForGroup
|
||||
? <RightPanel groupId={this.props.groupId} />
|
||||
: undefined;
|
||||
const rightPanel = this.state.showRightPanel ? <RightPanel groupId={this.props.groupId} /> : undefined;
|
||||
|
||||
const headerClasses = {
|
||||
"mx_GroupView_header": true,
|
||||
|
@ -1332,10 +1335,10 @@ export default createReactClass({
|
|||
<GroupHeaderButtons />
|
||||
</div>
|
||||
<MainSplit panel={rightPanel}>
|
||||
<GeminiScrollbarWrapper className="mx_GroupView_body">
|
||||
<AutoHideScrollbar className="mx_GroupView_body">
|
||||
{ this._getMembershipSection() }
|
||||
{ this._getGroupSection() }
|
||||
</GeminiScrollbarWrapper>
|
||||
</AutoHideScrollbar>
|
||||
</MainSplit>
|
||||
</main>
|
||||
);
|
||||
|
|
67
src/components/structures/HomePage.tsx
Normal file
67
src/components/structures/HomePage.tsx
Normal 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;
|
|
@ -66,6 +66,22 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
this._autoHideScrollbar = autoHideScrollbar;
|
||||
}
|
||||
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
|
||||
const curLen = this.props.children && this.props.children.length || 0;
|
||||
// check overflow only if amount of children changes.
|
||||
// if we don't guard here, we end up with an infinite
|
||||
// render > componentDidUpdate > checkOverflow > setState > render loop
|
||||
if (prevLen !== curLen) {
|
||||
this.checkOverflow();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.checkOverflow();
|
||||
}
|
||||
|
||||
checkOverflow() {
|
||||
const hasTopOverflow = this._scrollElement.scrollTop > 0;
|
||||
const hasBottomOverflow = this._scrollElement.scrollHeight >
|
||||
|
@ -95,10 +111,6 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
|
||||
}
|
||||
|
||||
if (this._autoHideScrollbar) {
|
||||
this._autoHideScrollbar.checkOverflow();
|
||||
}
|
||||
|
||||
if (this.props.trackHorizontalOverflow) {
|
||||
this.setState({
|
||||
// Offset from absolute position of the container
|
||||
|
|
|
@ -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,
|
||||
|
@ -161,6 +173,7 @@ export default createReactClass({
|
|||
_authStateUpdated: function(stageType, stageState) {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
authStage: stageType,
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
|
@ -184,11 +197,13 @@ export default createReactClass({
|
|||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `_authStateUpdated`.
|
||||
// See also https://github.com/vector-im/riot-web/issues/12546
|
||||
},
|
||||
|
||||
_setFocus: function() {
|
||||
|
@ -201,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) {
|
||||
|
@ -229,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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -21,11 +21,12 @@ import PropTypes from 'prop-types';
|
|||
import classNames from 'classnames';
|
||||
import { Key } from '../../Keyboard';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import * as VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
import Analytics from "../../Analytics";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
|
||||
const LeftPanel = createReactClass({
|
||||
|
@ -44,7 +45,8 @@ const LeftPanel = createReactClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this.focusedElement = null;
|
||||
|
||||
this._breadcrumbsWatcherRef = SettingsStore.watchSetting(
|
||||
|
@ -196,7 +198,7 @@ const LeftPanel = createReactClass({
|
|||
|
||||
onSearchCleared: function(source) {
|
||||
if (source === "keyboard") {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
this.setState({searchExpanded: false});
|
||||
},
|
||||
|
@ -250,7 +252,7 @@ const LeftPanel = createReactClass({
|
|||
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>
|
||||
<AccessibleButton onClick={() => dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -272,6 +274,16 @@ const LeftPanel = createReactClass({
|
|||
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
||||
}
|
||||
|
||||
const roomList = <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} />;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{ tagPanelContainer }
|
||||
|
@ -283,15 +295,7 @@ const LeftPanel = createReactClass({
|
|||
{ 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} />
|
||||
{roomList}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
|
411
src/components/structures/LeftPanel2.tsx
Normal file
411
src/components/structures/LeftPanel2.tsx
Normal file
|
@ -0,0 +1,411 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList2 from "../views/rooms/RoomList2";
|
||||
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import UserMenu from "./UserMenu";
|
||||
import RoomSearch from "./RoomSearch";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
|
||||
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/RoomListStore2";
|
||||
import {Key} from "../../Keyboard";
|
||||
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
|
||||
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
|
||||
|
||||
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_RoomSublist2_headerText",
|
||||
"mx_RoomTile2",
|
||||
"mx_RoomSublist2_showNButton",
|
||||
];
|
||||
|
||||
export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private tagPanelWatcherRef: 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);
|
||||
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);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
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 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_RoomSublist2");
|
||||
|
||||
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_RoomSublist2_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_RoomSublist2_headerContainer_stickyTop")) {
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
|
||||
}
|
||||
|
||||
const newTop = `${list.parentElement.offsetTop}px`;
|
||||
if (header.style.top !== newTop) {
|
||||
header.style.top = newTop;
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
|
||||
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
|
||||
}
|
||||
if (header.style.top) {
|
||||
header.style.removeProperty('top');
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
|
||||
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyTop || style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
|
||||
header.classList.add("mx_RoomSublist2_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_RoomSublist2_headerContainer_sticky")) {
|
||||
header.classList.remove("mx_RoomSublist2_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_LeftPanel2_roomListWrapper
|
||||
if (lastTopHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyTop");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyTop");
|
||||
}
|
||||
if (firstBottomHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyBottom");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel2_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_RoomTile2");
|
||||
if (firstRoom) {
|
||||
firstRoom.click();
|
||||
this.onSearch(""); // clear the search field
|
||||
}
|
||||
};
|
||||
|
||||
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_LeftPanel2_userHeader">
|
||||
<UserMenu isMinimized={this.props.isMinimized} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderBreadcrumbs(): React.ReactNode {
|
||||
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
|
||||
return (
|
||||
<IndicatorScrollbar
|
||||
className="mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||
verticalScrollsHorizontally={true}
|
||||
>
|
||||
<RoomBreadcrumbs2 />
|
||||
</IndicatorScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSearchExplore(): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_LeftPanel2_filterContainer"
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
<RoomSearch
|
||||
onQueryUpdate={this.onSearch}
|
||||
isMinimized={this.props.isMinimized}
|
||||
onVerticalArrow={this.onKeyDown}
|
||||
onEnter={this.onEnter}
|
||||
/>
|
||||
<AccessibleButton
|
||||
className="mx_LeftPanel2_exploreButton"
|
||||
onClick={this.onExplore}
|
||||
title={_t("Explore rooms")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const tagPanel = !this.state.showTagPanel ? null : (
|
||||
<div className="mx_LeftPanel2_tagPanelContainer">
|
||||
<TagPanel/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const roomList = <RoomList2
|
||||
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_LeftPanel2": true,
|
||||
"mx_LeftPanel2_hasTagPanel": !!tagPanel,
|
||||
"mx_LeftPanel2_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
const roomListClasses = classNames(
|
||||
"mx_LeftPanel2_actualRoomListContainer",
|
||||
"mx_AutoHideScrollbar",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{tagPanel}
|
||||
<aside className="mx_LeftPanel2_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
{this.renderBreadcrumbs()}
|
||||
<div className="mx_LeftPanel2_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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,29 +16,45 @@ 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';
|
||||
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 { RoomListStoreTempProxy } from "../../stores/room-list/RoomListStoreTempProxy";
|
||||
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 LeftPanel2 from "./LeftPanel2";
|
||||
import CallContainer from '../views/voip/CallContainer';
|
||||
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
|
||||
// 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.
|
||||
|
@ -51,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.
|
||||
|
@ -60,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,
|
||||
|
@ -76,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;
|
||||
|
||||
|
@ -113,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.
|
||||
|
@ -147,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 = {
|
||||
|
@ -186,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 &&
|
||||
|
@ -235,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 roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
if (roomLists[DefaultTagID.ServerNotice] && roomLists[DefaultTagID.ServerNotice].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 roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
if (!roomLists[DefaultTagID.ServerNotice]) return [];
|
||||
|
||||
const events = [];
|
||||
for (const room of roomLists[DefaultTagID.ServerNotice]) {
|
||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||
|
||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||
|
@ -258,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
|
||||
|
@ -279,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:
|
||||
|
@ -305,45 +394,31 @@ 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 ||
|
||||
ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.PAGE_UP:
|
||||
case Key.PAGE_DOWN:
|
||||
if (!hasModifier) {
|
||||
if (!hasModifier && !isModifier) {
|
||||
this._onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
}
|
||||
|
@ -365,26 +440,58 @@ const LoggedInView = createReactClass({
|
|||
}
|
||||
break;
|
||||
case Key.BACKTICK:
|
||||
if (ev.key !== "`") break;
|
||||
|
||||
// Ideally this would be CTRL+P for "Profile", but that's
|
||||
// taken by the print dialog. CTRL+I for "Information"
|
||||
// was previously chosen but conflicted with italics in
|
||||
// composer, so CTRL+` it is
|
||||
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_top_left_menu',
|
||||
dis.fire(Action.ToggleUserMenu);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.SLASH:
|
||||
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) {
|
||||
KeyboardShortcuts.toggleDialog();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: ev.key === Key.ARROW_UP ? -1 : 1,
|
||||
unread: ev.shiftKey,
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.PERIOD:
|
||||
if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_right_panel',
|
||||
type: this.props.page_type === "room_view" ? "room" : "group",
|
||||
});
|
||||
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) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
} else if (!hasModifier) {
|
||||
} else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
// The above condition is crafted to _allow_ characters with Shift
|
||||
// already pressed (but not the Shift key down itself).
|
||||
|
||||
const isClickShortcut = ev.target !== document.body &&
|
||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||
|
||||
|
@ -395,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;
|
||||
|
@ -436,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;
|
||||
|
@ -455,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).
|
||||
|
@ -476,9 +583,9 @@ const LoggedInView = createReactClass({
|
|||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onMouseUp: function(ev) {
|
||||
_onMouseUp = (ev) => {
|
||||
if (!this.state.mouseDown) return;
|
||||
|
||||
const deltaX = ev.pageX - this.state.mouseDown.x;
|
||||
|
@ -497,26 +604,15 @@ 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() {
|
||||
render() {
|
||||
const LeftPanel = sdk.getComponent('structures.LeftPanel');
|
||||
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;
|
||||
|
||||
|
@ -546,13 +642,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:
|
||||
|
@ -566,49 +656,27 @@ 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
|
||||
) {
|
||||
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';
|
||||
}
|
||||
|
||||
let leftPanel = (
|
||||
<LeftPanel
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
);
|
||||
if (SettingsStore.getValue("feature_new_room_list")) {
|
||||
leftPanel = (
|
||||
<LeftPanel2
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<div
|
||||
|
@ -619,23 +687,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;
|
|
@ -93,14 +93,19 @@ export default class MainSplit extends React.Component {
|
|||
const bodyView = React.Children.only(this.props.children);
|
||||
const panelView = this.props.panel;
|
||||
|
||||
if (this.props.collapsedRhs || !panelView) {
|
||||
return bodyView;
|
||||
} else {
|
||||
return <div className="mx_MainSplit" ref={this._setResizeContainerRef}>
|
||||
{ bodyView }
|
||||
const hasResizer = !this.props.collapsedRhs && panelView;
|
||||
|
||||
let children;
|
||||
if (hasResizer) {
|
||||
children = <React.Fragment>
|
||||
<ResizeHandle reverse={true} />
|
||||
{ panelView }
|
||||
</div>;
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
return <div className="mx_MainSplit" ref={hasResizer ? this._setResizeContainerRef : undefined}>
|
||||
{ bodyView }
|
||||
{ children }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -28,10 +28,36 @@ import {MatrixClientPeg} from '../../MatrixClientPeg';
|
|||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import {textForEvent} from "../../TextForEvent";
|
||||
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
|
||||
// check if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
function shouldFormContinuation(prevEvent, mxEvent) {
|
||||
// sanity check inputs
|
||||
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
|
||||
// check if within the max continuation period
|
||||
if (mxEvent.getTs() - prevEvent.getTs() > CONTINUATION_MAX_INTERVAL) return false;
|
||||
|
||||
// Some events should appear as continuations from previous events of different types.
|
||||
if (mxEvent.getType() !== prevEvent.getType() &&
|
||||
(!continuedTypes.includes(mxEvent.getType()) ||
|
||||
!continuedTypes.includes(prevEvent.getType()))) return false;
|
||||
|
||||
// Check if the sender is the same and hasn't changed their displayname/avatar between these events
|
||||
if (mxEvent.sender.userId !== prevEvent.sender.userId ||
|
||||
mxEvent.sender.name !== prevEvent.sender.name ||
|
||||
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
|
||||
|
||||
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
|
||||
if (!haveTileForEvent(prevEvent)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite';
|
||||
|
||||
/* (almost) stateless UI component which builds the event tiles in the room timeline.
|
||||
|
@ -106,10 +132,14 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
// whether to show reactions for an event
|
||||
showReactions: PropTypes.bool,
|
||||
|
||||
// whether to use the irc layout
|
||||
useIRCLayout: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Force props to be loaded for useIRCLayout
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
// previous positions the read marker has been in, so we can
|
||||
|
@ -358,8 +388,11 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<li key={"readMarker_"+eventId} ref={this._readMarkerNode}
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
<li key={"readMarker_"+eventId}
|
||||
ref={this._readMarkerNode}
|
||||
className="mx_RoomView_myReadMarker_container"
|
||||
data-scroll-tokens={eventId}
|
||||
>
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
|
@ -502,44 +535,13 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
|
||||
_getTilesForEvent(prevEvent, mxEv, last) {
|
||||
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const ret = [];
|
||||
|
||||
const isEditing = this.props.editState &&
|
||||
this.props.editState.getEvent().getId() === mxEv.getId();
|
||||
// is this a continuation of the previous message?
|
||||
let continuation = false;
|
||||
|
||||
// Some events should appear as continuations from previous events of
|
||||
// different types.
|
||||
|
||||
const eventTypeContinues =
|
||||
prevEvent !== null &&
|
||||
continuedTypes.includes(mxEv.getType()) &&
|
||||
continuedTypes.includes(prevEvent.getType());
|
||||
|
||||
// if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId &&
|
||||
(mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
|
||||
(mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) {
|
||||
continuation = true;
|
||||
}
|
||||
|
||||
/*
|
||||
// Work out if this is still a continuation, as we are now showing commands
|
||||
// and /me messages with their own little avatar. The case of a change of
|
||||
// event type (commands) is handled above, but we need to handle the /me
|
||||
// messages seperately as they have a msgtype of 'm.emote' but are classed
|
||||
// as normal messages
|
||||
if (prevEvent !== null && prevEvent.sender && mxEv.sender
|
||||
&& mxEv.sender.userId === prevEvent.sender.userId
|
||||
&& mxEv.getType() == prevEvent.getType()
|
||||
&& prevEvent.getContent().msgtype === 'm.emote') {
|
||||
continuation = false;
|
||||
}
|
||||
*/
|
||||
|
||||
// local echoes have a fake date, which could even be yesterday. Treat them
|
||||
// as 'today' for the date separators.
|
||||
|
@ -551,12 +553,15 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
|
||||
// do we need a date separator since the last event?
|
||||
if (this._wantsDateSeparator(prevEvent, eventDate)) {
|
||||
const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
|
||||
if (wantsDateSeparator) {
|
||||
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
|
||||
ret.push(dateSeparator);
|
||||
continuation = false;
|
||||
}
|
||||
|
||||
// is this a continuation of the previous message?
|
||||
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv);
|
||||
|
||||
const eventId = mxEv.getId();
|
||||
const highlight = (eventId === this.props.highlightedEventId);
|
||||
|
||||
|
@ -575,25 +580,28 @@ export default class MessagePanel extends React.Component {
|
|||
ref={this._collectEventNode.bind(this, eventId)}
|
||||
data-scroll-tokens={scrollToken}
|
||||
>
|
||||
<EventTile mxEvent={mxEv}
|
||||
continuation={continuation}
|
||||
isRedacted={mxEv.isRedacted()}
|
||||
replacingEventId={mxEv.replacingEventId()}
|
||||
editState={isEditing && this.props.editState}
|
||||
onHeightChanged={this._onHeightChanged}
|
||||
readReceipts={readReceipts}
|
||||
readReceiptMap={this._readReceiptMap}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
checkUnmounting={this._isUnmounting.bind(this)}
|
||||
eventSendStatus={mxEv.getAssociatedStatus()}
|
||||
tileShape={this.props.tileShape}
|
||||
isTwelveHour={this.props.isTwelveHour}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
last={last}
|
||||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
showReactions={this.props.showReactions}
|
||||
/>
|
||||
<TileErrorBoundary mxEvent={mxEv}>
|
||||
<EventTile mxEvent={mxEv}
|
||||
continuation={continuation}
|
||||
isRedacted={mxEv.isRedacted()}
|
||||
replacingEventId={mxEv.replacingEventId()}
|
||||
editState={isEditing && this.props.editState}
|
||||
onHeightChanged={this._onHeightChanged}
|
||||
readReceipts={readReceipts}
|
||||
readReceiptMap={this._readReceiptMap}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
checkUnmounting={this._isUnmounting.bind(this)}
|
||||
eventSendStatus={mxEv.getAssociatedStatus()}
|
||||
tileShape={this.props.tileShape}
|
||||
isTwelveHour={this.props.isTwelveHour}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
last={last}
|
||||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
/>
|
||||
</TileErrorBoundary>
|
||||
</li>,
|
||||
);
|
||||
|
||||
|
@ -755,6 +763,7 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary');
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
|
@ -786,23 +795,35 @@ export default class MessagePanel extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
let ircResizer = null;
|
||||
if (this.props.useIRCLayout) {
|
||||
ircResizer = <IRCTimelineProfileResizer
|
||||
minWidth={20}
|
||||
maxWidth={600}
|
||||
roomId={this.props.room ? this.props.room.roomId : null}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollPanel
|
||||
ref={this._scrollPanel}
|
||||
className={className}
|
||||
onScroll={this.props.onScroll}
|
||||
onResize={this.onResize}
|
||||
onFillRequest={this.props.onFillRequest}
|
||||
onUnfillRequest={this.props.onUnfillRequest}
|
||||
style={style}
|
||||
stickyBottom={this.props.stickyBottom}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
{ topSpinner }
|
||||
{ this._getEventTiles() }
|
||||
{ whoIsTyping }
|
||||
{ bottomSpinner }
|
||||
</ScrollPanel>
|
||||
<ErrorBoundary>
|
||||
<ScrollPanel
|
||||
ref={this._scrollPanel}
|
||||
className={className}
|
||||
onScroll={this.props.onScroll}
|
||||
onResize={this.onResize}
|
||||
onFillRequest={this.props.onFillRequest}
|
||||
onUnfillRequest={this.props.onUnfillRequest}
|
||||
style={style}
|
||||
stickyBottom={this.props.stickyBottom}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
fixedChildren={ircResizer}
|
||||
>
|
||||
{ topSpinner }
|
||||
{ this._getEventTiles() }
|
||||
{ whoIsTyping }
|
||||
{ bottomSpinner }
|
||||
</ScrollPanel>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -836,14 +857,16 @@ class CreationGrouper {
|
|||
// events that we include in the group but then eject out and place
|
||||
// above the group.
|
||||
this.ejectedEvents = [];
|
||||
this.readMarker = panel._readMarkerForEvent(createEvent.getId());
|
||||
this.readMarker = panel._readMarkerForEvent(
|
||||
createEvent.getId(),
|
||||
createEvent === lastShownEvent,
|
||||
);
|
||||
}
|
||||
|
||||
shouldGroup(ev) {
|
||||
const panel = this.panel;
|
||||
const createEvent = this.createEvent;
|
||||
if (!panel._shouldShowEvent(ev)) {
|
||||
this.readMarker = this.readMarker || panel._readMarkerForEvent(ev.getId());
|
||||
return true;
|
||||
}
|
||||
if (panel._wantsDateSeparator(this.createEvent, ev.getDate())) {
|
||||
|
@ -861,7 +884,10 @@ class CreationGrouper {
|
|||
|
||||
add(ev) {
|
||||
const panel = this.panel;
|
||||
this.readMarker = this.readMarker || panel._readMarkerForEvent(ev.getId());
|
||||
this.readMarker = this.readMarker || panel._readMarkerForEvent(
|
||||
ev.getId(),
|
||||
ev === this.lastShownEvent,
|
||||
);
|
||||
if (!panel._shouldShowEvent(ev)) {
|
||||
return;
|
||||
}
|
||||
|
@ -948,18 +974,34 @@ class MemberGrouper {
|
|||
|
||||
constructor(panel, ev, prevEvent, lastShownEvent) {
|
||||
this.panel = panel;
|
||||
this.readMarker = panel._readMarkerForEvent(ev.getId());
|
||||
this.readMarker = panel._readMarkerForEvent(
|
||||
ev.getId(),
|
||||
ev === lastShownEvent,
|
||||
);
|
||||
this.events = [ev];
|
||||
this.prevEvent = prevEvent;
|
||||
this.lastShownEvent = lastShownEvent;
|
||||
}
|
||||
|
||||
shouldGroup(ev) {
|
||||
if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) {
|
||||
return false;
|
||||
}
|
||||
return isMembershipChange(ev);
|
||||
}
|
||||
|
||||
add(ev) {
|
||||
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(ev.getId());
|
||||
if (ev.getType() === 'm.room.member') {
|
||||
// We'll just double check that it's worth our time to do so, through an
|
||||
// ugly hack. If textForEvent returns something, we should group it for
|
||||
// rendering but if it doesn't then we'll exclude it.
|
||||
const renderText = textForEvent(ev);
|
||||
if (!renderText || renderText.trim().length === 0) return; // quietly ignore
|
||||
}
|
||||
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
|
||||
ev.getId(),
|
||||
ev === this.lastShownEvent,
|
||||
);
|
||||
this.events.push(ev);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations 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.
|
||||
|
@ -19,9 +20,11 @@ import React from 'react';
|
|||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from '../../dispatcher';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import AccessibleButton from '../views/elements/AccessibleButton';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MyGroups',
|
||||
|
@ -37,7 +40,7 @@ export default createReactClass({
|
|||
contextType: MatrixClientContext,
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
componentDidMount: function() {
|
||||
this._fetch();
|
||||
},
|
||||
|
||||
|
@ -59,11 +62,10 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
const GroupTile = sdk.getComponent("groups.GroupTile");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
|
||||
let content;
|
||||
let contentHeader;
|
||||
|
@ -74,11 +76,12 @@ export default createReactClass({
|
|||
});
|
||||
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
|
||||
content = groupNodes.length > 0 ?
|
||||
<GeminiScrollbarWrapper>
|
||||
<AutoHideScrollbar className="mx_MyGroups_scrollable">
|
||||
<div className="mx_MyGroups_microcopy">
|
||||
<p>
|
||||
{ _t(
|
||||
"Did you know: you can use communities to filter your Riot.im experience!",
|
||||
"Did you know: you can use communities to filter your %(brand)s experience!",
|
||||
{ brand },
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
|
@ -93,7 +96,7 @@ export default createReactClass({
|
|||
<div className="mx_MyGroups_joinedGroups">
|
||||
{ groupNodes }
|
||||
</div>
|
||||
</GeminiScrollbarWrapper> :
|
||||
</AutoHideScrollbar> :
|
||||
<div className="mx_MyGroups_placeholder">
|
||||
{ _t(
|
||||
"You're not currently a member of any communities.",
|
||||
|
|
|
@ -22,14 +22,14 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import RateLimitedFunc from '../../ratelimitedfunc';
|
||||
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import {RIGHT_PANEL_PHASES, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
export default class RightPanel extends React.Component {
|
||||
static get propTypes() {
|
||||
|
@ -108,7 +108,7 @@ export default class RightPanel extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
const cli = this.context;
|
||||
cli.on("RoomState.members", this.onRoomStateMember);
|
||||
|
@ -123,7 +123,8 @@ export default class RightPanel extends React.Component {
|
|||
this._unregisterGroupStore(this.props.groupId);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
this._initGroupStore(newProps.groupId);
|
||||
|
@ -182,20 +183,42 @@ export default class RightPanel extends React.Component {
|
|||
member: payload.member,
|
||||
event: payload.event,
|
||||
verificationRequest: payload.verificationRequest,
|
||||
verificationRequestPromise: payload.verificationRequestPromise,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCloseUserInfo = () => {
|
||||
// XXX: There are three different ways of 'closing' this panel depending on what state
|
||||
// things are in... this knows far more than it should do about the state of the rest
|
||||
// of the app and is generally a bit silly.
|
||||
if (this.props.user) {
|
||||
// If we have a user prop then we're displaying a user from the 'user' page type
|
||||
// in LoggedInView, so need to change the page type to close the panel (we switch
|
||||
// to the home page which is not obviously the correct thing to do, but I'm not sure
|
||||
// anything else is - we could hide the close button altogether?)
|
||||
dis.dispatch({
|
||||
action: "view_home_page",
|
||||
});
|
||||
} else {
|
||||
// Otherwise we have got our user from RoomViewStore which means we're being shown
|
||||
// within a room/group, so go back to the member panel if we were in the encryption panel,
|
||||
// or the member list if we were in the member panel... phew.
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const MemberList = sdk.getComponent('rooms.MemberList');
|
||||
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
|
||||
const UserInfo = sdk.getComponent('right_panel.UserInfo');
|
||||
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
|
||||
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
|
||||
const FilePanel = sdk.getComponent('structures.FilePanel');
|
||||
|
||||
const GroupMemberList = sdk.getComponent('groups.GroupMemberList');
|
||||
const GroupMemberInfo = sdk.getComponent('groups.GroupMemberInfo');
|
||||
const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
|
||||
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
|
||||
|
||||
|
@ -217,53 +240,25 @@ export default class RightPanel extends React.Component {
|
|||
break;
|
||||
case RIGHT_PANEL_PHASES.RoomMemberInfo:
|
||||
case RIGHT_PANEL_PHASES.EncryptionPanel:
|
||||
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
|
||||
const onClose = () => {
|
||||
dis.dispatch({
|
||||
action: "view_user",
|
||||
member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null,
|
||||
});
|
||||
};
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
roomId={this.props.roomId}
|
||||
key={this.props.roomId || this.state.member.userId}
|
||||
onClose={onClose}
|
||||
phase={this.state.phase}
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
/>;
|
||||
} else {
|
||||
panel = <MemberInfo
|
||||
member={this.state.member}
|
||||
key={this.props.roomId || this.state.member.userId}
|
||||
/>;
|
||||
}
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
roomId={this.props.roomId}
|
||||
key={this.props.roomId || this.state.member.userId}
|
||||
onClose={this.onCloseUserInfo}
|
||||
phase={this.state.phase}
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
verificationRequestPromise={this.state.verificationRequestPromise}
|
||||
/>;
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.Room3pidMemberInfo:
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.GroupMemberInfo:
|
||||
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
|
||||
const onClose = () => {
|
||||
dis.dispatch({
|
||||
action: "view_user",
|
||||
member: null,
|
||||
});
|
||||
};
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.userId}
|
||||
onClose={onClose} />;
|
||||
} else {
|
||||
panel = (
|
||||
<GroupMemberInfo
|
||||
groupMember={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.user_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.userId}
|
||||
onClose={this.onCloseUserInfo} />;
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.GroupRoomInfo:
|
||||
panel = <GroupRoomInfo
|
||||
|
|
|
@ -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,14 +20,16 @@ import React from 'react';
|
|||
import createReactClass from 'create-react-class';
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import Modal from "../../Modal";
|
||||
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 160;
|
||||
|
@ -40,30 +42,23 @@ export default createReactClass({
|
|||
displayName: 'RoomDirectory',
|
||||
|
||||
propTypes: {
|
||||
config: PropTypes.object,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
config: {},
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
protocolsLoading: true,
|
||||
error: null,
|
||||
instanceId: null,
|
||||
includeAll: false,
|
||||
roomServer: null,
|
||||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
filterString: null,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this.nextBatch = null;
|
||||
this.filterTimeout = null;
|
||||
|
@ -80,7 +75,7 @@ export default createReactClass({
|
|||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
}, (err) => {
|
||||
console.warn(`error loading thirdparty protocols: ${err}`);
|
||||
console.warn(`error loading third party protocols: ${err}`);
|
||||
this.setState({protocolsLoading: false});
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// Guests currently aren't allowed to use this API, so
|
||||
|
@ -89,13 +84,17 @@ export default createReactClass({
|
|||
return;
|
||||
}
|
||||
track('Failed to get protocol list from homeserver');
|
||||
const brand = SdkConfig.get().brand;
|
||||
this.setState({
|
||||
error: _t(
|
||||
'Riot failed to get the protocol list from the homeserver. ' +
|
||||
'%(brand)s failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
{ brand },
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
this.refreshRoomList();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -130,10 +129,10 @@ export default createReactClass({
|
|||
if (my_server != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = my_server;
|
||||
}
|
||||
if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId;
|
||||
} else if (this.state.includeAll) {
|
||||
if (this.state.instanceId === ALL_ROOMS) {
|
||||
opts.include_all_networks = true;
|
||||
} else if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId;
|
||||
}
|
||||
if (this.nextBatch) opts.since = this.nextBatch;
|
||||
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
|
||||
|
@ -177,12 +176,13 @@ export default createReactClass({
|
|||
|
||||
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
|
||||
track('Failed to get public room list');
|
||||
const brand = SdkConfig.get().brand;
|
||||
this.setState({
|
||||
loading: false,
|
||||
error:
|
||||
`${_t('Riot failed to get the public room list.')} ` +
|
||||
`${(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')}`
|
||||
,
|
||||
error: (
|
||||
_t('%(brand)s failed to get the public room list.', { brand }) +
|
||||
(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
@ -203,7 +203,7 @@ export default createReactClass({
|
|||
|
||||
let desc;
|
||||
if (alias) {
|
||||
desc = _t('Delete the room alias %(alias)s and remove %(name)s from the directory?', {alias: alias, name: name});
|
||||
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name});
|
||||
} else {
|
||||
desc = _t('Remove %(name)s from the directory?', {name: name});
|
||||
}
|
||||
|
@ -220,7 +220,7 @@ export default createReactClass({
|
|||
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
|
||||
if (!alias) return;
|
||||
step = _t('delete the alias.');
|
||||
step = _t('delete the address.');
|
||||
return MatrixClientPeg.get().deleteAlias(alias);
|
||||
}).then(() => {
|
||||
modal.close();
|
||||
|
@ -247,7 +247,7 @@ export default createReactClass({
|
|||
}
|
||||
},
|
||||
|
||||
onOptionChange: function(server, instanceId, includeAll) {
|
||||
onOptionChange: function(server, instanceId) {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
|
@ -257,7 +257,6 @@ export default createReactClass({
|
|||
publicRooms: [],
|
||||
roomServer: server,
|
||||
instanceId: instanceId,
|
||||
includeAll: includeAll,
|
||||
error: null,
|
||||
}, this.refreshRoomList);
|
||||
// We also refresh the room list each time even though this
|
||||
|
@ -305,7 +304,7 @@ export default createReactClass({
|
|||
|
||||
onJoinFromSearchClick: function(alias) {
|
||||
// If we don't have a particular instance id selected, just show that rooms alias
|
||||
if (!this.state.instanceId) {
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
// If the user specified an alias without a domain, add on whichever server is selected
|
||||
// in the dropdown
|
||||
if (alias.indexOf(':') == -1) {
|
||||
|
@ -319,9 +318,10 @@ export default createReactClass({
|
|||
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
|
||||
if (!fields) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const brand = SdkConfig.get().brand;
|
||||
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
|
||||
title: _t('Unable to join network'),
|
||||
description: _t('Riot does not know how to join a room on this network'),
|
||||
description: _t('%(brand)s does not know how to join a room on this network', { brand }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -372,7 +372,10 @@ export default createReactClass({
|
|||
|
||||
onCreateRoomClick: function(room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({action: 'view_create_room'});
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
});
|
||||
},
|
||||
|
||||
showRoomAlias: function(alias, autoJoin=false) {
|
||||
|
@ -406,6 +409,12 @@ export default createReactClass({
|
|||
// would normally decide what the name is.
|
||||
name: room.name || room_alias || _t('Unnamed room'),
|
||||
};
|
||||
|
||||
if (this.state.roomServer) {
|
||||
payload.opts = {
|
||||
viaServers: [this.state.roomServer],
|
||||
};
|
||||
}
|
||||
}
|
||||
// It's not really possible to join Matrix rooms by ID because the HS has no way to know
|
||||
// which servers to start querying. However, there's no other way to join rooms in
|
||||
|
@ -587,7 +596,7 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
let placeholder = _t('Find a room…');
|
||||
if (!this.state.instanceId) {
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
|
||||
} else if (instance_expected_field_type) {
|
||||
placeholder = instance_expected_field_type.placeholder;
|
||||
|
@ -604,10 +613,18 @@ export default createReactClass({
|
|||
listHeader = <div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinFromSearchClick}
|
||||
placeholder={placeholder} showJoinButton={showJoinButton}
|
||||
onChange={this.onFilterChange}
|
||||
onClear={this.onFilterClear}
|
||||
onJoinClick={this.onJoinFromSearchClick}
|
||||
placeholder={placeholder}
|
||||
showJoinButton={showJoinButton}
|
||||
/>
|
||||
<NetworkDropdown
|
||||
protocols={this.protocols}
|
||||
onOptionChange={this.onOptionChange}
|
||||
selectedServerName={this.state.roomServer}
|
||||
selectedInstanceId={this.state.instanceId}
|
||||
/>
|
||||
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
|
@ -628,7 +645,7 @@ export default createReactClass({
|
|||
title={_t("Explore rooms")}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
<p>{explanation}</p>
|
||||
{explanation}
|
||||
<div className="mx_RoomDirectory_list">
|
||||
{listHeader}
|
||||
{content}
|
||||
|
|
172
src/components/structures/RoomSearch.tsx
Normal file
172
src/components/structures/RoomSearch.tsx
Normal file
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { throttle } from 'lodash';
|
||||
import { Key } from "../../Keyboard";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
interface IProps {
|
||||
onQueryUpdate: (newQuery: string) => void;
|
||||
isMinimized: boolean;
|
||||
onVerticalArrow(ev: React.KeyboardEvent);
|
||||
onEnter(ev: React.KeyboardEvent);
|
||||
}
|
||||
|
||||
interface IState {
|
||||
query: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private inputRef: React.RefObject<HTMLInputElement> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
query: "",
|
||||
focused: false,
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === 'view_room' && payload.clear_search) {
|
||||
this.clearInput();
|
||||
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private clearInput = () => {
|
||||
if (!this.inputRef.current) return;
|
||||
this.inputRef.current.value = "";
|
||||
this.onChange();
|
||||
};
|
||||
|
||||
private openSearch = () => {
|
||||
defaultDispatcher.dispatch({action: "show_left_panel"});
|
||||
defaultDispatcher.dispatch({action: "focus_room_filter"});
|
||||
};
|
||||
|
||||
private onChange = () => {
|
||||
if (!this.inputRef.current) return;
|
||||
this.setState({query: this.inputRef.current.value});
|
||||
this.onSearchUpdated();
|
||||
};
|
||||
|
||||
// it wants this at the top of the file, but we know better
|
||||
// tslint:disable-next-line
|
||||
private onSearchUpdated = throttle(
|
||||
() => {
|
||||
// We can't use the state variable because it can lag behind the input.
|
||||
// The lag is most obvious when deleting/clearing text with the keyboard.
|
||||
this.props.onQueryUpdate(this.inputRef.current.value);
|
||||
}, 200, {trailing: true, leading: true},
|
||||
);
|
||||
|
||||
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
this.setState({focused: true});
|
||||
ev.target.select();
|
||||
};
|
||||
|
||||
private onBlur = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
this.setState({focused: false});
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.clearInput();
|
||||
defaultDispatcher.fire(Action.FocusComposer);
|
||||
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
|
||||
this.props.onVerticalArrow(ev);
|
||||
} else if (ev.key === Key.ENTER) {
|
||||
this.props.onEnter(ev);
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const classes = classNames({
|
||||
'mx_RoomSearch': true,
|
||||
'mx_RoomSearch_expanded': this.state.query || this.state.focused,
|
||||
'mx_RoomSearch_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const inputClasses = classNames({
|
||||
'mx_RoomSearch_input': true,
|
||||
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
|
||||
});
|
||||
|
||||
let icon = (
|
||||
<div className='mx_RoomSearch_icon'/>
|
||||
);
|
||||
let input = (
|
||||
<input
|
||||
type="text"
|
||||
ref={this.inputRef}
|
||||
className={inputClasses}
|
||||
value={this.state.query}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={_t("Search")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
let clearButton = (
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
title={_t("Clear filter")}
|
||||
className="mx_RoomSearch_clearButton"
|
||||
onClick={this.clearInput}
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.props.isMinimized) {
|
||||
icon = (
|
||||
<AccessibleButton
|
||||
title={_t("Search rooms")}
|
||||
className="mx_RoomSearch_icon"
|
||||
onClick={this.openSearch}
|
||||
/>
|
||||
);
|
||||
input = null;
|
||||
clearButton = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{icon}
|
||||
{input}
|
||||
{clearButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue