Merge branches 'develop' and 't3chguy/fix-op' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix-op
This commit is contained in:
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] };
|
||||
22
src/@types/global.d.ts
vendored
22
src/@types/global.d.ts
vendored
@@ -15,13 +15,31 @@ 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 { 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_RoomListStore2: RoomListStore2;
|
||||
mx_RoomListLayoutStore: RoomListLayoutStore;
|
||||
mxPlatformPeg: PlatformPeg;
|
||||
|
||||
// TODO: Remove flag before launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
mx_QuietRoomListLogging: boolean;
|
||||
}
|
||||
|
||||
// workaround for https://github.com/microsoft/TypeScript/issues/30933
|
||||
@@ -34,6 +52,10 @@ declare global {
|
||||
hasStorageAccess?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
userLanguage?: string;
|
||||
}
|
||||
|
||||
interface StorageEstimate {
|
||||
usageDetails?: {[key: string]: number};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
// @flow
|
||||
|
||||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClient} from "matrix-js-sdk";
|
||||
import dis from './dispatcher';
|
||||
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
|
||||
|
||||
/**
|
||||
* Base class for classes that provide platform-specific functionality
|
||||
* eg. Setting an application badge or displaying notifications
|
||||
*
|
||||
* Instances of this class are provided by the application.
|
||||
*/
|
||||
export default class BasePlatform {
|
||||
constructor() {
|
||||
this.notificationCount = 0;
|
||||
this.errorDidOccur = false;
|
||||
|
||||
dis.register(this._onAction.bind(this));
|
||||
}
|
||||
|
||||
_onAction(payload: Object) {
|
||||
switch (payload.action) {
|
||||
case 'on_client_not_viable':
|
||||
case 'on_logged_out':
|
||||
this.setNotificationCount(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Used primarily for Analytics
|
||||
getHumanReadableName(): string {
|
||||
return 'Base Platform';
|
||||
}
|
||||
|
||||
setNotificationCount(count: number) {
|
||||
this.notificationCount = count;
|
||||
}
|
||||
|
||||
setErrorStatus(errorDidOccur: boolean) {
|
||||
this.errorDidOccur = errorDidOccur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the platform supports displaying
|
||||
* notifications, otherwise false.
|
||||
* @returns {boolean} whether the platform supports displaying notifications
|
||||
*/
|
||||
supportsNotifications(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the application currently has permission
|
||||
* to display notifications. Otherwise false.
|
||||
* @returns {boolean} whether the application has permission to display notifications
|
||||
*/
|
||||
maySendNotifications(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests permission to send notifications. Returns
|
||||
* a promise that is resolved when the user has responded
|
||||
* to the request. The promise has a single string argument
|
||||
* that is 'granted' if the user allowed the request or
|
||||
* 'denied' otherwise.
|
||||
*/
|
||||
requestNotificationPermission(): Promise<string> {
|
||||
}
|
||||
|
||||
displayNotification(title: string, msg: string, avatarUrl: string, room: Object) {
|
||||
}
|
||||
|
||||
loudNotification(ev: Event, room: Object) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to a string representing
|
||||
* the current version of the application.
|
||||
*/
|
||||
getAppVersion(): Promise<string> {
|
||||
throw new Error("getAppVersion not implemented!");
|
||||
}
|
||||
|
||||
/*
|
||||
* If it's not expected that capturing the screen will work
|
||||
* with getUserMedia, return a string explaining why not.
|
||||
* Otherwise, return null.
|
||||
*/
|
||||
screenCaptureErrorString(): string {
|
||||
return "Not implemented";
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the application, without neccessarily reloading
|
||||
* any application code
|
||||
*/
|
||||
reload() {
|
||||
throw new Error("reload not implemented!");
|
||||
}
|
||||
|
||||
supportsAutoLaunch(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// XXX: Surely this should be a setting like any other?
|
||||
async getAutoLaunchEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setAutoLaunchEnabled(enabled: boolean): void {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
supportsAutoHideMenuBar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getAutoHideMenuBarEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setAutoHideMenuBarEnabled(enabled: boolean): void {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
supportsMinimizeToTray(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getMinimizeToTrayEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setMinimizeToTrayEnabled(enabled: boolean): void {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get our platform specific EventIndexManager.
|
||||
*
|
||||
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
|
||||
* can be null if the platform doesn't support event indexing.
|
||||
*/
|
||||
getEventIndexingManager(): BaseEventIndexManager | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
setLanguage(preferredLangs: string[]) {}
|
||||
|
||||
getSSOCallbackUrl(hsUrl: string, isUrl: string, fragmentAfterLogin: string): URL {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = fragmentAfterLogin || "";
|
||||
url.searchParams.set("homeserver", hsUrl);
|
||||
url.searchParams.set("identityServer", isUrl);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin Single Sign On flows.
|
||||
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
|
||||
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
|
||||
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
|
||||
*/
|
||||
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) {
|
||||
const callbackUrl = this.getSSOCallbackUrl(mxClient.getHomeserverUrl(), mxClient.getIdentityServerUrl(),
|
||||
fragmentAfterLogin);
|
||||
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
|
||||
}
|
||||
|
||||
onKeyDown(ev: KeyboardEvent): boolean {
|
||||
return false; // no shortcuts implemented
|
||||
}
|
||||
}
|
||||
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,8 +59,7 @@ import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import { _t } from './languageHandler';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import dis from './dispatcher';
|
||||
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
import SettingsStore, { SettingLevel } from './settings/SettingsStore';
|
||||
@@ -119,62 +118,22 @@ function pause(audioId) {
|
||||
}
|
||||
}
|
||||
|
||||
function _reAttemptCall(call) {
|
||||
if (call.direction === 'outbound') {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
room_id: call.roomId,
|
||||
type: call.type,
|
||||
});
|
||||
} else {
|
||||
call.answer();
|
||||
}
|
||||
}
|
||||
|
||||
function _setCallListeners(call) {
|
||||
call.on("error", function(err) {
|
||||
console.error("Call error:", err);
|
||||
if (err.code === 'unknown_devices') {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
||||
Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
|
||||
title: _t('Call Failed'),
|
||||
description: _t(
|
||||
"There are unknown sessions in this room: "+
|
||||
"if you proceed without verifying them, it will be "+
|
||||
"possible for someone to eavesdrop on your call.",
|
||||
),
|
||||
button: _t('Review Sessions'),
|
||||
onFinished: function(confirmed) {
|
||||
if (confirmed) {
|
||||
const room = MatrixClientPeg.get().getRoom(call.roomId);
|
||||
showUnknownDeviceDialogForCalls(
|
||||
MatrixClientPeg.get(),
|
||||
room,
|
||||
() => {
|
||||
_reAttemptCall(call);
|
||||
},
|
||||
call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"),
|
||||
call.direction === 'outbound' ? _t("Call") : _t("Answer"),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (
|
||||
MatrixClientPeg.get().getTurnServers().length === 0 &&
|
||||
SettingsStore.getValue("fallbackICEServerAllowed") === null
|
||||
) {
|
||||
_showICEFallbackPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
title: _t('Call Failed'),
|
||||
description: err.message,
|
||||
});
|
||||
if (
|
||||
MatrixClientPeg.get().getTurnServers().length === 0 &&
|
||||
SettingsStore.getValue("fallbackICEServerAllowed") === null
|
||||
) {
|
||||
_showICEFallbackPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
title: _t('Call Failed'),
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
call.on("hangup", function() {
|
||||
_setCallState(undefined, call.roomId, "ended");
|
||||
|
||||
@@ -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,6 @@ import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
|
||||
import { _t } from './languageHandler';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
|
||||
|
||||
// This stores the secret storage private keys in memory for the JS SDK. This is
|
||||
@@ -32,10 +31,7 @@ let secretStorageKeys = {};
|
||||
let secretStorageBeingAccessed = false;
|
||||
|
||||
function isCachingAllowed() {
|
||||
return (
|
||||
secretStorageBeingAccessed ||
|
||||
SettingsStore.getValue("keepSecretStoragePassphraseForSession")
|
||||
);
|
||||
return secretStorageBeingAccessed;
|
||||
}
|
||||
|
||||
export class AccessCancelledError extends Error {
|
||||
|
||||
@@ -14,43 +14,42 @@ 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';
|
||||
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;
|
||||
const THIS_DEVICE_TOAST_KEY = 'setupencryption';
|
||||
const OTHER_DEVICES_TOAST_KEY = 'reviewsessions';
|
||||
|
||||
function toastKey(deviceId) {
|
||||
return "unverified_session_" + deviceId;
|
||||
}
|
||||
|
||||
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 (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener();
|
||||
return global.mx_DeviceListener;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// 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;
|
||||
|
||||
// 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.
|
||||
this._ourDeviceIdsAtStart = null;
|
||||
|
||||
// The set of device IDs we're currently displaying toasts for
|
||||
this._displayingToastsForDeviceIds = new Set();
|
||||
if (!window.mx_DeviceListener) window.mx_DeviceListener = new DeviceListener();
|
||||
return window.mx_DeviceListener;
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -74,12 +73,12 @@ export default class DeviceListener {
|
||||
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();
|
||||
this.dismissed.clear();
|
||||
this.dismissedThisDeviceToast = false;
|
||||
this.keyBackupInfo = null;
|
||||
this.keyBackupFetchedAt = null;
|
||||
this.ourDeviceIdsAtStart = null;
|
||||
this.displayingToastsForDeviceIds = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,29 +86,29 @@ export default class DeviceListener {
|
||||
*
|
||||
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
|
||||
*/
|
||||
async dismissUnverifiedSessions(deviceIds) {
|
||||
async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
|
||||
for (const d of deviceIds) {
|
||||
this._dismissed.add(d);
|
||||
this.dismissed.add(d);
|
||||
}
|
||||
|
||||
this._recheck();
|
||||
}
|
||||
|
||||
dismissEncryptionSetup() {
|
||||
this._dismissedThisDeviceToast = true;
|
||||
this.dismissedThisDeviceToast = true;
|
||||
this._recheck();
|
||||
}
|
||||
|
||||
_ensureDeviceIdsAtStartPopulated() {
|
||||
if (this._ourDeviceIdsAtStart === null) {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
this._ourDeviceIdsAtStart = new Set(
|
||||
this.ourDeviceIdsAtStart = new Set(
|
||||
cli.getStoredDevicesForUser(cli.getUserId()).map(d => d.deviceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_onWillUpdateDevices = async (users, initialFetch) => {
|
||||
_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.
|
||||
@@ -120,26 +119,26 @@ export default class DeviceListener {
|
||||
|
||||
// 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) => {
|
||||
_onDevicesUpdated = (users: string[]) => {
|
||||
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
|
||||
this._recheck();
|
||||
}
|
||||
};
|
||||
|
||||
_onDeviceVerificationChanged = (userId) => {
|
||||
_onDeviceVerificationChanged = (userId: string) => {
|
||||
if (userId !== MatrixClientPeg.get().getUserId()) return;
|
||||
this._recheck();
|
||||
}
|
||||
};
|
||||
|
||||
_onUserTrustStatusChanged = (userId, trustLevel) => {
|
||||
_onUserTrustStatusChanged = (userId: string) => {
|
||||
if (userId !== MatrixClientPeg.get().getUserId()) return;
|
||||
this._recheck();
|
||||
}
|
||||
};
|
||||
|
||||
_onCrossSingingKeysChanged = () => {
|
||||
this._recheck();
|
||||
}
|
||||
};
|
||||
|
||||
_onAccountData = (ev) => {
|
||||
// User may have:
|
||||
@@ -153,30 +152,35 @@ export default class DeviceListener {
|
||||
) {
|
||||
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;
|
||||
if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
|
||||
this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||
this.keyBackupFetchedAt = now;
|
||||
}
|
||||
return this._keyBackupInfo;
|
||||
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 (
|
||||
!SettingsStore.getValue("feature_cross_signing") ||
|
||||
!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
|
||||
) return;
|
||||
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
|
||||
@@ -186,48 +190,25 @@ export default class DeviceListener {
|
||||
|
||||
const crossSigningReady = await cli.isCrossSigningReady();
|
||||
|
||||
if (this._dismissedThisDeviceToast) {
|
||||
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
|
||||
} else {
|
||||
if (!crossSigningReady) {
|
||||
// make sure our keys are finished downlaoding
|
||||
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)
|
||||
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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// cross-signing is ready, and we don't need to upgrade encryption
|
||||
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,20 +220,20 @@ export default class DeviceListener {
|
||||
// (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();
|
||||
const oldUnverifiedDeviceIds = new Set<string>();
|
||||
// Unverified devices that have appeared since then
|
||||
const newUnverifiedDeviceIds = new Set();
|
||||
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;
|
||||
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)) {
|
||||
if (!deviceTrust.isCrossSigningVerified() && !this.dismissed.has(device.deviceId)) {
|
||||
if (this.ourDeviceIdsAtStart.has(device.deviceId)) {
|
||||
oldUnverifiedDeviceIds.add(device.deviceId);
|
||||
} else {
|
||||
newUnverifiedDeviceIds.add(device.deviceId);
|
||||
@@ -263,38 +244,23 @@ export default class DeviceListener {
|
||||
|
||||
// Display or hide the batch toast for old unverified sessions
|
||||
if (oldUnverifiedDeviceIds.size > 0) {
|
||||
ToastStore.sharedInstance().addOrReplaceToast({
|
||||
key: OTHER_DEVICES_TOAST_KEY,
|
||||
title: _t("Review where you’re logged in"),
|
||||
icon: "verification_warning",
|
||||
priority: ToastStore.PRIORITY_LOW,
|
||||
props: {
|
||||
deviceIds: oldUnverifiedDeviceIds,
|
||||
},
|
||||
component: sdk.getComponent("toasts.BulkUnverifiedSessionsToast"),
|
||||
});
|
||||
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
|
||||
} else {
|
||||
ToastStore.sharedInstance().dismissToast(OTHER_DEVICES_TOAST_KEY);
|
||||
hideBulkUnverifiedSessionsToast();
|
||||
}
|
||||
|
||||
// Show toasts for new unverified devices if they aren't already there
|
||||
for (const deviceId of newUnverifiedDeviceIds) {
|
||||
ToastStore.sharedInstance().addOrReplaceToast({
|
||||
key: toastKey(deviceId),
|
||||
title: _t("New login. Was this you?"),
|
||||
icon: "verification_warning",
|
||||
props: { deviceId },
|
||||
component: sdk.getComponent("toasts.UnverifiedSessionToast"),
|
||||
});
|
||||
showUnverifiedSessionsToast(deviceId);
|
||||
}
|
||||
|
||||
// ...and hide any we don't need any more
|
||||
for (const deviceId of this._displayingToastsForDeviceIds) {
|
||||
for (const deviceId of this.displayingToastsForDeviceIds) {
|
||||
if (!newUnverifiedDeviceIds.has(deviceId)) {
|
||||
ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
|
||||
hideUnverifiedSessionsToast(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
this._displayingToastsForDeviceIds = newUnverifiedDeviceIds;
|
||||
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ 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";
|
||||
@@ -25,6 +25,7 @@ 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 = [
|
||||
@@ -247,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);
|
||||
}
|
||||
@@ -260,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,10 +17,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
|
||||
import React from 'react';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import * as linkify from 'linkifyjs';
|
||||
@@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
|
||||
import _linkifyElement from 'linkifyjs/element';
|
||||
import _linkifyString from 'linkifyjs/string';
|
||||
import classNames from 'classnames';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import url from 'url';
|
||||
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
||||
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
|
||||
linkifyMatrix(linkify);
|
||||
|
||||
@@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||
* need emojification.
|
||||
* unicodeToImage uses this function.
|
||||
*/
|
||||
function mightContainEmoji(str) {
|
||||
function mightContainEmoji(str: string) {
|
||||
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
|
||||
}
|
||||
|
||||
@@ -74,7 +71,7 @@ function mightContainEmoji(str) {
|
||||
* @param {String} char The emoji character
|
||||
* @return {String} The shortcode (such as :thumbup:)
|
||||
*/
|
||||
export function unicodeToShortcode(char) {
|
||||
export function unicodeToShortcode(char: string) {
|
||||
const data = getEmojiFromUnicode(char);
|
||||
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
|
||||
}
|
||||
@@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
|
||||
* @param {String} shortcode The shortcode (such as :thumbup:)
|
||||
* @return {String} The emoji character; null if none exists
|
||||
*/
|
||||
export function shortcodeToUnicode(shortcode) {
|
||||
export function shortcodeToUnicode(shortcode: string) {
|
||||
shortcode = shortcode.slice(1, shortcode.length - 1);
|
||||
const data = SHORTCODE_TO_EMOJI.get(shortcode);
|
||||
return data ? data.unicode : null;
|
||||
@@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string {
|
||||
}
|
||||
|
||||
let contentHTML = "";
|
||||
for (let i=0; i < contentDiv.children.length; i++) {
|
||||
for (let i = 0; i < contentDiv.children.length; i++) {
|
||||
const element = contentDiv.children[i];
|
||||
if (element.tagName.toLowerCase() === 'p') {
|
||||
contentHTML += element.innerHTML;
|
||||
@@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string {
|
||||
* Given an untrusted HTML string, return a React node with an sanitized version
|
||||
* of that HTML.
|
||||
*/
|
||||
export function sanitizedHtmlNode(insaneHtml) {
|
||||
export function sanitizedHtmlNode(insaneHtml: string) {
|
||||
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
||||
}
|
||||
|
||||
export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
|
||||
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.innerHTML = saneHtml;
|
||||
return contentDiv.innerText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a URL from an untrusted source may be safely put into the DOM
|
||||
* The biggest threat here is javascript: URIs.
|
||||
@@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
|
||||
* other places we need to sanitise URLs.
|
||||
* @return true if permitted, otherwise false
|
||||
*/
|
||||
export function isUrlPermitted(inputUrl) {
|
||||
export function isUrlPermitted(inputUrl: string) {
|
||||
try {
|
||||
const parsed = url.parse(inputUrl);
|
||||
if (!parsed.protocol) return false;
|
||||
@@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
const transformTags = { // custom to matrix
|
||||
const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
|
||||
// add blank targets to all hyperlinks except vector URLs
|
||||
'a': function(tagName, attribs) {
|
||||
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
if (attribs.href) {
|
||||
attribs.target = '_blank'; // by default
|
||||
|
||||
@@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
|
||||
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
|
||||
return { tagName, attribs };
|
||||
},
|
||||
'img': function(tagName, attribs) {
|
||||
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
||||
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
||||
// we don't want to allow images with `https?` `src`s.
|
||||
@@ -176,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) {
|
||||
@@ -186,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;
|
||||
@@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
|
||||
},
|
||||
};
|
||||
|
||||
const sanitizeHtmlParams = {
|
||||
const sanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
'font', // custom to matrix for IRC-style font coloring
|
||||
'del', // for markdown
|
||||
@@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
|
||||
};
|
||||
|
||||
// this is the same as the above except with less rewriting
|
||||
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
|
||||
composerSanitizeHtmlParams.transformTags = {
|
||||
'code': transformTags['code'],
|
||||
'*': transformTags['*'],
|
||||
const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||
...sanitizeHtmlParams,
|
||||
transformTags: {
|
||||
'code': transformTags['code'],
|
||||
'*': transformTags['*'],
|
||||
},
|
||||
};
|
||||
|
||||
class BaseHighlighter {
|
||||
constructor(highlightClass, highlightLink) {
|
||||
this.highlightClass = highlightClass;
|
||||
this.highlightLink = highlightLink;
|
||||
abstract class BaseHighlighter<T extends React.ReactNode> {
|
||||
constructor(public highlightClass: string, public highlightLink: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,47 +274,49 @@ class BaseHighlighter {
|
||||
* returns a list of results (strings for HtmlHighligher, react nodes for
|
||||
* TextHighlighter).
|
||||
*/
|
||||
applyHighlights(safeSnippet, safeHighlights) {
|
||||
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
||||
let lastOffset = 0;
|
||||
let offset;
|
||||
let nodes = [];
|
||||
let nodes: T[] = [];
|
||||
|
||||
const safeHighlight = safeHighlights[0];
|
||||
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
|
||||
// handle preamble
|
||||
if (offset > lastOffset) {
|
||||
var subSnippet = safeSnippet.substring(lastOffset, offset);
|
||||
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
||||
const subSnippet = safeSnippet.substring(lastOffset, offset);
|
||||
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
||||
}
|
||||
|
||||
// do highlight. use the original string rather than safeHighlight
|
||||
// to preserve the original casing.
|
||||
const endOffset = offset + safeHighlight.length;
|
||||
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
|
||||
nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
|
||||
|
||||
lastOffset = endOffset;
|
||||
}
|
||||
|
||||
// handle postamble
|
||||
if (lastOffset !== safeSnippet.length) {
|
||||
subSnippet = safeSnippet.substring(lastOffset, undefined);
|
||||
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
||||
const subSnippet = safeSnippet.substring(lastOffset, undefined);
|
||||
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
_applySubHighlights(safeSnippet, safeHighlights) {
|
||||
private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
||||
if (safeHighlights[1]) {
|
||||
// recurse into this range to check for the next set of highlight matches
|
||||
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
|
||||
} else {
|
||||
// no more highlights to be found, just return the unhighlighted string
|
||||
return [this._processSnippet(safeSnippet, false)];
|
||||
return [this.processSnippet(safeSnippet, false)];
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract processSnippet(snippet: string, highlight: boolean): T;
|
||||
}
|
||||
|
||||
class HtmlHighlighter extends BaseHighlighter {
|
||||
class HtmlHighlighter extends BaseHighlighter<string> {
|
||||
/* highlight the given snippet if required
|
||||
*
|
||||
* snippet: content of the span; must have been sanitised
|
||||
@@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
|
||||
*
|
||||
* returns an HTML string
|
||||
*/
|
||||
_processSnippet(snippet, highlight) {
|
||||
protected processSnippet(snippet: string, highlight: boolean): string {
|
||||
if (!highlight) {
|
||||
// nothing required here
|
||||
return snippet;
|
||||
}
|
||||
|
||||
let span = "<span class=\""+this.highlightClass+"\">"
|
||||
+ snippet + "</span>";
|
||||
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
|
||||
|
||||
if (this.highlightLink) {
|
||||
span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
|
||||
+span+"</a>";
|
||||
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
class TextHighlighter extends BaseHighlighter {
|
||||
constructor(highlightClass, highlightLink) {
|
||||
super(highlightClass, highlightLink);
|
||||
this._key = 0;
|
||||
}
|
||||
class TextHighlighter extends BaseHighlighter<React.ReactNode> {
|
||||
private key = 0;
|
||||
|
||||
/* create a <span> node to hold the given content
|
||||
*
|
||||
@@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter {
|
||||
*
|
||||
* returns a React node
|
||||
*/
|
||||
_processSnippet(snippet, highlight) {
|
||||
const key = this._key++;
|
||||
protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
|
||||
const key = this.key++;
|
||||
|
||||
let node =
|
||||
<span key={key} className={highlight ? this.highlightClass : null}>
|
||||
{ snippet }
|
||||
</span>;
|
||||
let node = <span key={key} className={highlight ? this.highlightClass : null}>
|
||||
{ snippet }
|
||||
</span>;
|
||||
|
||||
if (highlight && this.highlightLink) {
|
||||
node = <a key={key} href={this.highlightLink}>{ node }</a>;
|
||||
@@ -364,6 +364,20 @@ class TextHighlighter extends BaseHighlighter {
|
||||
}
|
||||
}
|
||||
|
||||
interface IContent {
|
||||
format?: string;
|
||||
formatted_body?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface IOpts {
|
||||
highlightLink?: string;
|
||||
disableBigEmoji?: boolean;
|
||||
stripReplyFallback?: boolean;
|
||||
returnString?: boolean;
|
||||
forComposerQuote?: boolean;
|
||||
ref?: React.Ref<any>;
|
||||
}
|
||||
|
||||
/* turn a matrix event body into html
|
||||
*
|
||||
@@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
|
||||
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
|
||||
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
|
||||
*/
|
||||
export function bodyToHtml(content, highlights, opts={}) {
|
||||
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
|
||||
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
||||
let bodyHasEmoji = false;
|
||||
|
||||
@@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
|
||||
sanitizeParams = composerSanitizeHtmlParams;
|
||||
}
|
||||
|
||||
let strippedBody;
|
||||
let safeBody;
|
||||
let isDisplayedWithHtml;
|
||||
let strippedBody: string;
|
||||
let safeBody: string;
|
||||
let isDisplayedWithHtml: boolean;
|
||||
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
|
||||
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
|
||||
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
|
||||
@@ -471,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
|
||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string} Linkified string
|
||||
*/
|
||||
export function linkifyString(str, options = linkifyMatrix.options) {
|
||||
export function linkifyString(str: string, options = linkifyMatrix.options) {
|
||||
return _linkifyString(str, options);
|
||||
}
|
||||
|
||||
@@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
|
||||
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function linkifyElement(element, options = linkifyMatrix.options) {
|
||||
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
|
||||
return _linkifyElement(element, options);
|
||||
}
|
||||
|
||||
@@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
|
||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string}
|
||||
*/
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
||||
}
|
||||
|
||||
@@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
|
||||
* @param {Node} node
|
||||
* @returns {bool}
|
||||
*/
|
||||
export function checkBlockNode(node) {
|
||||
export function checkBlockNode(node: Node) {
|
||||
switch (node.nodeName) {
|
||||
case "H1":
|
||||
case "H2":
|
||||
@@ -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.getValue("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.getValue("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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -41,6 +41,10 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import {Mjolnir} from "./mjolnir/Mjolnir";
|
||||
import DeviceListener from "./DeviceListener";
|
||||
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
|
||||
@@ -163,14 +167,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,
|
||||
@@ -256,8 +262,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");
|
||||
@@ -298,6 +304,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,
|
||||
@@ -306,6 +314,7 @@ async function _restoreFromLocalStorage(opts) {
|
||||
homeserverUrl: hsUrl,
|
||||
identityServerUrl: isUrl,
|
||||
guest: isGuest,
|
||||
pickleKey: pickleKey,
|
||||
}, false);
|
||||
return true;
|
||||
} else {
|
||||
@@ -348,9 +357,13 @@ async function _handleLoadSessionFailure(e) {
|
||||
*
|
||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||
*/
|
||||
export function setLoggedIn(credentials) {
|
||||
export async function setLoggedIn(credentials) {
|
||||
stopMatrixClient();
|
||||
return _doSetLoggedIn(credentials, true);
|
||||
const pickleKey = credentials.userId && credentials.deviceId
|
||||
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
|
||||
: null;
|
||||
|
||||
return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,9 +492,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 +529,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,10 +590,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();
|
||||
DMRoomMap.makeShared().start();
|
||||
IntegrationManagers.sharedInstance().startWatching();
|
||||
ActiveWidgetStore.start();
|
||||
@@ -608,7 +625,7 @@ async function startMatrixClient(startSyncing=true) {
|
||||
}
|
||||
|
||||
// Now that we have a MatrixClientPeg, update the Jitsi info
|
||||
await Jitsi.getInstance().update();
|
||||
await Jitsi.getInstance().start();
|
||||
|
||||
// dispatch that we finished starting up to wire up any other bits
|
||||
// of the matrix client that cannot be set prior to starting up.
|
||||
|
||||
@@ -95,6 +95,8 @@ export default class Login {
|
||||
identifier = {
|
||||
type: 'm.id.phone',
|
||||
country: phoneCountry,
|
||||
phone: phoneNumber,
|
||||
// XXX: Synapse historically wanted `number` and not `phone`
|
||||
number: phoneNumber,
|
||||
};
|
||||
} else if (isEmail) {
|
||||
|
||||
@@ -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);
|
||||
@@ -158,9 +199,7 @@ class _MatrixClientPeg {
|
||||
// The js-sdk found a crypto DB too new for it to use
|
||||
const CryptoStoreTooNewDialog =
|
||||
sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog");
|
||||
Modal.createDialog(CryptoStoreTooNewDialog, {
|
||||
host: window.location.host,
|
||||
});
|
||||
Modal.createDialog(CryptoStoreTooNewDialog);
|
||||
}
|
||||
// this can happen for a number of reasons, the most likely being
|
||||
// that the olm library was missing. It's not fatal.
|
||||
@@ -171,6 +210,7 @@ class _MatrixClientPeg {
|
||||
// the react sdk doesn't work without this, so don't allow
|
||||
opts.pendingEventOrdering = "detached";
|
||||
opts.lazyLoadMembers = true;
|
||||
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
|
||||
|
||||
// Connect the matrix client to the dispatcher and setting handlers
|
||||
MatrixActionCreators.start(this.matrixClient);
|
||||
@@ -179,7 +219,7 @@ class _MatrixClientPeg {
|
||||
return opts;
|
||||
}
|
||||
|
||||
async start() {
|
||||
public async start(): Promise<any> {
|
||||
const opts = await this.assign();
|
||||
|
||||
console.log(`MatrixClientPeg: really starting MatrixClient`);
|
||||
@@ -187,7 +227,7 @@ class _MatrixClientPeg {
|
||||
console.log(`MatrixClientPeg: MatrixClient started`);
|
||||
}
|
||||
|
||||
getCredentials(): MatrixClientCreds {
|
||||
public getCredentials(): IMatrixClientCreds {
|
||||
return {
|
||||
homeserverUrl: this.matrixClient.baseUrl,
|
||||
identityServerUrl: this.matrixClient.idBaseUrl,
|
||||
@@ -198,12 +238,7 @@ class _MatrixClientPeg {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Return the server name of the user's homeserver
|
||||
* Throws an error if unable to deduce the homeserver name
|
||||
* (eg. if the user is not logged in)
|
||||
*/
|
||||
getHomeserverName() {
|
||||
public getHomeserverName(): string {
|
||||
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
|
||||
if (matches === null || matches.length < 1) {
|
||||
throw new Error("Failed to derive homeserver name from user ID!");
|
||||
@@ -211,13 +246,15 @@ class _MatrixClientPeg {
|
||||
return matches[1];
|
||||
}
|
||||
|
||||
_createClient(creds: MatrixClientCreds) {
|
||||
private createClient(creds: IMatrixClientCreds): void {
|
||||
// TODO: Make these opts typesafe with the js-sdk
|
||||
const opts = {
|
||||
baseUrl: creds.homeserverUrl,
|
||||
idBaseUrl: creds.identityServerUrl,
|
||||
accessToken: creds.accessToken,
|
||||
userId: creds.userId,
|
||||
deviceId: creds.deviceId,
|
||||
pickleKey: creds.pickleKey,
|
||||
timelineSupport: true,
|
||||
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
|
||||
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
|
||||
@@ -228,9 +265,9 @@ class _MatrixClientPeg {
|
||||
],
|
||||
unstableClientRelationAggregation: true,
|
||||
identityServer: new IdentityAuthClient(),
|
||||
cryptoCallbacks: {},
|
||||
};
|
||||
|
||||
opts.cryptoCallbacks = {};
|
||||
// These are always installed regardless of the labs flag so that
|
||||
// cross-signing features can toggle on without reloading and also be
|
||||
// accessed immediately after login.
|
||||
@@ -253,8 +290,8 @@ class _MatrixClientPeg {
|
||||
}
|
||||
}
|
||||
|
||||
if (!global.mxMatrixClientPeg) {
|
||||
global.mxMatrixClientPeg = new _MatrixClientPeg();
|
||||
if (!window.mxMatrixClientPeg) {
|
||||
window.mxMatrixClientPeg = new _MatrixClientPeg();
|
||||
}
|
||||
|
||||
export const MatrixClientPeg = global.mxMatrixClientPeg;
|
||||
export const MatrixClientPeg = window.mxMatrixClientPeg;
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -21,11 +21,14 @@ 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:
|
||||
@@ -119,7 +122,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);
|
||||
@@ -148,7 +151,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 {
|
||||
@@ -278,12 +281,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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -238,22 +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,
|
||||
|
||||
534
src/Searching.js
534
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,6 +155,19 @@ async function localSearch(searchTerm, roomId = undefined) {
|
||||
searchArgs.room_id = roomId;
|
||||
}
|
||||
|
||||
const localResult = await eventIndex.search(searchArgs);
|
||||
|
||||
searchArgs.next_batch = localResult.next_batch;
|
||||
|
||||
const result = {
|
||||
response: localResult,
|
||||
query: searchArgs,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function localSearchProcess(searchTerm, roomId = undefined) {
|
||||
const emptyResult = {
|
||||
results: [],
|
||||
highlights: [],
|
||||
@@ -94,9 +175,34 @@ async function localSearch(searchTerm, roomId = undefined) {
|
||||
|
||||
if (searchTerm === "") return emptyResult;
|
||||
|
||||
const result = await localSearch(searchTerm, roomId);
|
||||
|
||||
emptyResult.seshatQuery = result.query;
|
||||
|
||||
const response = {
|
||||
search_categories: {
|
||||
room_events: result.response,
|
||||
},
|
||||
};
|
||||
|
||||
const processedResult = MatrixClientPeg.get()._processRoomEventsSearch(emptyResult, response);
|
||||
// Restore our encryption info so we can properly re-verify the events.
|
||||
restoreEncryptionInfo(processedResult.results);
|
||||
|
||||
return processedResult;
|
||||
}
|
||||
|
||||
async function localPagination(searchResult) {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
const searchArgs = searchResult.seshatQuery;
|
||||
|
||||
const localResult = await eventIndex.search(searchArgs);
|
||||
searchResult.seshatQuery.next_batch = localResult.next_batch;
|
||||
|
||||
// We only need to restore the encryption state for the new results, so
|
||||
// remember how many of them we got.
|
||||
const newResultCount = localResult.results.length;
|
||||
|
||||
const response = {
|
||||
search_categories: {
|
||||
@@ -104,8 +210,324 @@ async function localSearch(searchTerm, roomId = undefined) {
|
||||
},
|
||||
};
|
||||
|
||||
const result = MatrixClientPeg.get()._processRoomEventsSearch(
|
||||
emptyResult, response);
|
||||
const result = MatrixClientPeg.get()._processRoomEventsSearch(searchResult, response);
|
||||
|
||||
// Restore our encryption info so we can properly re-verify the events.
|
||||
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
|
||||
restoreEncryptionInfo(newSlice);
|
||||
|
||||
searchResult.pendingRequest = null;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function compareOldestEvents(firstResults, secondResults) {
|
||||
try {
|
||||
const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result;
|
||||
const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result;
|
||||
|
||||
if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function combineEventSources(previousSearchResult, response, a, b) {
|
||||
// Merge event sources and sort the events.
|
||||
const combinedEvents = a.concat(b).sort(compareEvents);
|
||||
// Put half of the events in the response, and cache the other half.
|
||||
response.results = combinedEvents.slice(0, SEARCH_LIMIT);
|
||||
previousSearchResult.cachedEvents = combinedEvents.slice(SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine the events from our event sources into a sorted result
|
||||
*
|
||||
* This method will first be called from the combinedSearch() method. In this
|
||||
* case we will fetch SEARCH_LIMIT events from the server and the local index.
|
||||
*
|
||||
* The method will put the SEARCH_LIMIT newest events from the server and the
|
||||
* local index in the results part of the response, the rest will be put in the
|
||||
* cachedEvents field of the previousSearchResult (in this case an empty search
|
||||
* result).
|
||||
*
|
||||
* Every subsequent call will be made from the combinedPagination() method, in
|
||||
* this case we will combine the cachedEvents and the next SEARCH_LIMIT events
|
||||
* from either the server or the local index.
|
||||
*
|
||||
* Since we have two event sources and we need to sort the results by date we
|
||||
* need keep on looking for the oldest event. We are implementing a variation of
|
||||
* a sliding window.
|
||||
*
|
||||
* The event sources are here represented as two sorted lists where the smallest
|
||||
* number represents the newest event. The two lists need to be merged in a way
|
||||
* that preserves the sorted property so they can be shown as one search result.
|
||||
* We first fetch SEARCH_LIMIT events from both sources.
|
||||
*
|
||||
* If we set SEARCH_LIMIT to 3:
|
||||
*
|
||||
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
|
||||
* |01, 02, 04|
|
||||
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
|
||||
* |03, 05, 09|
|
||||
*
|
||||
* We note that the oldest event is from the local index, and we combine the
|
||||
* results:
|
||||
*
|
||||
* Server window [01, 02, 04]
|
||||
* Local window [03, 05, 09]
|
||||
*
|
||||
* Combined events [01, 02, 03, 04, 05, 09]
|
||||
*
|
||||
* We split the combined result in the part that we want to present and a part
|
||||
* that will be cached.
|
||||
*
|
||||
* Presented events [01, 02, 03]
|
||||
* Cached events [04, 05, 09]
|
||||
*
|
||||
* We slide the window for the server since the oldest event is from the local
|
||||
* index.
|
||||
*
|
||||
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
|
||||
* |06, 07, 08|
|
||||
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
|
||||
* |XX, XX, XX|
|
||||
* Cached events [04, 05, 09]
|
||||
*
|
||||
* We note that the oldest event is from the server and we combine the new
|
||||
* server events with the cached ones.
|
||||
*
|
||||
* Cached events [04, 05, 09]
|
||||
* Server events [06, 07, 08]
|
||||
*
|
||||
* Combined events [04, 05, 06, 07, 08, 09]
|
||||
*
|
||||
* We split again.
|
||||
*
|
||||
* Presented events [04, 05, 06]
|
||||
* Cached events [07, 08, 09]
|
||||
*
|
||||
* We slide the local window, the oldest event is on the server.
|
||||
*
|
||||
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
|
||||
* |XX, XX, XX|
|
||||
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
|
||||
* |10, 12, 14|
|
||||
*
|
||||
* Cached events [07, 08, 09]
|
||||
* Local events [10, 12, 14]
|
||||
* Combined events [07, 08, 09, 10, 12, 14]
|
||||
*
|
||||
* Presented events [07, 08, 09]
|
||||
* Cached events [10, 12, 14]
|
||||
*
|
||||
* Next up we slide the server window again.
|
||||
*
|
||||
* Server events [01, 02, 04, 06, 07, 08, 11, 13]
|
||||
* |11, 13|
|
||||
* Local events [03, 05, 09, 10, 12, 14, 15, 16]
|
||||
* |XX, XX, XX|
|
||||
*
|
||||
* Cached events [10, 12, 14]
|
||||
* Server events [11, 13]
|
||||
* Combined events [10, 11, 12, 13, 14]
|
||||
*
|
||||
* Presented events [10, 11, 12]
|
||||
* Cached events [13, 14]
|
||||
*
|
||||
* We have one source exhausted, we fetch the rest of our events from the other
|
||||
* source and combine it with our cached events.
|
||||
*
|
||||
*
|
||||
* @param {object} previousSearchResult A search result from a previous search
|
||||
* call.
|
||||
* @param {object} localEvents An unprocessed search result from the event
|
||||
* index.
|
||||
* @param {object} serverEvents An unprocessed search result from the server.
|
||||
*
|
||||
* @return {object} A response object that combines the events from the
|
||||
* different event sources.
|
||||
*
|
||||
*/
|
||||
function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
|
||||
const response = {};
|
||||
|
||||
const cachedEvents = previousSearchResult.cachedEvents;
|
||||
let oldestEventFrom = previousSearchResult.oldestEventFrom;
|
||||
response.highlights = previousSearchResult.highlights;
|
||||
|
||||
if (localEvents && serverEvents) {
|
||||
// This is a first search call, combine the events from the server and
|
||||
// the local index. Note where our oldest event came from, we shall
|
||||
// fetch the next batch of events from the other source.
|
||||
if (compareOldestEvents(localEvents, serverEvents) < 0) {
|
||||
oldestEventFrom = "local";
|
||||
}
|
||||
|
||||
combineEventSources(previousSearchResult, response, localEvents.results, serverEvents.results);
|
||||
response.highlights = localEvents.highlights.concat(serverEvents.highlights);
|
||||
} else if (localEvents) {
|
||||
// This is a pagination call fetching more events from the local index,
|
||||
// meaning that our oldest event was on the server.
|
||||
// Change the source of the oldest event if our local event is older
|
||||
// than the cached one.
|
||||
if (compareOldestEvents(localEvents, cachedEvents) < 0) {
|
||||
oldestEventFrom = "local";
|
||||
}
|
||||
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
|
||||
} else if (serverEvents) {
|
||||
// This is a pagination call fetching more events from the server,
|
||||
// meaning that our oldest event was in the local index.
|
||||
// Change the source of the oldest event if our server event is older
|
||||
// than the cached one.
|
||||
if (compareOldestEvents(serverEvents, cachedEvents) < 0) {
|
||||
oldestEventFrom = "server";
|
||||
}
|
||||
combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents);
|
||||
} else {
|
||||
// This is a pagination call where we exhausted both of our event
|
||||
// sources, let's push the remaining cached events.
|
||||
response.results = cachedEvents;
|
||||
previousSearchResult.cachedEvents = [];
|
||||
}
|
||||
|
||||
previousSearchResult.oldestEventFrom = oldestEventFrom;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine the local and server search responses
|
||||
*
|
||||
* @param {object} previousSearchResult A search result from a previous search
|
||||
* call.
|
||||
* @param {object} localEvents An unprocessed search result from the event
|
||||
* index.
|
||||
* @param {object} serverEvents An unprocessed search result from the server.
|
||||
*
|
||||
* @return {object} A response object that combines the events from the
|
||||
* different event sources.
|
||||
*/
|
||||
function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
|
||||
// Combine our events first.
|
||||
const response = combineEvents(previousSearchResult, localEvents, serverEvents);
|
||||
|
||||
// Our first search will contain counts from both sources, subsequent
|
||||
// pagination requests will fetch responses only from one of the sources, so
|
||||
// reuse the first count when we're paginating.
|
||||
if (previousSearchResult.count) {
|
||||
response.count = previousSearchResult.count;
|
||||
} else {
|
||||
response.count = localEvents.count + serverEvents.count;
|
||||
}
|
||||
|
||||
// Update our next batch tokens for the given search sources.
|
||||
if (localEvents) {
|
||||
previousSearchResult.seshatQuery.next_batch = localEvents.next_batch;
|
||||
}
|
||||
if (serverEvents) {
|
||||
previousSearchResult.serverSideNextBatch = serverEvents.next_batch;
|
||||
}
|
||||
|
||||
// Set the response next batch token to one of the tokens from the sources,
|
||||
// this makes sure that if we exhaust one of the sources we continue with
|
||||
// the other one.
|
||||
if (previousSearchResult.seshatQuery.next_batch) {
|
||||
response.next_batch = previousSearchResult.seshatQuery.next_batch;
|
||||
} else if (previousSearchResult.serverSideNextBatch) {
|
||||
response.next_batch = previousSearchResult.serverSideNextBatch;
|
||||
}
|
||||
|
||||
// We collected all search results from the server as well as from Seshat,
|
||||
// we still have some events cached that we'll want to display on the next
|
||||
// pagination request.
|
||||
//
|
||||
// Provide a fake next batch token for that case.
|
||||
if (!response.next_batch && previousSearchResult.cachedEvents.length > 0) {
|
||||
response.next_batch = "cached";
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function restoreEncryptionInfo(searchResultSlice) {
|
||||
for (let i = 0; i < searchResultSlice.length; i++) {
|
||||
const timeline = searchResultSlice[i].context.getTimeline();
|
||||
|
||||
for (let j = 0; j < timeline.length; j++) {
|
||||
const ev = timeline[j];
|
||||
|
||||
if (ev.event.curve25519Key) {
|
||||
ev.makeEncrypted(
|
||||
"m.room.encrypted",
|
||||
{ algorithm: ev.event.algorithm },
|
||||
ev.event.curve25519Key,
|
||||
ev.event.ed25519Key,
|
||||
);
|
||||
ev._forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain;
|
||||
|
||||
delete ev.event.curve25519Key;
|
||||
delete ev.event.ed25519Key;
|
||||
delete ev.event.algorithm;
|
||||
delete ev.event.forwardingCurve25519KeyChain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function combinedPagination(searchResult) {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const searchArgs = searchResult.seshatQuery;
|
||||
const oldestEventFrom = searchResult.oldestEventFrom;
|
||||
|
||||
let localResult;
|
||||
let serverSideResult;
|
||||
|
||||
// Fetch events from the local index if we have a token for itand if it's
|
||||
// the local indexes turn or the server has exhausted its results.
|
||||
if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
|
||||
localResult = await eventIndex.search(searchArgs);
|
||||
}
|
||||
|
||||
// Fetch events from the server if we have a token for it and if it's the
|
||||
// local indexes turn or the local index has exhausted its results.
|
||||
if (searchResult.serverSideNextBatch && (oldestEventFrom === "local" || !searchArgs.next_batch)) {
|
||||
const body = {body: searchResult._query, next_batch: searchResult.serverSideNextBatch};
|
||||
serverSideResult = await client.search(body);
|
||||
}
|
||||
|
||||
let serverEvents;
|
||||
|
||||
if (serverSideResult) {
|
||||
serverEvents = serverSideResult.search_categories.room_events;
|
||||
}
|
||||
|
||||
// Combine our events.
|
||||
const combinedResult = combineResponses(searchResult, localResult, serverEvents);
|
||||
|
||||
const response = {
|
||||
search_categories: {
|
||||
room_events: combinedResult,
|
||||
},
|
||||
};
|
||||
|
||||
const oldResultCount = searchResult.results.length;
|
||||
|
||||
// Let the client process the combined result.
|
||||
const result = client._processRoomEventsSearch(searchResult, response);
|
||||
|
||||
// Restore our encryption info so we can properly re-verify the events.
|
||||
const newResultCount = result.results.length - oldResultCount;
|
||||
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
|
||||
restoreEncryptionInfo(newSlice);
|
||||
|
||||
searchResult.pendingRequest = null;
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -117,11 +539,11 @@ function eventIndexSearch(term, roomId = undefined) {
|
||||
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
|
||||
// The search is for a single encrypted room, use our local
|
||||
// search method.
|
||||
searchPromise = localSearch(term, roomId);
|
||||
searchPromise = localSearchProcess(term, roomId);
|
||||
} else {
|
||||
// The search is for a single non-encrypted room, use the
|
||||
// server-side search.
|
||||
searchPromise = serverSideSearch(term, roomId);
|
||||
searchPromise = serverSideSearchProcess(term, roomId);
|
||||
}
|
||||
} else {
|
||||
// Search across all rooms, combine a server side search and a
|
||||
@@ -132,9 +554,45 @@ function eventIndexSearch(term, roomId = undefined) {
|
||||
return searchPromise;
|
||||
}
|
||||
|
||||
function eventIndexSearchPagination(searchResult) {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const seshatQuery = searchResult.seshatQuery;
|
||||
const serverQuery = searchResult._query;
|
||||
|
||||
if (!seshatQuery) {
|
||||
// This is a search in a non-encrypted room. Do the normal server-side
|
||||
// pagination.
|
||||
return client.backPaginateRoomEventsSearch(searchResult);
|
||||
} else if (!serverQuery) {
|
||||
// This is a search in a encrypted room. Do a local pagination.
|
||||
const promise = localPagination(searchResult);
|
||||
searchResult.pendingRequest = promise;
|
||||
|
||||
return promise;
|
||||
} else {
|
||||
// We have both queries around, this is a search across all rooms so a
|
||||
// combined pagination needs to be done.
|
||||
const promise = combinedPagination(searchResult);
|
||||
searchResult.pendingRequest = promise;
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
export function searchPagination(searchResult) {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
if (searchResult.pendingRequest) return searchResult.pendingRequest;
|
||||
|
||||
if (eventIndex === null) return client.backPaginateRoomEventsSearch(searchResult);
|
||||
else return eventIndexSearchPagination(searchResult);
|
||||
}
|
||||
|
||||
export default function eventSearch(term, roomId = undefined) {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
if (eventIndex === null) return serverSideSearch(term, roomId);
|
||||
if (eventIndex === null) return serverSideSearchProcess(term, roomId);
|
||||
else return eventIndexSearch(term, roomId);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
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';
|
||||
@@ -41,6 +41,8 @@ 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";
|
||||
|
||||
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
||||
interface HTMLInputEvent extends Event {
|
||||
@@ -116,7 +118,7 @@ export class Command {
|
||||
|
||||
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;
|
||||
if (!this.runFn) return reject(_t("Command error"));
|
||||
return this.runFn.bind(this)(roomId, args, cmd);
|
||||
}
|
||||
|
||||
@@ -448,8 +450,8 @@ export const Commands = [
|
||||
new Command({
|
||||
command: 'join',
|
||||
aliases: ['j', 'goto'],
|
||||
args: '<room-alias>',
|
||||
description: _td('Joins room with given alias'),
|
||||
args: '<room-address>',
|
||||
description: _td('Joins room with given address'),
|
||||
runFn: function(_, args) {
|
||||
if (args) {
|
||||
// Note: we support 2 versions of this command. The first is
|
||||
@@ -493,8 +495,7 @@ export const Commands = [
|
||||
});
|
||||
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',
|
||||
@@ -560,7 +561,7 @@ export const Commands = [
|
||||
}),
|
||||
new Command({
|
||||
command: 'part',
|
||||
args: '[<room-alias>]',
|
||||
args: '[<room-address>]',
|
||||
description: _td('Leave room'),
|
||||
runFn: function(roomId, args) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
@@ -592,7 +593,7 @@ export const Commands = [
|
||||
}
|
||||
if (targetRoomId) break;
|
||||
}
|
||||
if (!targetRoomId) return reject(_t('Unrecognised room alias:') + ' ' + roomAlias);
|
||||
if (!targetRoomId) return reject(_t('Unrecognised room address:') + ' ' + roomAlias);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,7 +660,7 @@ export const Commands = [
|
||||
if (args) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
const matches = args.match(/^(\S+)$/);
|
||||
const matches = args.match(/^(@[^:]+:\S+)$/);
|
||||
if (matches) {
|
||||
const userId = matches[1];
|
||||
const ignoredUsers = cli.getIgnoredUsers();
|
||||
@@ -689,7 +690,7 @@ export const Commands = [
|
||||
if (args) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
const matches = args.match(/^(\S+)$/);
|
||||
const matches = args.match(/(^@[^:]+:\S+$)/);
|
||||
if (matches) {
|
||||
const userId = matches[1];
|
||||
const ignoredUsers = cli.getIgnoredUsers();
|
||||
@@ -943,8 +944,10 @@ export const Commands = [
|
||||
}
|
||||
|
||||
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
|
||||
dis.dispatch({
|
||||
action: 'view_user',
|
||||
dis.dispatch<ViewUserPayload>({
|
||||
action: Action.ViewUser,
|
||||
// XXX: We should be using a real member object and not assuming what the
|
||||
// receiver wants.
|
||||
member: member || {userId},
|
||||
});
|
||||
return success();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -96,6 +96,17 @@ export default class WidgetMessaging {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget that it should terminate now.
|
||||
* @returns {Promise<*>} Resolves when widget has acknowledged the message.
|
||||
*/
|
||||
terminate() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.Terminate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a screenshot from a widget
|
||||
* @return {Promise} To be resolved with screenshot data when it has been generated
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
componentDidMount: 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
||||
import PropTypes from 'prop-types';
|
||||
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";
|
||||
@@ -67,10 +66,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
||||
|
||||
async componentDidMount() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const secureSecretStorage = (
|
||||
SettingsStore.getValue("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
|
||||
@@ -284,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
|
||||
@@ -295,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;
|
||||
@@ -303,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>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -26,20 +26,27 @@ 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_LOADERROR = 1;
|
||||
const PHASE_MIGRATE = 2;
|
||||
const PHASE_PASSPHRASE = 3;
|
||||
const PHASE_PASSPHRASE_CONFIRM = 4;
|
||||
const PHASE_SHOWKEY = 5;
|
||||
const PHASE_KEEPITSAFE = 6;
|
||||
const PHASE_STORING = 7;
|
||||
const PHASE_DONE = 8;
|
||||
const PHASE_CONFIRM_SKIP = 9;
|
||||
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.
|
||||
|
||||
// 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
|
||||
* Secret Storage in account data.
|
||||
@@ -70,6 +77,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
passPhraseConfirm: '',
|
||||
copied: false,
|
||||
downloaded: false,
|
||||
setPassphrase: false,
|
||||
backupInfo: null,
|
||||
backupSigStatus: null,
|
||||
// does the server offer a UI auth flow with just m.login.password
|
||||
@@ -77,8 +85,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
canUploadKeysWithPasswordOnly: null,
|
||||
accountPassword: props.accountPassword || "",
|
||||
accountPasswordCorrect: null,
|
||||
// status of the key backup toggle switch
|
||||
useKeyBackup: true,
|
||||
|
||||
passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY,
|
||||
};
|
||||
|
||||
this._passphraseField = createRef();
|
||||
@@ -110,7 +118,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
);
|
||||
|
||||
const { force } = this.props;
|
||||
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE;
|
||||
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE;
|
||||
|
||||
this.setState({
|
||||
phase,
|
||||
@@ -152,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) => {
|
||||
@@ -176,7 +203,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
if (successful) {
|
||||
this.setState({
|
||||
copied: true,
|
||||
phase: PHASE_KEEPITSAFE,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -189,7 +215,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
|
||||
this.setState({
|
||||
downloaded: true,
|
||||
phase: PHASE_KEEPITSAFE,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,7 +226,8 @@ 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,
|
||||
});
|
||||
@@ -258,22 +284,15 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
await cli.bootstrapSecretStorage({
|
||||
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
|
||||
createSecretStorageKey: async () => this._recoveryKey,
|
||||
setupNewKeyBackup: this.state.useKeyBackup,
|
||||
setupNewKeyBackup: true,
|
||||
setupNewSecretStorage: true,
|
||||
});
|
||||
if (!this.state.useKeyBackup && this.state.backupInfo) {
|
||||
// If the user is resetting their cross-signing keys and doesn't want
|
||||
// key backup (but had it enabled before), delete the key backup as it's
|
||||
// no longer valid.
|
||||
console.log("Deleting invalid key backup (secrets have been reset; key backup not requested)");
|
||||
await cli.deleteKeyBackupVersion(this.state.backupInfo.version);
|
||||
}
|
||||
} else {
|
||||
await cli.bootstrapSecretStorage({
|
||||
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
|
||||
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
|
||||
@@ -285,9 +304,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
},
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
phase: PHASE_DONE,
|
||||
});
|
||||
this.props.onFinished(true);
|
||||
} catch (e) {
|
||||
if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) {
|
||||
this.setState({
|
||||
@@ -341,22 +358,16 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
this._fetchBackupInfo();
|
||||
}
|
||||
|
||||
_onSkipSetupClick = () => {
|
||||
_onShowKeyContinueClick = () => {
|
||||
this._bootstrapSecretStorage();
|
||||
}
|
||||
|
||||
_onCancelClick = () => {
|
||||
this.setState({phase: PHASE_CONFIRM_SKIP});
|
||||
}
|
||||
|
||||
_onSetUpClick = () => {
|
||||
this.setState({phase: PHASE_PASSPHRASE});
|
||||
}
|
||||
|
||||
_onSkipPassPhraseClick = async () => {
|
||||
this._recoveryKey =
|
||||
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase();
|
||||
this.setState({
|
||||
copied: false,
|
||||
downloaded: false,
|
||||
phase: PHASE_SHOWKEY,
|
||||
});
|
||||
_onGoBackClick = () => {
|
||||
this.setState({phase: PHASE_CHOOSE_KEY_PASSPHRASE});
|
||||
}
|
||||
|
||||
_onPassPhraseNextClick = async (e) => {
|
||||
@@ -383,6 +394,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
this.setState({
|
||||
copied: false,
|
||||
downloaded: false,
|
||||
setPassphrase: true,
|
||||
phase: PHASE_SHOWKEY,
|
||||
});
|
||||
}
|
||||
@@ -396,12 +408,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
_onKeepItSafeBackClick = () => {
|
||||
this.setState({
|
||||
phase: PHASE_SHOWKEY,
|
||||
});
|
||||
}
|
||||
|
||||
_onPassPhraseValidate = (result) => {
|
||||
this.setState({
|
||||
passPhraseValid: result.valid,
|
||||
@@ -426,13 +432,55 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
_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;
|
||||
@@ -445,7 +493,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
label={_t("Password")}
|
||||
value={this.state.accountPassword}
|
||||
onChange={this._onAccountPasswordChange}
|
||||
flagInvalid={this.state.accountPasswordCorrect === false}
|
||||
forceValidity={this.state.accountPasswordCorrect === false ? false : null}
|
||||
autoFocus={true}
|
||||
/></div>
|
||||
</div>;
|
||||
@@ -473,7 +521,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
hasCancel={false}
|
||||
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
|
||||
>
|
||||
<button type="button" className="danger" onClick={this._onSkipSetupClick}>
|
||||
<button type="button" className="danger" onClick={this._onCancelClick}>
|
||||
{_t('Skip')}
|
||||
</button>
|
||||
</DialogButtons>
|
||||
@@ -481,14 +529,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
}
|
||||
|
||||
_renderPhasePassPhrase() {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
|
||||
|
||||
return <form onSubmit={this._onPassPhraseNextClick}>
|
||||
<p>{_t(
|
||||
"Set a recovery passphrase to secure encrypted information and recover it if you log out. " +
|
||||
"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">
|
||||
@@ -507,11 +551,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LabelledToggleSwitch
|
||||
label={ _t("Back up encrypted message keys")}
|
||||
onChange={this._onUseKeyBackupChange} value={this.state.useKeyBackup}
|
||||
/>
|
||||
|
||||
<DialogButtons
|
||||
primaryButton={_t('Continue')}
|
||||
onPrimaryButtonClick={this._onPassPhraseNextClick}
|
||||
@@ -519,27 +558,21 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
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
|
||||
@@ -549,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;
|
||||
@@ -557,12 +591,11 @@ 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 recovery passphrase a second time to confirm it.",
|
||||
@@ -588,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>
|
||||
@@ -596,66 +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 recovery 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._recoveryKey.encodedPrivateKey}</code>
|
||||
</div>
|
||||
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
|
||||
<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}
|
||||
>
|
||||
{_t("Copy")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onDownloadClick}>
|
||||
{_t("Download")}
|
||||
{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>;
|
||||
}
|
||||
|
||||
@@ -667,7 +682,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
}
|
||||
|
||||
_renderPhaseLoadError() {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return <div>
|
||||
<p>{_t("Unable to query secret storage status")}</p>
|
||||
<div className="mx_Dialog_buttons">
|
||||
@@ -680,53 +694,39 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
</div>;
|
||||
}
|
||||
|
||||
_renderPhaseDone() {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
_renderPhaseSkipConfirm() {
|
||||
return <div>
|
||||
<p>{_t(
|
||||
"You can now verify your other devices, " +
|
||||
"and other users to keep your chats safe.",
|
||||
"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('OK')}
|
||||
onPrimaryButtonClick={this._onDone}
|
||||
hasCancel={false}
|
||||
/>
|
||||
</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.",
|
||||
)}
|
||||
<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 recovery 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 '';
|
||||
}
|
||||
@@ -737,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">
|
||||
@@ -756,6 +755,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
||||
case PHASE_LOADERROR:
|
||||
content = this._renderPhaseLoadError();
|
||||
break;
|
||||
case PHASE_CHOOSE_KEY_PASSPHRASE:
|
||||
content = this._renderPhaseChooseKeyPassphrase();
|
||||
break;
|
||||
case PHASE_MIGRATE:
|
||||
content = this._renderPhaseMigrate();
|
||||
break;
|
||||
@@ -768,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}
|
||||
>
|
||||
|
||||
@@ -35,15 +35,15 @@ export interface ISelectionRange {
|
||||
|
||||
export interface ICompletion {
|
||||
type: "at-room" | "command" | "community" | "room" | "user";
|
||||
completion: string,
|
||||
completion: string;
|
||||
completionId?: string;
|
||||
component?: ReactElement,
|
||||
range: ISelectionRange,
|
||||
command?: 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 = [
|
||||
|
||||
@@ -90,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,
|
||||
}))
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, {forwardRef} from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/* These were earlier stateless functional components but had to be converted
|
||||
@@ -30,50 +30,37 @@ interface ITextualCompletionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export class TextualCompletion extends React.PureComponent<ITextualCompletionProps> {
|
||||
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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
interface IPillCompletionProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
initialComponent?: React.ReactNode,
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export class PillCompletion extends React.PureComponent<IPillCompletionProps> {
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
||||
|
||||
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
|
||||
@@ -121,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,
|
||||
};
|
||||
|
||||
@@ -48,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,
|
||||
}];
|
||||
|
||||
@@ -17,8 +17,6 @@ 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 {
|
||||
@@ -35,8 +33,9 @@ interface IOptions<T extends {}> {
|
||||
/**
|
||||
* 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
|
||||
@@ -45,11 +44,11 @@ interface IOptions<T extends {}> {
|
||||
* @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<T> {
|
||||
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, T[]>;
|
||||
private _items: Map<string, {object: T, keyWeight: number}[]>;
|
||||
|
||||
constructor(objects: T[], options: IOptions<T> = { keys: [] }) {
|
||||
this._options = options;
|
||||
@@ -75,19 +74,26 @@ export default class QueryMatcher<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,32 +106,40 @@ export default class QueryMatcher<T> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ 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 {ICompletion, ISelectionRange} from "./Autocompleter";
|
||||
import { uniqBy, sortBy } from 'lodash';
|
||||
|
||||
const ROOM_REGEX = /\B#\S*/g;
|
||||
|
||||
@@ -91,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,
|
||||
@@ -103,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,
|
||||
};
|
||||
|
||||
@@ -125,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,
|
||||
};
|
||||
|
||||
@@ -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,23 +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 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;
|
||||
@@ -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,91 +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,
|
||||
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitem
|
||||
export const MenuItem = ({children, label, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItem.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=group for grouping menu radios/checkboxes
|
||||
export const MenuGroup = ({children, label, ...props}) => {
|
||||
return <div {...props} role="group" aria-label={label}>
|
||||
{ children }
|
||||
</div>;
|
||||
};
|
||||
MenuGroup.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
className: PropTypes.string, // optional
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitemcheckbox
|
||||
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItemCheckbox.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
active: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitemradio
|
||||
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItemRadio.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
active: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
||||
export const toRightOf = (elementRect, chevronOffset=12) => {
|
||||
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
|
||||
const left = elementRect.right + window.pageXOffset + 3;
|
||||
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
@@ -426,8 +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;
|
||||
@@ -485,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';
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import PropTypes from 'prop-types';
|
||||
import request from 'browser-request';
|
||||
import { _t } from '../../languageHandler';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import classnames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
||||
@@ -21,7 +21,7 @@ import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { getHostingLink } from '../../utils/HostingLink';
|
||||
import { sanitizedHtmlNode } from '../../HtmlUtils';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
@@ -92,7 +92,7 @@ const CategoryRoomList = createReactClass({
|
||||
Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, {
|
||||
title: _t('Add rooms to the community summary'),
|
||||
description: _t("Which rooms would you like to add to this summary?"),
|
||||
placeholder: _t("Room name or alias"),
|
||||
placeholder: _t("Room name or address"),
|
||||
button: _t("Add to summary"),
|
||||
pickerType: 'room',
|
||||
validAddressTypes: ['mx-room-id'],
|
||||
|
||||
@@ -21,10 +21,11 @@ import { getHomePageUrl } from "../../utils/pages";
|
||||
import { _t } from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
|
||||
const onClickExplore = () => dis.dispatch({action: 'view_room_directory'});
|
||||
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
|
||||
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
|
||||
|
||||
const HomePage = () => {
|
||||
|
||||
@@ -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({
|
||||
@@ -197,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});
|
||||
},
|
||||
@@ -251,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>
|
||||
);
|
||||
}
|
||||
@@ -273,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 }
|
||||
@@ -284,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>
|
||||
);
|
||||
|
||||
417
src/components/structures/LeftPanel2.tsx
Normal file
417
src/components/structures/LeftPanel2.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
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);
|
||||
const headerContainer = header.parentElement; // .mx_RoomSublist2_headerContainer
|
||||
|
||||
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");
|
||||
}
|
||||
if (!headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
|
||||
headerContainer.classList.add("mx_RoomSublist2_headerContainer_hasSticky");
|
||||
}
|
||||
|
||||
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 (headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
|
||||
headerContainer.classList.remove("mx_RoomSublist2_headerContainer_hasSticky");
|
||||
}
|
||||
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 {
|
||||
let breadcrumbs;
|
||||
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
|
||||
breadcrumbs = (
|
||||
<IndicatorScrollbar
|
||||
className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||
verticalScrollsHorizontally={true}
|
||||
>
|
||||
<RoomBreadcrumbs2 />
|
||||
</IndicatorScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_LeftPanel2_userHeader">
|
||||
<UserMenu isMinimized={this.props.isMinimized} />
|
||||
{breadcrumbs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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()}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ limitations under the License.
|
||||
import * as React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
|
||||
@@ -27,11 +26,10 @@ 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, MatrixClientCreds} from '../../MatrixClientPeg';
|
||||
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore from "../../stores/RoomListStore";
|
||||
|
||||
import TagOrderActions from '../../actions/TagOrderActions';
|
||||
import RoomListActions from '../../actions/RoomListActions';
|
||||
@@ -42,6 +40,21 @@ 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.
|
||||
@@ -56,7 +69,7 @@ function canElementReceiveInput(el) {
|
||||
|
||||
interface IProps {
|
||||
matrixClient: MatrixClient;
|
||||
onRegistered: (credentials: MatrixClientCreds) => Promise<MatrixClient>;
|
||||
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
|
||||
viaServers?: string[];
|
||||
hideToSRUsers: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
@@ -64,10 +77,6 @@ interface IProps {
|
||||
initialEventPixelOffset: number;
|
||||
leftDisabled: boolean;
|
||||
rightDisabled: boolean;
|
||||
showCookieBar: boolean;
|
||||
hasNewVersion: boolean;
|
||||
userHasGeneratedPassword: boolean;
|
||||
showNotifierToolbar: boolean;
|
||||
page_type: string;
|
||||
autoJoin: boolean;
|
||||
thirdPartyInvite?: object;
|
||||
@@ -75,7 +84,6 @@ interface IProps {
|
||||
currentRoomId: string;
|
||||
ConferenceHandler?: object;
|
||||
collapseLhs: boolean;
|
||||
checkingForUpdate: boolean;
|
||||
config: {
|
||||
piwik: {
|
||||
policyUrl: string;
|
||||
@@ -85,19 +93,26 @@ interface IProps {
|
||||
currentUserId?: string;
|
||||
currentGroupId?: string;
|
||||
currentGroupIsNew?: boolean;
|
||||
version?: string;
|
||||
newVersion?: string;
|
||||
newVersionReleaseNotes?: string;
|
||||
}
|
||||
|
||||
interface IUsageLimit {
|
||||
limit_type: "monthly_active_user" | string;
|
||||
admin_contact?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
mouseDown?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
syncErrorData: any;
|
||||
syncErrorData?: {
|
||||
error: {
|
||||
data: IUsageLimit;
|
||||
errcode: string;
|
||||
};
|
||||
};
|
||||
usageLimitEventContent?: IUsageLimit;
|
||||
useCompactLayout: boolean;
|
||||
serverNoticeEvents: MatrixEvent[];
|
||||
userHasGeneratedPassword: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +124,7 @@ interface IState {
|
||||
*
|
||||
* Components mounted below us can access the matrix client via the react context.
|
||||
*/
|
||||
class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
class LoggedInView extends React.Component<IProps, IState> {
|
||||
static displayName = 'LoggedInView';
|
||||
|
||||
static propTypes = {
|
||||
@@ -132,6 +147,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
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) {
|
||||
@@ -140,11 +156,8 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
this.state = {
|
||||
mouseDown: undefined,
|
||||
syncErrorData: undefined,
|
||||
userHasGeneratedPassword: false,
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
// any currently active server notice events
|
||||
serverNoticeEvents: [],
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
@@ -166,6 +179,10 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = React.createRef();
|
||||
@@ -178,23 +195,12 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
this._loadResizerPreferences();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// attempt to guess when a banner was opened or closed
|
||||
if (
|
||||
(prevProps.showCookieBar !== this.props.showCookieBar) ||
|
||||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
|
||||
(prevState.userHasGeneratedPassword !== this.state.userHasGeneratedPassword) ||
|
||||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
|
||||
) {
|
||||
this.props.resizeNotifier.notifyBannersChanged();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -219,9 +225,11 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
};
|
||||
|
||||
_setStateFromSessionStore = () => {
|
||||
this.setState({
|
||||
userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()),
|
||||
});
|
||||
if (this._sessionStore.getCachedPassword()) {
|
||||
showSetPasswordToast();
|
||||
} else {
|
||||
hideSetPasswordToast();
|
||||
}
|
||||
};
|
||||
|
||||
_createResizer() {
|
||||
@@ -262,16 +270,17 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
onAccountData = (event) => {
|
||||
if (event.getType() === "im.vector.web.settings") {
|
||||
this.setState({
|
||||
useCompactLayout: event.getContent().useCompactLayout,
|
||||
});
|
||||
}
|
||||
if (event.getType() === "m.ignored_user_list") {
|
||||
dis.dispatch({action: "ignore_state_changed"});
|
||||
}
|
||||
};
|
||||
|
||||
onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => {
|
||||
this.setState({
|
||||
useCompactLayout: valueAtLevel,
|
||||
});
|
||||
};
|
||||
|
||||
onSync = (syncState, oldSyncState, data) => {
|
||||
const oldErrCode = (
|
||||
this.state.syncErrorData &&
|
||||
@@ -293,22 +302,37 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
|
||||
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
|
||||
this._updateServerNoticeEvents();
|
||||
} else {
|
||||
this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
|
||||
}
|
||||
};
|
||||
|
||||
onRoomStateEvents = (ev, state) => {
|
||||
const roomLists = RoomListStore.getRoomLists();
|
||||
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
|
||||
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
if (roomLists[DefaultTagID.ServerNotice] && roomLists[DefaultTagID.ServerNotice].some(r => r.roomId === ev.getRoomId())) {
|
||||
this._updateServerNoticeEvents();
|
||||
}
|
||||
};
|
||||
|
||||
_updateServerNoticeEvents = async () => {
|
||||
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;
|
||||
@@ -317,12 +341,19 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
for (const eventId of pinnedEventIds) {
|
||||
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
|
||||
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
|
||||
if (event) pinnedEvents.push(event);
|
||||
if (event) events.push(event);
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
serverNoticeEvents: pinnedEvents,
|
||||
|
||||
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) => {
|
||||
@@ -337,7 +368,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -379,20 +410,6 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
};
|
||||
|
||||
_onKeyDown = (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;
|
||||
}
|
||||
*/
|
||||
|
||||
let handled = false;
|
||||
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
|
||||
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
|
||||
@@ -429,9 +446,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
// composer, so CTRL+` it is
|
||||
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_top_left_menu',
|
||||
});
|
||||
dis.fire(Action.ToggleUserMenu);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
@@ -446,8 +461,8 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
dis.dispatch({
|
||||
action: 'view_room_delta',
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: ev.key === Key.ARROW_UP ? -1 : 1,
|
||||
unread: ev.shiftKey,
|
||||
});
|
||||
@@ -487,7 +502,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
|
||||
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
|
||||
@@ -598,12 +613,6 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
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;
|
||||
|
||||
@@ -647,50 +656,27 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
break;
|
||||
}
|
||||
|
||||
const usageLimitEvent = this.state.serverNoticeEvents.find((e) => {
|
||||
return (
|
||||
e && e.getType() === 'm.room.message' &&
|
||||
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
|
||||
);
|
||||
});
|
||||
|
||||
let topBar;
|
||||
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
topBar = <ServerLimitBar kind='hard'
|
||||
adminContact={this.state.syncErrorData.error.data.admin_contact}
|
||||
limitType={this.state.syncErrorData.error.data.limit_type}
|
||||
/>;
|
||||
} else if (usageLimitEvent) {
|
||||
topBar = <ServerLimitBar kind='soft'
|
||||
adminContact={usageLimitEvent.getContent().admin_contact}
|
||||
limitType={usageLimitEvent.getContent().limit_type}
|
||||
/>;
|
||||
} else if (this.props.showCookieBar &&
|
||||
this.props.config.piwik &&
|
||||
navigator.doNotTrack !== "1"
|
||||
) {
|
||||
const policyUrl = this.props.config.piwik.policyUrl || null;
|
||||
topBar = <CookieBar policyUrl={policyUrl} />;
|
||||
} else if (this.props.hasNewVersion) {
|
||||
topBar = <NewVersionBar version={this.props.version} newVersion={this.props.newVersion}
|
||||
releaseNotes={this.props.newVersionReleaseNotes}
|
||||
/>;
|
||||
} else if (this.props.checkingForUpdate) {
|
||||
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
|
||||
} else if (this.state.userHasGeneratedPassword) {
|
||||
topBar = <PasswordNagBar />;
|
||||
} else if (this.props.showNotifierToolbar) {
|
||||
topBar = <MatrixToolbar />;
|
||||
}
|
||||
|
||||
let bodyClasses = 'mx_MatrixChat';
|
||||
if (topBar) {
|
||||
bodyClasses += ' mx_MatrixChat_toolbarShowing';
|
||||
}
|
||||
if (this.state.useCompactLayout) {
|
||||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||
}
|
||||
|
||||
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
|
||||
@@ -701,20 +687,16 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||
onMouseDown={this._onMouseDown}
|
||||
onMouseUp={this._onMouseUp}
|
||||
>
|
||||
{ topBar }
|
||||
<ToastContainer />
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||
<LeftPanel
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
{ leftPanel }
|
||||
<ResizeHandle />
|
||||
{ pageElement }
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
<CallContainer />
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import {InvalidStoreError} from "matrix-js-sdk/src/errors";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import { isCryptoAvailable } from 'matrix-js-sdk/src/crypto';
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
// @ts-ignore - XXX: no idea why this import fails
|
||||
import * as Matrix from "matrix-js-sdk";
|
||||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
|
||||
import 'focus-visible';
|
||||
// what-input helps improve keyboard accessibility
|
||||
@@ -30,17 +30,17 @@ import 'what-input';
|
||||
|
||||
import Analytics from "../../Analytics";
|
||||
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as RoomListSorter from "../../RoomListSorter";
|
||||
import dis from "../../dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import Notifier from '../../Notifier';
|
||||
|
||||
import Modal from "../../Modal";
|
||||
import Tinter from "../../Tinter";
|
||||
import * as sdk from '../../index';
|
||||
import { showStartChatInviteDialog, showRoomInviteDialog } from '../../RoomInvite';
|
||||
import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite';
|
||||
import * as Rooms from '../../Rooms';
|
||||
import linkifyMatrix from "../../linkify-matrix";
|
||||
import * as Lifecycle from '../../Lifecycle';
|
||||
@@ -50,23 +50,31 @@ import PageTypes from '../../PageTypes';
|
||||
import { getHomePageUrl } from '../../utils/pages';
|
||||
|
||||
import createRoom from "../../createRoom";
|
||||
import KeyRequestHandler from '../../KeyRequestHandler';
|
||||
import { _t, getCurrentLanguage } from '../../languageHandler';
|
||||
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
|
||||
import SettingsStore, { SettingLevel } from "../../settings/SettingsStore";
|
||||
import ThemeController from "../../settings/controllers/ThemeController";
|
||||
import { startAnyRegistrationFlow } from "../../Registration.js";
|
||||
import { messageForSyncError } from '../../utils/ErrorUtils';
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
|
||||
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
|
||||
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
|
||||
import DMRoomMap from '../../utils/DMRoomMap';
|
||||
import { countRoomsWithNotif } from '../../RoomNotifs';
|
||||
import { ThemeWatcher } from "../../theme";
|
||||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||
import { FontWatcher } from '../../settings/watchers/FontWatcher';
|
||||
import { storeRoomAliasInCache } from '../../RoomAliasCache';
|
||||
import {defer, IDeferred} from "../../utils/promise";
|
||||
import { defer, IDeferred } from "../../utils/promise";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
import * as StorageManager from "../../utils/StorageManager";
|
||||
import type LoggedInViewType from "./LoggedInView";
|
||||
import { ViewUserPayload } from "../../dispatcher/payloads/ViewUserPayload";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import {
|
||||
showToast as showAnalyticsToast,
|
||||
hideToast as hideAnalyticsToast
|
||||
} from "../../toasts/AnalyticsToast";
|
||||
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
@@ -107,7 +115,7 @@ export enum Views {
|
||||
// re-dispatched. NOTE: some actions are non-trivial and would require
|
||||
// re-factoring to be included in this list in future.
|
||||
const ONBOARDING_FLOW_STARTERS = [
|
||||
'view_user_settings',
|
||||
Action.ViewUserSettings,
|
||||
'view_create_chat',
|
||||
'view_create_room',
|
||||
'view_create_group',
|
||||
@@ -145,9 +153,9 @@ interface IProps { // TODO type things better
|
||||
// Represents the screen to display as a result of parsing the initial window.location
|
||||
initialScreenAfterLogin?: IScreen;
|
||||
// displayname, if any, to set on the device when logging in/registering.
|
||||
defaultDeviceDisplayName?: string,
|
||||
defaultDeviceDisplayName?: string;
|
||||
// A function that makes a registration URL
|
||||
makeRegistrationUrl: (object) => string,
|
||||
makeRegistrationUrl: (object) => string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -168,12 +176,6 @@ interface IState {
|
||||
leftDisabled: boolean;
|
||||
middleDisabled: boolean;
|
||||
// the right panel's disabled state is tracked in its store.
|
||||
version?: string;
|
||||
newVersion?: string;
|
||||
hasNewVersion: boolean;
|
||||
newVersionReleaseNotes?: string;
|
||||
checkingForUpdate?: string; // updateCheckStatusEnum
|
||||
showCookieBar: boolean;
|
||||
// Parameters used in the registration dance with the IS
|
||||
register_client_secret?: string;
|
||||
register_session_id?: string;
|
||||
@@ -183,7 +185,6 @@ interface IState {
|
||||
hideToSRUsers: boolean;
|
||||
syncError?: Error;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
showNotifierToolbar: boolean;
|
||||
serverConfig?: ValidatedServerConfig;
|
||||
ready: boolean;
|
||||
thirdPartyInvite?: object;
|
||||
@@ -216,6 +217,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
private readonly loggedInView: React.RefObject<LoggedInViewType>;
|
||||
private readonly dispatcherRef: any;
|
||||
private readonly themeWatcher: ThemeWatcher;
|
||||
private readonly fontWatcher: FontWatcher;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
@@ -226,17 +228,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
leftDisabled: false,
|
||||
middleDisabled: false,
|
||||
|
||||
hasNewVersion: false,
|
||||
newVersionReleaseNotes: null,
|
||||
checkingForUpdate: null,
|
||||
|
||||
showCookieBar: false,
|
||||
|
||||
hideToSRUsers: false,
|
||||
|
||||
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
|
||||
resizeNotifier: new ResizeNotifier(),
|
||||
showNotifierToolbar: false,
|
||||
ready: false,
|
||||
};
|
||||
|
||||
@@ -283,8 +278,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.accountPasswordTimer = null;
|
||||
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
this.themeWatcher = new ThemeWatcher();
|
||||
this.fontWatcher = new FontWatcher();
|
||||
this.themeWatcher.start();
|
||||
this.fontWatcher.start();
|
||||
|
||||
this.focusComposer = false;
|
||||
|
||||
@@ -334,12 +332,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("showCookieBar")) {
|
||||
this.setState({
|
||||
showCookieBar: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
Analytics.enable();
|
||||
}
|
||||
@@ -358,7 +350,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
Analytics.trackPageChange(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
this.focusComposer = false;
|
||||
}
|
||||
}
|
||||
@@ -367,6 +359,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
Lifecycle.stopMatrixClient();
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.themeWatcher.stop();
|
||||
this.fontWatcher.stop();
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
|
||||
|
||||
@@ -468,7 +461,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
||||
onAction = (payload) => {
|
||||
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
||||
// Start the onboarding process for certain actions
|
||||
@@ -562,6 +554,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
case 'leave_room':
|
||||
this.leaveRoom(payload.room_id);
|
||||
break;
|
||||
case 'forget_room':
|
||||
this.forgetRoom(payload.room_id);
|
||||
break;
|
||||
case 'reject_invite':
|
||||
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
|
||||
title: _t('Reject invitation'),
|
||||
@@ -604,19 +599,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'view_prev_room':
|
||||
this.viewNextRoom(-1);
|
||||
break;
|
||||
case 'view_next_room':
|
||||
this.viewNextRoom(1);
|
||||
break;
|
||||
case 'view_indexed_room':
|
||||
this.viewIndexedRoom(payload.roomIndex);
|
||||
break;
|
||||
case 'view_user_settings': {
|
||||
case Action.ViewUserSettings: {
|
||||
const tabPayload = payload as OpenToTabPayload;
|
||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
|
||||
{initialTabId: tabPayload.initialTabId},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true
|
||||
);
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this.viewSomethingBehindModal();
|
||||
@@ -630,7 +622,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
||||
break;
|
||||
}
|
||||
case 'view_room_directory': {
|
||||
case Action.ViewRoomDirectory: {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
||||
'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
@@ -680,9 +672,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
dis.dispatch({action: 'view_my_groups'});
|
||||
}
|
||||
break;
|
||||
case 'notifier_enabled':
|
||||
this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()});
|
||||
break;
|
||||
case 'hide_left_panel':
|
||||
this.setState({
|
||||
collapseLhs: true,
|
||||
@@ -730,15 +719,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
case 'client_started':
|
||||
this.onClientStarted();
|
||||
break;
|
||||
case 'new_version':
|
||||
this.onVersion(
|
||||
payload.currentVersion, payload.newVersion,
|
||||
payload.releaseNotes,
|
||||
);
|
||||
break;
|
||||
case 'check_updates':
|
||||
this.setState({ checkingForUpdate: payload.value });
|
||||
break;
|
||||
case 'send_event':
|
||||
this.onSendEvent(payload.room_id, payload.event);
|
||||
break;
|
||||
@@ -755,19 +735,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
case 'accept_cookies':
|
||||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
|
||||
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
|
||||
|
||||
this.setState({
|
||||
showCookieBar: false,
|
||||
});
|
||||
hideAnalyticsToast();
|
||||
Analytics.enable();
|
||||
break;
|
||||
case 'reject_cookies':
|
||||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
|
||||
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
|
||||
|
||||
this.setState({
|
||||
showCookieBar: false,
|
||||
});
|
||||
hideAnalyticsToast();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -835,19 +809,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Move to RoomViewStore
|
||||
private viewIndexedRoom(roomIndex: number) {
|
||||
const allRooms = RoomListSorter.mostRecentActivityFirst(
|
||||
MatrixClientPeg.get().getRooms(),
|
||||
);
|
||||
if (allRooms[roomIndex]) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: allRooms[roomIndex].roomId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// switch view to the given room
|
||||
//
|
||||
// @param {Object} roomInfo Object containing data about the room to be joined
|
||||
@@ -926,9 +887,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
private viewGroup(payload) {
|
||||
private async viewGroup(payload) {
|
||||
const groupId = payload.group_id;
|
||||
|
||||
// Wait for the first sync to complete
|
||||
if (!this.firstSyncComplete) {
|
||||
if (!this.firstSyncPromise) {
|
||||
console.warn('Cannot view a group before first sync. group_id:', groupId);
|
||||
return;
|
||||
}
|
||||
await this.firstSyncPromise.promise;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
view: Views.LOGGED_IN,
|
||||
currentGroupId: groupId,
|
||||
currentGroupIsNew: payload.group_is_new,
|
||||
});
|
||||
@@ -1091,7 +1063,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
||||
private leaveRoom(roomId: string) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this.leaveRoomWarnings(roomId);
|
||||
|
||||
@@ -1155,6 +1126,21 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
private forgetRoom(roomId: string) {
|
||||
MatrixClientPeg.get().forget(roomId).then(() => {
|
||||
// Switch to another room view if we're currently viewing the historical room
|
||||
if (this.state.currentRoomId === roomId) {
|
||||
dis.dispatch({ action: "view_next_room" });
|
||||
}
|
||||
}).catch((err) => {
|
||||
const errCode = err.errcode || _td("unknown error code");
|
||||
Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, {
|
||||
title: _t("Failed to forget room %(errCode)s", {errCode}),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a chat with the welcome user, if the user doesn't already have one
|
||||
* @returns {string} The room ID of the new room, or null if no room was created
|
||||
@@ -1245,6 +1231,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
StorageManager.tryPersistStorage();
|
||||
|
||||
if (SettingsStore.getValue("showCookieBar") && this.props.config.piwik && navigator.doNotTrack !== "1") {
|
||||
showAnalyticsToast(this.props.config.piwik && this.props.config.piwik.policyUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private showScreenAfterLogin() {
|
||||
@@ -1372,10 +1362,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.firstSyncComplete = true;
|
||||
this.firstSyncPromise.resolve();
|
||||
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
if (Notifier.shouldShowToolbar()) {
|
||||
showNotificationsToast();
|
||||
}
|
||||
|
||||
dis.fire(Action.FocusComposer);
|
||||
this.setState({
|
||||
ready: true,
|
||||
showNotifierToolbar: Notifier.shouldShowToolbar(),
|
||||
});
|
||||
});
|
||||
cli.on('Call.incoming', function(call) {
|
||||
@@ -1396,7 +1389,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
return;
|
||||
}
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
|
||||
title: _t('Signed Out'),
|
||||
description: _t('For security, this session has been signed out. Please sign in again.'),
|
||||
@@ -1454,16 +1446,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
cli.on("Session.logged_out", () => dft.stop());
|
||||
cli.on("Event.decrypted", (e, err) => dft.eventDecrypted(e, err));
|
||||
|
||||
// TODO: We can remove this once cross-signing is the only way.
|
||||
// https://github.com/vector-im/riot-web/issues/11908
|
||||
const krh = new KeyRequestHandler(cli);
|
||||
cli.on("crypto.roomKeyRequest", (req) => {
|
||||
krh.handleKeyRequest(req);
|
||||
});
|
||||
cli.on("crypto.roomKeyRequestCancellation", (req) => {
|
||||
krh.handleKeyRequestCancellation(req);
|
||||
});
|
||||
|
||||
cli.on("Room", (room) => {
|
||||
if (MatrixClientPeg.get().isCryptoEnabled()) {
|
||||
const blacklistEnabled = SettingsStore.getValueAt(
|
||||
@@ -1476,7 +1458,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
});
|
||||
cli.on("crypto.warning", (type) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
switch (type) {
|
||||
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
|
||||
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
|
||||
@@ -1534,13 +1515,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
|
||||
cli.on("crypto.verification.request", request => {
|
||||
const isFlagOn = SettingsStore.getValue("feature_cross_signing");
|
||||
|
||||
if (!isFlagOn && !request.channel.deviceId) {
|
||||
request.cancel({code: "m.invalid_message", reason: "This client has cross-signing disabled"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.verifier) {
|
||||
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
|
||||
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
|
||||
@@ -1553,7 +1527,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
icon: "verification",
|
||||
props: {request},
|
||||
component: sdk.getComponent("toasts.VerificationRequestToast"),
|
||||
priority: ToastStore.PRIORITY_REALTIME,
|
||||
priority: 90,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1583,9 +1557,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
// be aware of will be signalled through the room shield
|
||||
// changing colour. More advanced behaviour will come once
|
||||
// we implement more settings.
|
||||
cli.setGlobalErrorOnUnknownDevices(
|
||||
!SettingsStore.getValue("feature_cross_signing"),
|
||||
);
|
||||
cli.setGlobalErrorOnUnknownDevices(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1621,9 +1593,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
action: 'view_create_room',
|
||||
});
|
||||
} else if (screen === 'settings') {
|
||||
dis.dispatch({
|
||||
action: 'view_user_settings',
|
||||
});
|
||||
dis.fire(Action.ViewUserSettings);
|
||||
} else if (screen === 'welcome') {
|
||||
dis.dispatch({
|
||||
action: 'view_welcome_page',
|
||||
@@ -1638,9 +1608,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
action: 'require_registration',
|
||||
});
|
||||
} else if (screen === 'directory') {
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
} else if (screen === "start_sso" || screen === "start_cas") {
|
||||
// TODO if logged in, skip SSO
|
||||
let cli = MatrixClientPeg.get();
|
||||
if (!cli) {
|
||||
const {hsUrl, isUrl} = this.props.serverConfig;
|
||||
cli = Matrix.createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const type = screen === "start_sso" ? "sso" : "cas";
|
||||
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
|
||||
} else if (screen === 'groups') {
|
||||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
@@ -1755,8 +1736,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
||||
const member = new RoomMember(null, userId);
|
||||
if (!member) { return; }
|
||||
dis.dispatch({
|
||||
action: 'view_user',
|
||||
dis.dispatch<ViewUserPayload>({
|
||||
action: Action.ViewUser,
|
||||
member: member,
|
||||
});
|
||||
}
|
||||
@@ -1829,16 +1810,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.showScreen("settings");
|
||||
};
|
||||
|
||||
onVersion(current: string, latest: string, releaseNotes?: string) {
|
||||
this.setState({
|
||||
version: current,
|
||||
newVersion: latest,
|
||||
hasNewVersion: current !== latest,
|
||||
newVersionReleaseNotes: releaseNotes,
|
||||
checkingForUpdate: null,
|
||||
});
|
||||
}
|
||||
|
||||
onSendEvent(roomId: string, event: MatrixEvent) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli) {
|
||||
@@ -1867,7 +1838,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
updateStatusIndicator(state: string, prevState: string) {
|
||||
const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getRooms()).count;
|
||||
// only count visible rooms to not torment the user with notification counts in rooms they can't see
|
||||
// it will include highlights from the previous version of the room internally
|
||||
const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getVisibleRooms()).count;
|
||||
|
||||
if (PlatformPeg.get()) {
|
||||
PlatformPeg.get().setErrorStatus(state === 'ERROR');
|
||||
@@ -1909,60 +1882,42 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.accountPasswordTimer = null;
|
||||
}, 60 * 5 * 1000);
|
||||
|
||||
// Wait for the client to be logged in (but not started)
|
||||
// which is enough to ask the server about account data.
|
||||
const loggedIn = new Promise(resolve => {
|
||||
const actionHandlerRef = dis.register(payload => {
|
||||
if (payload.action !== "on_logged_in") {
|
||||
return;
|
||||
}
|
||||
dis.unregister(actionHandlerRef);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create and start the client in the background
|
||||
const setLoggedInPromise = Lifecycle.setLoggedIn(credentials);
|
||||
await loggedIn;
|
||||
// Create and start the client
|
||||
await Lifecycle.setLoggedIn(credentials);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
// We're checking `isCryptoAvailable` here instead of `isCryptoEnabled`
|
||||
// because the client hasn't been started yet.
|
||||
const cryptoAvailable = isCryptoAvailable();
|
||||
if (!cryptoAvailable) {
|
||||
const cryptoEnabled = cli.isCryptoEnabled();
|
||||
if (!cryptoEnabled) {
|
||||
this.onLoggedIn();
|
||||
}
|
||||
|
||||
this.setState({ pendingInitialSync: true });
|
||||
await this.firstSyncPromise.promise;
|
||||
|
||||
if (!cryptoAvailable) {
|
||||
this.setState({ pendingInitialSync: false });
|
||||
return setLoggedInPromise;
|
||||
const promisesList = [this.firstSyncPromise.promise];
|
||||
if (cryptoEnabled) {
|
||||
// wait for the client to finish downloading cross-signing keys for us so we
|
||||
// know whether or not we have keys set up on this account
|
||||
promisesList.push(cli.downloadKeys([cli.getUserId()]));
|
||||
}
|
||||
|
||||
// Test for the master cross-signing key in SSSS as a quick proxy for
|
||||
// whether cross-signing has been set up on the account.
|
||||
const masterKeyInStorage = !!cli.getAccountData("m.cross_signing.master");
|
||||
if (masterKeyInStorage) {
|
||||
// Auto-enable cross-signing for the new session when key found in
|
||||
// secret storage.
|
||||
SettingsStore.setValue("feature_cross_signing", null, SettingLevel.DEVICE, true);
|
||||
// Now update the state to say we're waiting for the first sync to complete rather
|
||||
// than for the login to finish.
|
||||
this.setState({ pendingInitialSync: true });
|
||||
|
||||
await Promise.all(promisesList);
|
||||
|
||||
if (!cryptoEnabled) {
|
||||
this.setState({ pendingInitialSync: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
|
||||
if (crossSigningIsSetUp) {
|
||||
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
||||
} else if (
|
||||
SettingsStore.getValue("feature_cross_signing") &&
|
||||
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
|
||||
) {
|
||||
// This will only work if the feature is set to 'enable' in the config,
|
||||
// since it's too early in the lifecycle for users to have turned the
|
||||
// labs flag on.
|
||||
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
|
||||
this.setStateForNewView({ view: Views.E2E_SETUP });
|
||||
} else {
|
||||
this.onLoggedIn();
|
||||
}
|
||||
this.setState({ pendingInitialSync: false });
|
||||
|
||||
return setLoggedInPromise;
|
||||
};
|
||||
|
||||
// complete security / e2e setup has finished
|
||||
@@ -1970,14 +1925,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.onLoggedIn();
|
||||
};
|
||||
|
||||
render() {
|
||||
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
|
||||
|
||||
getFragmentAfterLogin() {
|
||||
let fragmentAfterLogin = "";
|
||||
if (this.props.initialScreenAfterLogin) {
|
||||
fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`;
|
||||
const initialScreenAfterLogin = this.props.initialScreenAfterLogin;
|
||||
if (initialScreenAfterLogin &&
|
||||
// XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop
|
||||
!["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen)
|
||||
) {
|
||||
fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`;
|
||||
}
|
||||
return fragmentAfterLogin;
|
||||
}
|
||||
|
||||
render() {
|
||||
const fragmentAfterLogin = this.getFragmentAfterLogin();
|
||||
let view;
|
||||
|
||||
if (this.state.view === Views.LOADING) {
|
||||
@@ -2033,7 +1994,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
onCloseAllSettings={this.onCloseAllSettings}
|
||||
onRegistered={this.onRegistered}
|
||||
currentRoomId={this.state.currentRoomId}
|
||||
showCookieBar={this.state.showCookieBar}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -2057,7 +2017,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
} else if (this.state.view === Views.WELCOME) {
|
||||
const Welcome = sdk.getComponent('auth.Welcome');
|
||||
view = <Welcome {...this.getServerProperties()} fragmentAfterLogin={fragmentAfterLogin} />;
|
||||
view = <Welcome />;
|
||||
} else if (this.state.view === Views.REGISTER) {
|
||||
const Registration = sdk.getComponent('structures.auth.Registration');
|
||||
view = (
|
||||
|
||||
@@ -29,10 +29,35 @@ 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.
|
||||
@@ -107,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
|
||||
@@ -359,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>
|
||||
);
|
||||
@@ -510,39 +542,6 @@ export default class MessagePanel extends React.Component {
|
||||
|
||||
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 &&
|
||||
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
|
||||
haveTileForEvent(prevEvent) && (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.
|
||||
@@ -554,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);
|
||||
|
||||
@@ -597,6 +599,7 @@ export default class MessagePanel extends React.Component {
|
||||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
/>
|
||||
</TileErrorBoundary>
|
||||
</li>,
|
||||
@@ -792,6 +795,15 @@ 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 (
|
||||
<ErrorBoundary>
|
||||
<ScrollPanel
|
||||
@@ -804,6 +816,7 @@ export default class MessagePanel extends React.Component {
|
||||
style={style}
|
||||
stickyBottom={this.props.stickyBottom}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
fixedChildren={ircResizer}
|
||||
>
|
||||
{ topSpinner }
|
||||
{ this._getEventTiles() }
|
||||
|
||||
@@ -19,7 +19,7 @@ import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import AccessibleButton from '../views/elements/AccessibleButton';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
@@ -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() {
|
||||
@@ -188,16 +188,37 @@ export default class RightPanel extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -219,71 +240,25 @@ export default class RightPanel extends React.Component {
|
||||
break;
|
||||
case RIGHT_PANEL_PHASES.RoomMemberInfo:
|
||||
case RIGHT_PANEL_PHASES.EncryptionPanel:
|
||||
if (SettingsStore.getValue("feature_cross_signing")) {
|
||||
const onClose = () => {
|
||||
// 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, 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: "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}
|
||||
verificationRequestPromise={this.state.verificationRequestPromise}
|
||||
/>;
|
||||
} 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.getValue("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
|
||||
|
||||
@@ -20,7 +20,7 @@ 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';
|
||||
@@ -199,7 +199,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});
|
||||
}
|
||||
@@ -216,7 +216,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();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@ import { _t, _td } from '../../languageHandler';
|
||||
import * as sdk from '../../index';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import Resend from '../../Resend';
|
||||
import * as cryptodevices from '../../cryptodevices';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
@@ -126,25 +126,14 @@ export default createReactClass({
|
||||
});
|
||||
},
|
||||
|
||||
_onSendWithoutVerifyingClick: function() {
|
||||
cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => {
|
||||
cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices);
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
});
|
||||
},
|
||||
|
||||
_onResendAllClick: function() {
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
_onCancelAllClick: function() {
|
||||
Resend.cancelUnsentEvents(this.props.room);
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
},
|
||||
|
||||
_onShowDevicesClick: function() {
|
||||
cryptodevices.showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
|
||||
@@ -213,82 +202,65 @@ export default createReactClass({
|
||||
if (!unsentMessages.length) return null;
|
||||
|
||||
let title;
|
||||
let content;
|
||||
|
||||
const hasUDE = unsentMessages.some((m) => {
|
||||
return m.error && m.error.name === "UnknownDeviceError";
|
||||
});
|
||||
|
||||
if (hasUDE) {
|
||||
title = _t("Message not sent due to unknown sessions being present");
|
||||
content = _t(
|
||||
"<showSessionsText>Show sessions</showSessionsText>, <sendAnywayText>send anyway</sendAnywayText> or <cancelText>cancel</cancelText>.",
|
||||
let consentError = null;
|
||||
let resourceLimitError = null;
|
||||
for (const m of unsentMessages) {
|
||||
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
|
||||
consentError = m.error;
|
||||
break;
|
||||
} else if (m.error && m.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
resourceLimitError = m.error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (consentError) {
|
||||
title = _t(
|
||||
"You can't send any messages until you review and agree to " +
|
||||
"<consentLink>our terms and conditions</consentLink>.",
|
||||
{},
|
||||
{
|
||||
'showSessionsText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
|
||||
'sendAnywayText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="sendAnyway" onClick={this._onSendWithoutVerifyingClick}>{ sub }</a>,
|
||||
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
|
||||
'consentLink': (sub) =>
|
||||
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
);
|
||||
} else if (resourceLimitError) {
|
||||
title = messageForResourceLimitError(
|
||||
resourceLimitError.data.limit_type,
|
||||
resourceLimitError.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'': _td(
|
||||
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
});
|
||||
} else if (
|
||||
unsentMessages.length === 1 &&
|
||||
unsentMessages[0].error &&
|
||||
unsentMessages[0].error.data &&
|
||||
unsentMessages[0].error.data.error
|
||||
) {
|
||||
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
|
||||
} else {
|
||||
let consentError = null;
|
||||
let resourceLimitError = null;
|
||||
for (const m of unsentMessages) {
|
||||
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
|
||||
consentError = m.error;
|
||||
break;
|
||||
} else if (m.error && m.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
resourceLimitError = m.error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (consentError) {
|
||||
title = _t(
|
||||
"You can't send any messages until you review and agree to " +
|
||||
"<consentLink>our terms and conditions</consentLink>.",
|
||||
{},
|
||||
{
|
||||
'consentLink': (sub) =>
|
||||
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
);
|
||||
} else if (resourceLimitError) {
|
||||
title = messageForResourceLimitError(
|
||||
resourceLimitError.data.limit_type,
|
||||
resourceLimitError.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'': _td(
|
||||
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
});
|
||||
} else if (
|
||||
unsentMessages.length === 1 &&
|
||||
unsentMessages[0].error &&
|
||||
unsentMessages[0].error.data &&
|
||||
unsentMessages[0].error.data.error
|
||||
) {
|
||||
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
|
||||
} else {
|
||||
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
|
||||
}
|
||||
content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
|
||||
"You can also select individual messages to resend or cancel.",
|
||||
{ count: unsentMessages.length },
|
||||
{
|
||||
'resendText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
|
||||
'cancelText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
|
||||
},
|
||||
);
|
||||
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
|
||||
}
|
||||
|
||||
const content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> " +
|
||||
"now. You can also select individual messages to resend or cancel.",
|
||||
{ count: unsentMessages.length },
|
||||
{
|
||||
'resendText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
|
||||
'cancelText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
|
||||
},
|
||||
);
|
||||
|
||||
return <div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
|
||||
<div>
|
||||
|
||||
@@ -20,7 +20,7 @@ limitations under the License.
|
||||
import React, {createRef} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import * as Unread from '../../Unread';
|
||||
import * as RoomNotifs from '../../RoomNotifs';
|
||||
import * as FormattingUtils from '../../utils/FormattingUtils';
|
||||
@@ -32,7 +32,7 @@ import RoomTile from "../views/rooms/RoomTile";
|
||||
import LazyRenderList from "../views/elements/LazyRenderList";
|
||||
import {_t} from "../../languageHandler";
|
||||
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
|
||||
import toRem from "../../utils/rem";
|
||||
import {toPx} from "../../utils/units";
|
||||
|
||||
// turn this on for drop & drag console debugging galore
|
||||
const debug = false;
|
||||
@@ -420,7 +420,7 @@ export default class RoomSubList extends React.PureComponent {
|
||||
|
||||
setHeight = (height) => {
|
||||
if (this._subList.current) {
|
||||
this._subList.current.style.height = toRem(height);
|
||||
this._subList.current.style.height = toPx(height);
|
||||
}
|
||||
this._updateLazyRenderHeight(height);
|
||||
};
|
||||
|
||||
@@ -34,12 +34,12 @@ import ContentMessages from '../../ContentMessages';
|
||||
import Modal from '../../Modal';
|
||||
import * as sdk from '../../index';
|
||||
import CallHandler from '../../CallHandler';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import Tinter from '../../Tinter';
|
||||
import rate_limited_func from '../../ratelimitedfunc';
|
||||
import * as ObjectUtils from '../../ObjectUtils';
|
||||
import * as Rooms from '../../Rooms';
|
||||
import eventSearch from '../../Searching';
|
||||
import eventSearch, {searchPagination} from '../../Searching';
|
||||
|
||||
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
|
||||
|
||||
@@ -55,6 +55,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import RoomContext from "../../contexts/RoomContext";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { shieldStatusForRoom } from '../../utils/ShieldUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function() {};
|
||||
@@ -164,6 +165,10 @@ export default createReactClass({
|
||||
|
||||
canReact: false,
|
||||
canReply: false,
|
||||
|
||||
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
|
||||
|
||||
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -193,6 +198,8 @@ export default createReactClass({
|
||||
|
||||
this._roomView = createRef();
|
||||
this._searchResultsPanel = createRef();
|
||||
|
||||
this._layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
|
||||
},
|
||||
|
||||
_onReadReceiptsChange: function() {
|
||||
@@ -232,7 +239,8 @@ export default createReactClass({
|
||||
initialEventId: RoomViewStore.getInitialEventId(),
|
||||
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
|
||||
forwardingEvent: RoomViewStore.getForwardingEvent(),
|
||||
shouldPeek: RoomViewStore.shouldPeek(),
|
||||
// we should only peek once we have a ready client
|
||||
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
|
||||
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
|
||||
};
|
||||
@@ -532,6 +540,14 @@ export default createReactClass({
|
||||
// no need to do this as Dir & Settings are now overlays. It just burnt CPU.
|
||||
// console.log("Tinter.tint from RoomView.unmount");
|
||||
// Tinter.tint(); // reset colourscheme
|
||||
|
||||
SettingsStore.unwatchSetting(this._layoutWatcherRef);
|
||||
},
|
||||
|
||||
onLayoutChange: function() {
|
||||
this.setState({
|
||||
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
|
||||
});
|
||||
},
|
||||
|
||||
_onRightPanelStoreUpdate: function() {
|
||||
@@ -681,6 +697,16 @@ export default createReactClass({
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'sync_state':
|
||||
if (!this.state.matrixClientIsReady) {
|
||||
this.setState({
|
||||
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
|
||||
}, () => {
|
||||
// send another "initial" RVS update to trigger peeking if needed
|
||||
this._onRoomViewStoreUpdate(true);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -854,15 +880,6 @@ export default createReactClass({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!SettingsStore.getValue("feature_cross_signing")) {
|
||||
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
|
||||
this.setState({
|
||||
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
|
||||
});
|
||||
});
|
||||
debuglog("e2e check is warning/verified only as cross-signing is off");
|
||||
return;
|
||||
}
|
||||
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
this.setState({
|
||||
@@ -1019,8 +1036,7 @@ export default createReactClass({
|
||||
|
||||
if (this.state.searchResults.next_batch) {
|
||||
debuglog("requesting more search results");
|
||||
const searchPromise = this.context.backPaginateRoomEventsSearch(
|
||||
this.state.searchResults);
|
||||
const searchPromise = searchPagination(this.state.searchResults);
|
||||
return this._handleSearchResult(searchPromise);
|
||||
} else {
|
||||
debuglog("no more search results");
|
||||
@@ -1146,7 +1162,7 @@ export default createReactClass({
|
||||
ev.dataTransfer.files, this.state.room.roomId, this.context,
|
||||
);
|
||||
this.setState({ draggingFile: false });
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
onDragLeaveOrEnd: function(ev) {
|
||||
@@ -1297,6 +1313,14 @@ export default createReactClass({
|
||||
const mxEv = result.context.getEvent();
|
||||
const roomId = mxEv.getRoomId();
|
||||
const room = this.context.getRoom(roomId);
|
||||
if (!room) {
|
||||
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
|
||||
// As per the spec, an all rooms search can create this condition,
|
||||
// it happens with Seshat but not Synapse.
|
||||
// It will make the result count not match the displayed count.
|
||||
console.log("Hiding search result from an unknown room", roomId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
@@ -1305,16 +1329,9 @@ export default createReactClass({
|
||||
}
|
||||
|
||||
if (this.state.searchScope === 'All') {
|
||||
if (roomId != lastRoomId) {
|
||||
|
||||
// XXX: if we've left the room, we might not know about
|
||||
// it. We should tell the js sdk to go and find out about
|
||||
// it. But that's not an issue currently, as synapse only
|
||||
// returns results for rooms we're joined to.
|
||||
const roomName = room ? room.name : _t("Unknown room %(roomId)s", { roomId: roomId });
|
||||
|
||||
if (roomId !== lastRoomId) {
|
||||
ret.push(<li key={mxEv.getId() + "-room"}>
|
||||
<h2>{ _t("Room") }: { roomName }</h2>
|
||||
<h2>{ _t("Room") }: { room.name }</h2>
|
||||
</li>);
|
||||
lastRoomId = roomId;
|
||||
}
|
||||
@@ -1352,7 +1369,7 @@ export default createReactClass({
|
||||
event: null,
|
||||
});
|
||||
}
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
onLeaveClick: function() {
|
||||
@@ -1363,15 +1380,9 @@ export default createReactClass({
|
||||
},
|
||||
|
||||
onForgetClick: function() {
|
||||
this.context.forget(this.state.room.roomId).then(function() {
|
||||
dis.dispatch({ action: 'view_next_room' });
|
||||
}, function(err) {
|
||||
const errCode = err.errcode || _t("unknown error code");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Failed to forget room %(errCode)s", { errCode: errCode }),
|
||||
});
|
||||
dis.dispatch({
|
||||
action: 'forget_room',
|
||||
room_id: this.state.room.roomId,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1441,9 +1452,7 @@ export default createReactClass({
|
||||
// using /leave rather than /join. In the short term though, we
|
||||
// just ignore them.
|
||||
// https://github.com/vector-im/vector-web/issues/1134
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
},
|
||||
|
||||
onSearchClick: function() {
|
||||
@@ -1463,7 +1472,7 @@ export default createReactClass({
|
||||
// jump down to the bottom of this room, where new events are arriving
|
||||
jumpToLiveTimeline: function() {
|
||||
this._messagePanel.jumpToLiveTimeline();
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
// jump up to wherever our read marker is
|
||||
@@ -1663,14 +1672,16 @@ export default createReactClass({
|
||||
const ErrorBoundary = sdk.getComponent("elements.ErrorBoundary");
|
||||
|
||||
if (!this.state.room) {
|
||||
const loading = this.state.roomLoading || this.state.peekLoading;
|
||||
const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading;
|
||||
if (loading) {
|
||||
// Assume preview loading if we don't have a ready client or a room ID (still resolving the alias)
|
||||
const previewLoading = !this.state.matrixClientIsReady || !this.state.roomId || this.state.peekLoading;
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<ErrorBoundary>
|
||||
<RoomPreviewBar
|
||||
canPreview={false}
|
||||
previewLoading={this.state.peekLoading}
|
||||
previewLoading={previewLoading && !this.state.roomLoadError}
|
||||
error={this.state.roomLoadError}
|
||||
loading={loading}
|
||||
joining={this.state.joining}
|
||||
@@ -1695,7 +1706,8 @@ export default createReactClass({
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<ErrorBoundary>
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
<RoomPreviewBar
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false} error={this.state.roomLoadError}
|
||||
@@ -1801,6 +1813,7 @@ export default createReactClass({
|
||||
);
|
||||
|
||||
const showRoomRecoveryReminder = (
|
||||
this.context.isCryptoEnabled() &&
|
||||
SettingsStore.getValue("showRoomRecoveryReminder") &&
|
||||
this.context.isRoomEncrypted(this.state.room.roomId) &&
|
||||
this.context.getKeyBackupEnabled() === false
|
||||
@@ -1959,8 +1972,9 @@ export default createReactClass({
|
||||
searchResultsPanel = (<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />);
|
||||
} else {
|
||||
searchResultsPanel = (
|
||||
<ScrollPanel ref={this._searchResultsPanel}
|
||||
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
|
||||
<ScrollPanel
|
||||
ref={this._searchResultsPanel}
|
||||
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel mx_GroupLayout"
|
||||
onFillRequest={this.onSearchResultsFillRequest}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
@@ -1980,6 +1994,13 @@ export default createReactClass({
|
||||
highlightedEventId = this.state.initialEventId;
|
||||
}
|
||||
|
||||
const messagePanelClassNames = classNames(
|
||||
"mx_RoomView_messagePanel",
|
||||
{
|
||||
"mx_IRCLayout": this.state.useIRCLayout,
|
||||
"mx_GroupLayout": !this.state.useIRCLayout,
|
||||
});
|
||||
|
||||
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
|
||||
const messagePanel = (
|
||||
<TimelinePanel
|
||||
@@ -1995,11 +2016,12 @@ export default createReactClass({
|
||||
onScroll={this.onMessageListScroll}
|
||||
onReadMarkerUpdated={this._updateTopUnreadMessagesBar}
|
||||
showUrlPreview = {this.state.showUrlPreview}
|
||||
className="mx_RoomView_messagePanel"
|
||||
className={messagePanelClassNames}
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
showReactions={true}
|
||||
useIRCLayout={this.state.useIRCLayout}
|
||||
/>);
|
||||
|
||||
let topUnreadMessagesBar = null;
|
||||
@@ -2016,6 +2038,7 @@ export default createReactClass({
|
||||
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
|
||||
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
|
||||
jumpToBottom = (<JumpToBottomButton
|
||||
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
|
||||
numUnreadMessages={this.state.numUnreadMessages}
|
||||
onScrollToBottomClick={this.jumpToLiveTimeline}
|
||||
/>);
|
||||
|
||||
@@ -144,6 +144,11 @@ export default createReactClass({
|
||||
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
|
||||
*/
|
||||
resizeNotifier: PropTypes.object,
|
||||
|
||||
/* fixedChildren: allows for children to be passed which are rendered outside
|
||||
* of the wrapper
|
||||
*/
|
||||
fixedChildren: PropTypes.node,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
@@ -881,6 +886,7 @@ export default createReactClass({
|
||||
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
|
||||
onScroll={this.onScroll}
|
||||
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
|
||||
{ this.props.fixedChildren }
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
|
||||
{ this.props.children }
|
||||
|
||||
@@ -19,7 +19,7 @@ import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Key } from '../../Keyboard';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { throttle } from 'lodash';
|
||||
import AccessibleButton from '../../components/views/elements/AccessibleButton';
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -27,25 +27,20 @@ import { ReactNode } from "react";
|
||||
* Represents a tab for the TabbedView.
|
||||
*/
|
||||
export class Tab {
|
||||
public label: string;
|
||||
public icon: string;
|
||||
public body: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Creates a new tab.
|
||||
* @param {string} tabLabel The untranslated tab label.
|
||||
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} tabJsx The JSX for the tab container.
|
||||
* @param {string} id The tab's ID.
|
||||
* @param {string} label The untranslated tab label.
|
||||
* @param {string} icon The class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} body The JSX for the tab container.
|
||||
*/
|
||||
constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) {
|
||||
this.label = tabLabel;
|
||||
this.icon = tabIconClass;
|
||||
this.body = tabJsx;
|
||||
constructor(public id: string, public label: string, public icon: string, public body: React.ReactNode) {
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
tabs: Tab[];
|
||||
initialTabId?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -53,16 +48,17 @@ interface IState {
|
||||
}
|
||||
|
||||
export default class TabbedView extends React.Component<IProps, IState> {
|
||||
static propTypes = {
|
||||
// The tabs to show
|
||||
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
let activeTabIndex = 0;
|
||||
if (props.initialTabId) {
|
||||
const tabIndex = props.tabs.findIndex(t => t.id === props.initialTabId);
|
||||
if (tabIndex >= 0) activeTabIndex = tabIndex;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
activeTabIndex: 0,
|
||||
activeTabIndex,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import TagOrderStore from '../../stores/TagOrderStore';
|
||||
import GroupActions from '../../actions/GroupActions';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
import { Droppable } from 'react-beautiful-dnd';
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
Copyright 2019 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import Modal from '../../Modal';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
const TagPanelButtons = createReactClass({
|
||||
displayName: 'TagPanelButtons',
|
||||
|
||||
|
||||
componentDidMount: function() {
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._dispatcherRef) {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
this._dispatcherRef = null;
|
||||
}
|
||||
},
|
||||
|
||||
_onAction(payload) {
|
||||
if (payload.action === "show_redesign_feedback_dialog") {
|
||||
const RedesignFeedbackDialog =
|
||||
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
const GroupsButton = sdk.getComponent('elements.GroupsButton');
|
||||
const ActionButton = sdk.getComponent("elements.ActionButton");
|
||||
|
||||
return (<div className="mx_TagPanelButtons">
|
||||
<GroupsButton />
|
||||
<ActionButton
|
||||
className="mx_TagPanelButtons_report" action="show_redesign_feedback_dialog"
|
||||
label={_t("Report bugs & give feedback")} tooltip={true} />
|
||||
</div>);
|
||||
},
|
||||
});
|
||||
export default TagPanelButtons;
|
||||
@@ -29,7 +29,7 @@ import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import * as ObjectUtils from "../../ObjectUtils";
|
||||
import UserActivity from "../../UserActivity";
|
||||
import Modal from "../../Modal";
|
||||
import dis from "../../dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import * as sdk from "../../index";
|
||||
import { Key } from '../../Keyboard';
|
||||
import Timer from '../../utils/Timer';
|
||||
@@ -112,6 +112,9 @@ const TimelinePanel = createReactClass({
|
||||
|
||||
// whether to show reactions for an event
|
||||
showReactions: PropTypes.bool,
|
||||
|
||||
// whether to use the irc layout
|
||||
useIRCLayout: PropTypes.bool,
|
||||
},
|
||||
|
||||
statics: {
|
||||
@@ -795,6 +798,9 @@ const TimelinePanel = createReactClass({
|
||||
readMarkerVisible: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Send the updated read marker (along with read receipt) to the server
|
||||
this.sendReadReceipt();
|
||||
},
|
||||
|
||||
|
||||
@@ -1447,6 +1453,7 @@ const TimelinePanel = createReactClass({
|
||||
getRelationsForEvent={this.getRelationsForEvent}
|
||||
editState={this.state.editState}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -15,14 +15,21 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { _t } from '../../languageHandler';
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
import ToastStore, {IToast} from "../../stores/ToastStore";
|
||||
import classNames from "classnames";
|
||||
|
||||
export default class ToastContainer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {toasts: ToastStore.sharedInstance().getToasts()};
|
||||
interface IState {
|
||||
toasts: IToast<any>[];
|
||||
countSeen: number;
|
||||
}
|
||||
|
||||
export default class ToastContainer extends React.Component<{}, IState> {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
countSeen: ToastStore.sharedInstance().getCountSeen(),
|
||||
};
|
||||
|
||||
// Start listening here rather than in componentDidMount because
|
||||
// toasts may dismiss themselves in their didMount if they find
|
||||
@@ -36,7 +43,10 @@ export default class ToastContainer extends React.Component {
|
||||
}
|
||||
|
||||
_onToastStoreUpdate = () => {
|
||||
this.setState({toasts: ToastStore.sharedInstance().getToasts()});
|
||||
this.setState({
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
countSeen: ToastStore.sharedInstance().getCountSeen(),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -50,14 +60,21 @@ export default class ToastContainer extends React.Component {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
});
|
||||
const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null;
|
||||
|
||||
let countIndicator;
|
||||
if (isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
const toastProps = Object.assign({}, props, {
|
||||
key,
|
||||
toastKey: key,
|
||||
});
|
||||
toast = (<div className={toastClasses}>
|
||||
<h2>{title}{countIndicator}</h2>
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{title}</h2>
|
||||
<span>{countIndicator}</span>
|
||||
</div>
|
||||
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -22,8 +22,9 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import * as Avatar from '../../Avatar';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from "../../dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const AVATAR_SIZE = 28;
|
||||
|
||||
@@ -75,7 +76,7 @@ export default class TopLeftMenuButton extends React.Component {
|
||||
|
||||
onAction = (payload) => {
|
||||
// For accessibility
|
||||
if (payload.action === "toggle_top_left_menu") {
|
||||
if (payload.action === Action.ToggleUserMenu) {
|
||||
if (this._buttonRef) this._buttonRef.click();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
import dis from "../../dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import filesize from "filesize";
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
|
||||
356
src/components/structures/UserMenu.tsx
Normal file
356
src/components/structures/UserMenu.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
|
||||
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||
import Modal from "../../Modal";
|
||||
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
||||
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||
import {getCustomTheme} from "../../theme";
|
||||
import {getHostingLink} from "../../utils/HostingLink";
|
||||
import {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import {getHomePageUrl} from "../../utils/pages";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import classNames from "classnames";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
contextMenuPosition: PartialDOMRect;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
interface IMenuButtonProps {
|
||||
iconClassName: string;
|
||||
label: string;
|
||||
onClick(ev: ButtonEvent);
|
||||
}
|
||||
|
||||
const MenuButton: React.FC<IMenuButtonProps> = ({iconClassName, label, onClick}) => {
|
||||
return <MenuItem label={label} onClick={onClick}>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{label}</span>
|
||||
</MenuItem>;
|
||||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
contextMenuPosition: null,
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
}
|
||||
|
||||
private get hasHomePage(): boolean {
|
||||
return !!getHomePageUrl(SdkConfig.get());
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
|
||||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
}
|
||||
|
||||
private isUserOnDarkTheme(): boolean {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
}
|
||||
return theme === "dark";
|
||||
}
|
||||
|
||||
private onProfileUpdate = async () => {
|
||||
// the store triggered an update, so force a layout update. We don't
|
||||
// have any state to store here for that to magically happen.
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onThemeChanged = () => {
|
||||
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
|
||||
};
|
||||
|
||||
private onAction = (ev: ActionPayload) => {
|
||||
if (ev.action !== Action.ToggleUserMenu) return; // not interested
|
||||
|
||||
if (this.state.contextMenuPosition) {
|
||||
this.setState({contextMenuPosition: null});
|
||||
} else {
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({contextMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
contextMenuPosition: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
width: 20,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseMenu = () => {
|
||||
this.setState({contextMenuPosition: null});
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// Disable system theme matching if the user hits this button
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||
|
||||
const newTheme = this.state.isDarkTheme ? "light" : "dark";
|
||||
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
|
||||
};
|
||||
|
||||
private onSettingsOpen = (ev: ButtonEvent, tabId: string) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onShowArchived = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// TODO: Archived room view: https://github.com/vector-im/riot-web/issues/14038
|
||||
// Note: You'll need to uncomment the button too.
|
||||
console.log("TODO: Show archived rooms");
|
||||
};
|
||||
|
||||
private onProvideFeedback = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onHomeClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({action: 'view_home_page'});
|
||||
};
|
||||
|
||||
private renderContextMenu = (): React.ReactNode => {
|
||||
if (!this.state.contextMenuPosition) return null;
|
||||
|
||||
let hostingLink;
|
||||
const signupLink = getHostingLink("user-context-menu");
|
||||
if (signupLink) {
|
||||
hostingLink = (
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
{_t(
|
||||
"<a>Upgrade</a> to your own domain", {},
|
||||
{
|
||||
a: sub => (
|
||||
<a
|
||||
href={signupLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
tabIndex={-1}
|
||||
>{sub}</a>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let homeButton = null;
|
||||
if (this.hasHomePage) {
|
||||
homeButton = (
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconHome"
|
||||
label={_t("Home")}
|
||||
onClick={this.onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
|
||||
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{OwnProfileStore.instance.displayName}
|
||||
</span>
|
||||
<span className="mx_UserMenu_contextMenu_userId">
|
||||
{MatrixClientPeg.get().getUserId()}
|
||||
</span>
|
||||
</div>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
|
||||
>
|
||||
<img
|
||||
src={require("../../../res/img/feather-customised/sun.svg")}
|
||||
alt={_t("Switch theme")}
|
||||
width={16}
|
||||
/>
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{hostingLink}
|
||||
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
|
||||
{homeButton}
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("Security & privacy")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{/* <MenuButton
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/> */}
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("Feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const avatarSize = 32; // should match border-radius of the avatar
|
||||
|
||||
let name = <span className="mx_UserMenu_userName">{OwnProfileStore.instance.displayName}</span>;
|
||||
let buttons = (
|
||||
<span className="mx_UserMenu_headerButtons">
|
||||
{/* masked image in CSS */}
|
||||
</span>
|
||||
);
|
||||
if (this.props.isMinimized) {
|
||||
name = null;
|
||||
buttons = null;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
'mx_UserMenu': true,
|
||||
'mx_UserMenu_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className={classes}
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.buttonRef}
|
||||
label={_t("User menu")}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<div className="mx_UserMenu_row">
|
||||
<span className="mx_UserMenu_userAvatarContainer">
|
||||
<BaseAvatar
|
||||
idName={MatrixClientPeg.get().getUserId()}
|
||||
name={OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId()}
|
||||
url={OwnProfileStore.instance.getHttpAvatarUrl(avatarSize)}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
resizeMethod="crop"
|
||||
className="mx_UserMenu_userAvatar"
|
||||
/>
|
||||
</span>
|
||||
{name}
|
||||
{buttons}
|
||||
</div>
|
||||
</ContextMenuButton>
|
||||
{this.renderContextMenu()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import * as Lifecycle from '../../../Lifecycle';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import Login from "../../../Login";
|
||||
import dis from "../../../dispatcher";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
@@ -247,9 +247,8 @@ export default createReactClass({
|
||||
// do SSO instead. If we've already started the UI Auth process though, we don't
|
||||
// need to.
|
||||
if (!this.state.doingUIAuth) {
|
||||
await this._makeRegisterRequest({});
|
||||
// This should never succeed since we specified an empty
|
||||
// auth object.
|
||||
await this._makeRegisterRequest(null);
|
||||
// This should never succeed since we specified no auth object.
|
||||
console.log("Expecting 401 from register request but got success!");
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -379,7 +378,7 @@ export default createReactClass({
|
||||
}
|
||||
|
||||
if (response.access_token) {
|
||||
const cli = await this.props.onLoggedIn({
|
||||
await this.props.onLoggedIn({
|
||||
userId: response.user_id,
|
||||
deviceId: response.device_id,
|
||||
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
|
||||
@@ -387,7 +386,7 @@ export default createReactClass({
|
||||
accessToken: response.access_token,
|
||||
}, this.state.formVals.password);
|
||||
|
||||
this._setupPushers(cli);
|
||||
this._setupPushers();
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
newState.busy = true;
|
||||
} else {
|
||||
@@ -398,10 +397,11 @@ export default createReactClass({
|
||||
this.setState(newState);
|
||||
},
|
||||
|
||||
_setupPushers: function(matrixClient) {
|
||||
_setupPushers: function() {
|
||||
if (!this.props.brand) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
return matrixClient.getPushers().then((resp)=>{
|
||||
const pushers = resp.pushers;
|
||||
for (let i = 0; i < pushers.length; ++i) {
|
||||
|
||||
@@ -28,6 +28,14 @@ import {
|
||||
PHASE_FINISHED,
|
||||
} from '../../../stores/SetupEncryptionStore';
|
||||
|
||||
function keyHasPassphrase(keyInfo) {
|
||||
return (
|
||||
keyInfo.passphrase &&
|
||||
keyInfo.passphrase.salt &&
|
||||
keyInfo.passphrase.iterations
|
||||
);
|
||||
}
|
||||
|
||||
export default class SetupEncryptionBody extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
@@ -108,6 +116,21 @@ export default class SetupEncryptionBody extends React.Component {
|
||||
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
|
||||
/>;
|
||||
} else if (phase === PHASE_INTRO) {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
let recoveryKeyPrompt;
|
||||
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
|
||||
recoveryKeyPrompt = _t("Use Recovery Key or Passphrase");
|
||||
} else if (store.keyInfo) {
|
||||
recoveryKeyPrompt = _t("Use Recovery Key");
|
||||
}
|
||||
|
||||
let useRecoveryKeyButton;
|
||||
if (recoveryKeyPrompt) {
|
||||
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this._onUsePassphraseClick}>
|
||||
{recoveryKeyPrompt}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
@@ -131,9 +154,7 @@ export default class SetupEncryptionBody extends React.Component {
|
||||
</div>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="link" onClick={this._onUsePassphraseClick}>
|
||||
{_t("Use Recovery Passphrase or Key")}
|
||||
</AccessibleButton>
|
||||
{useRecoveryKeyButton}
|
||||
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
|
||||
{_t("Skip")}
|
||||
</AccessibleButton>
|
||||
|
||||
@@ -18,13 +18,14 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import Modal from '../../../Modal';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {sendLoginRequest} from "../../../Login";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import SSOButton from "../../views/elements/SSOButton";
|
||||
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
|
||||
|
||||
const LOGIN_VIEW = {
|
||||
LOADING: 1,
|
||||
@@ -43,7 +44,7 @@ const FLOWS_TO_VIEWS = {
|
||||
export default class SoftLogout extends React.Component {
|
||||
static propTypes = {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: PropTypes.object, // {homeserver, identityServer, loginToken}
|
||||
realQueryParams: PropTypes.object, // {loginToken}
|
||||
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: PropTypes.func,
|
||||
@@ -90,7 +91,7 @@ export default class SoftLogout extends React.Component {
|
||||
|
||||
async _initLogin() {
|
||||
const queryParams = this.props.realQueryParams;
|
||||
const hasAllParams = queryParams && queryParams['homeserver'] && queryParams['loginToken'];
|
||||
const hasAllParams = queryParams && queryParams['loginToken'];
|
||||
if (hasAllParams) {
|
||||
this.setState({loginView: LOGIN_VIEW.LOADING});
|
||||
this.trySsoLogin();
|
||||
@@ -157,8 +158,8 @@ export default class SoftLogout extends React.Component {
|
||||
async trySsoLogin() {
|
||||
this.setState({busy: true});
|
||||
|
||||
const hsUrl = this.props.realQueryParams['homeserver'];
|
||||
const isUrl = this.props.realQueryParams['identityServer'] || MatrixClientPeg.get().getIdentityServerUrl();
|
||||
const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
|
||||
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
|
||||
const loginType = "m.login.token";
|
||||
const loginParams = {
|
||||
token: this.props.realQueryParams['loginToken'],
|
||||
|
||||
@@ -355,6 +355,7 @@ export const TermsAuthEntry = createReactClass({
|
||||
allChecked = allChecked && checked;
|
||||
|
||||
checkboxes.push(
|
||||
// XXX: replace with StyledCheckbox
|
||||
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
|
||||
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} />
|
||||
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
|
||||
@@ -538,6 +539,7 @@ export const MsisdnAuthEntry = createReactClass({
|
||||
type: MsisdnAuthEntry.LOGIN_TYPE,
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/riot-web/issues/10312
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
threepid_creds: creds,
|
||||
threepidCreds: creds,
|
||||
});
|
||||
|
||||
@@ -23,7 +23,8 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import ServerConfig from "./ServerConfig";
|
||||
|
||||
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
const MODULAR_URL = 'https://modular.im/services/matrix-hosting-riot' +
|
||||
'?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
|
||||
// TODO: TravisR - Can this extend ServerConfig for most things?
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ interface IProps {
|
||||
labelStrongPassword?: string;
|
||||
labelAllowedButUnsafe?: string;
|
||||
|
||||
onChange(ev: KeyboardEvent);
|
||||
onChange(ev: React.FormEvent<HTMLElement>);
|
||||
onValidate(result: IValidationResult);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class PassphraseField extends PureComponent<IProps, IState> {
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
onValidate={this.onValidate}
|
||||
/>
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ export default class PasswordLogin extends React.Component {
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.state.phoneNumber}
|
||||
prefix={phoneCountry}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
|
||||
@@ -473,7 +473,7 @@ export default createReactClass({
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
value={this.state.phoneNumber}
|
||||
prefix={phoneCountry}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChange}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
/>;
|
||||
|
||||
@@ -22,7 +22,8 @@ import classnames from 'classnames';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import {makeType} from "../../../utils/TypeUtils";
|
||||
|
||||
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
const MODULAR_URL = 'https://modular.im/services/matrix-hosting-riot' +
|
||||
'?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
|
||||
export const FREE = 'Free';
|
||||
export const PREMIUM = 'Premium';
|
||||
|
||||
@@ -18,9 +18,7 @@ import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import AuthPage from "./AuthPage";
|
||||
import * as Matrix from "matrix-js-sdk";
|
||||
import {_td} from "../../../languageHandler";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
|
||||
// translatable strings for Welcome pages
|
||||
_td("Sign in with SSO");
|
||||
@@ -39,15 +37,6 @@ export default class Welcome extends React.PureComponent {
|
||||
pageUrl = 'welcome.html';
|
||||
}
|
||||
|
||||
const {hsUrl, isUrl} = this.props.serverConfig;
|
||||
const tmpClient = Matrix.createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
});
|
||||
const plaf = PlatformPeg.get();
|
||||
const callbackUrl = plaf.getSSOCallbackUrl(tmpClient.getHomeserverUrl(), tmpClient.getIdentityServerUrl(),
|
||||
this.props.fragmentAfterLogin);
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<div className="mx_Welcome">
|
||||
@@ -55,8 +44,8 @@ export default class Welcome extends React.PureComponent {
|
||||
className="mx_WelcomePage"
|
||||
url={pageUrl}
|
||||
replaceMap={{
|
||||
"$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"),
|
||||
"$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"),
|
||||
"$riot:ssoUrl": "#/start_sso",
|
||||
"$riot:casUrl": "#/start_cas",
|
||||
}}
|
||||
/>
|
||||
<LanguageSelector />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user