Merge remote-tracking branch 'upstream/develop' into feature_confetti#14676
This commit is contained in:
commit
c86964cd5e
478 changed files with 21997 additions and 13673 deletions
16
src/@types/global.d.ts
vendored
16
src/@types/global.d.ts
vendored
|
@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
||||
import * as ModernizrStatic from "modernizr";
|
||||
import ContentMessages from "../ContentMessages";
|
||||
import { IMatrixClientPeg } from "../MatrixClientPeg";
|
||||
|
@ -27,10 +28,17 @@ import {ModalManager} from "../Modal";
|
|||
import SettingsStore from "../settings/SettingsStore";
|
||||
import {ActiveRoomObserver} from "../ActiveRoomObserver";
|
||||
import {Notifier} from "../Notifier";
|
||||
import type {Renderer} from "react-dom";
|
||||
import RightPanelStore from "../stores/RightPanelStore";
|
||||
import WidgetStore from "../stores/WidgetStore";
|
||||
import CallHandler from "../CallHandler";
|
||||
import {Analytics} from "../Analytics";
|
||||
import UserActivity from "../UserActivity";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Modernizr: ModernizrStatic;
|
||||
matrixChat: ReturnType<Renderer>;
|
||||
mxMatrixClientPeg: IMatrixClientPeg;
|
||||
Olm: {
|
||||
init: () => Promise<void>;
|
||||
|
@ -47,6 +55,11 @@ declare global {
|
|||
singletonModalManager: ModalManager;
|
||||
mxSettingsStore: SettingsStore;
|
||||
mxNotifier: typeof Notifier;
|
||||
mxRightPanelStore: RightPanelStore;
|
||||
mxWidgetStore: WidgetStore;
|
||||
mxCallHandler: CallHandler;
|
||||
mxAnalytics: Analytics;
|
||||
mxUserActivity: UserActivity;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
|
@ -56,6 +69,9 @@ declare global {
|
|||
|
||||
interface Navigator {
|
||||
userLanguage?: string;
|
||||
// https://github.com/Microsoft/TypeScript/issues/19473
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/MediaSession
|
||||
mediaSession: any;
|
||||
}
|
||||
|
||||
interface StorageEstimate {
|
||||
|
|
23
src/@types/sanitize-html.ts
Normal file
23
src/@types/sanitize-html.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
export interface IExtendedSanitizeOptions extends sanitizeHtml.IOptions {
|
||||
// This option only exists in 2.x RCs so far, so not yet present in the
|
||||
// separate type definition module.
|
||||
nestingLimit?: number;
|
||||
}
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { getCurrentLanguage, _t, _td } from './languageHandler';
|
||||
import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import Modal from './Modal';
|
||||
|
@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password
|
|||
const hashVarRegex = /#\/(group|room|user)\/.*$/;
|
||||
|
||||
// Remove all but the first item in the hash path. Redact unexpected hashes.
|
||||
function getRedactedHash(hash) {
|
||||
function getRedactedHash(hash: string): string {
|
||||
// Don't leak URLs we aren't expecting - they could contain tokens/PII
|
||||
const match = hashRegex.exec(hash);
|
||||
if (!match) {
|
||||
|
@ -44,7 +44,7 @@ function getRedactedHash(hash) {
|
|||
|
||||
// Return the current origin, path and hash separated with a `/`. This does
|
||||
// not include query parameters.
|
||||
function getRedactedUrl() {
|
||||
function getRedactedUrl(): string {
|
||||
const { origin, hash } = window.location;
|
||||
let { pathname } = window.location;
|
||||
|
||||
|
@ -56,7 +56,25 @@ function getRedactedUrl() {
|
|||
return origin + pathname + getRedactedHash(hash);
|
||||
}
|
||||
|
||||
const customVariables = {
|
||||
interface IData {
|
||||
/* eslint-disable camelcase */
|
||||
gt_ms?: string;
|
||||
e_c?: string;
|
||||
e_a?: string;
|
||||
e_n?: string;
|
||||
e_v?: string;
|
||||
ping?: string;
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
|
||||
interface IVariable {
|
||||
id: number;
|
||||
expl: string; // explanation
|
||||
example: string; // example value
|
||||
getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t`
|
||||
}
|
||||
|
||||
const customVariables: Record<string, IVariable> = {
|
||||
// The Matomo installation at https://matomo.riot.im is currently configured
|
||||
// with a limit of 10 custom variables.
|
||||
'App Platform': {
|
||||
|
@ -120,7 +138,7 @@ const customVariables = {
|
|||
},
|
||||
};
|
||||
|
||||
function whitelistRedact(whitelist, str) {
|
||||
function whitelistRedact(whitelist: string[], str: string): string {
|
||||
if (whitelist.includes(str)) return str;
|
||||
return '<redacted>';
|
||||
}
|
||||
|
@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
|
|||
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
|
||||
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
|
||||
|
||||
function getUid() {
|
||||
function getUid(): string {
|
||||
try {
|
||||
let data = localStorage && localStorage.getItem(UID_KEY);
|
||||
if (!data && localStorage) {
|
||||
|
@ -145,97 +163,105 @@ function getUid() {
|
|||
|
||||
const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
|
||||
|
||||
class Analytics {
|
||||
export class Analytics {
|
||||
private baseUrl: URL = null;
|
||||
private siteId: string = null;
|
||||
private visitVariables: Record<number, [string, string]> = {}; // {[id: number]: [name: string, value: string]}
|
||||
private firstPage = true;
|
||||
private heartbeatIntervalID: number = null;
|
||||
|
||||
private readonly creationTs: string;
|
||||
private readonly lastVisitTs: string;
|
||||
private readonly visitCount: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = null;
|
||||
this.siteId = null;
|
||||
this.visitVariables = {};
|
||||
|
||||
this.firstPage = true;
|
||||
this._heartbeatIntervalID = null;
|
||||
|
||||
this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
|
||||
if (!this.creationTs && localStorage) {
|
||||
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
|
||||
localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime()));
|
||||
}
|
||||
|
||||
this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
|
||||
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0;
|
||||
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0";
|
||||
this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment
|
||||
if (localStorage) {
|
||||
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
|
||||
localStorage.setItem(VISIT_COUNT_KEY, this.visitCount);
|
||||
}
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
public get disabled() {
|
||||
return !this.baseUrl;
|
||||
}
|
||||
|
||||
public canEnable() {
|
||||
const config = SdkConfig.get();
|
||||
return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable Analytics if initialized but disabled
|
||||
* otherwise try and initalize, no-op if piwik config missing
|
||||
*/
|
||||
async enable() {
|
||||
public async enable() {
|
||||
if (!this.disabled) return;
|
||||
|
||||
if (!this.canEnable()) return;
|
||||
const config = SdkConfig.get();
|
||||
if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return;
|
||||
|
||||
this.baseUrl = new URL("piwik.php", config.piwik.url);
|
||||
// set constants
|
||||
this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
|
||||
this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking
|
||||
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
|
||||
this.baseUrl.searchParams.set("apiv", 1); // API version to use
|
||||
this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF
|
||||
this.baseUrl.searchParams.set("apiv", "1"); // API version to use
|
||||
this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF
|
||||
// set user parameters
|
||||
this.baseUrl.searchParams.set("_id", getUid()); // uuid
|
||||
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
|
||||
this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
|
||||
this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count
|
||||
if (this.lastVisitTs) {
|
||||
this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
|
||||
}
|
||||
|
||||
const platform = PlatformPeg.get();
|
||||
this._setVisitVariable('App Platform', platform.getHumanReadableName());
|
||||
this.setVisitVariable('App Platform', platform.getHumanReadableName());
|
||||
try {
|
||||
this._setVisitVariable('App Version', await platform.getAppVersion());
|
||||
this.setVisitVariable('App Version', await platform.getAppVersion());
|
||||
} catch (e) {
|
||||
this._setVisitVariable('App Version', 'unknown');
|
||||
this.setVisitVariable('App Version', 'unknown');
|
||||
}
|
||||
|
||||
this._setVisitVariable('Chosen Language', getCurrentLanguage());
|
||||
this.setVisitVariable('Chosen Language', getCurrentLanguage());
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname === 'riot.im') {
|
||||
this._setVisitVariable('Instance', window.location.pathname);
|
||||
this.setVisitVariable('Instance', window.location.pathname);
|
||||
} else if (hostname.endsWith('.element.io')) {
|
||||
this._setVisitVariable('Instance', hostname.replace('.element.io', ''));
|
||||
this.setVisitVariable('Instance', hostname.replace('.element.io', ''));
|
||||
}
|
||||
|
||||
let installedPWA = "unknown";
|
||||
try {
|
||||
// Known to work at least for desktop Chrome
|
||||
installedPWA = window.matchMedia('(display-mode: standalone)').matches;
|
||||
installedPWA = String(window.matchMedia('(display-mode: standalone)').matches);
|
||||
} catch (e) { }
|
||||
this._setVisitVariable('Installed PWA', installedPWA);
|
||||
this.setVisitVariable('Installed PWA', installedPWA);
|
||||
|
||||
let touchInput = "unknown";
|
||||
try {
|
||||
// MDN claims broad support across browsers
|
||||
touchInput = window.matchMedia('(pointer: coarse)').matches;
|
||||
touchInput = String(window.matchMedia('(pointer: coarse)').matches);
|
||||
} catch (e) { }
|
||||
this._setVisitVariable('Touch Input', touchInput);
|
||||
this.setVisitVariable('Touch Input', touchInput);
|
||||
|
||||
// start heartbeat
|
||||
this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
|
||||
this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable Analytics, stop the heartbeat and clear identifiers from localStorage
|
||||
*/
|
||||
disable() {
|
||||
public disable() {
|
||||
if (this.disabled) return;
|
||||
this.trackEvent('Analytics', 'opt-out');
|
||||
window.clearInterval(this._heartbeatIntervalID);
|
||||
window.clearInterval(this.heartbeatIntervalID);
|
||||
this.baseUrl = null;
|
||||
this.visitVariables = {};
|
||||
localStorage.removeItem(UID_KEY);
|
||||
|
@ -244,7 +270,7 @@ class Analytics {
|
|||
localStorage.removeItem(LAST_VISIT_TS_KEY);
|
||||
}
|
||||
|
||||
async _track(data) {
|
||||
private async _track(data: IData) {
|
||||
if (this.disabled) return;
|
||||
|
||||
const now = new Date();
|
||||
|
@ -260,13 +286,13 @@ class Analytics {
|
|||
s: now.getSeconds(),
|
||||
};
|
||||
|
||||
const url = new URL(this.baseUrl);
|
||||
const url = new URL(this.baseUrl.toString()); // copy
|
||||
for (const key in params) {
|
||||
url.searchParams.set(key, params[key]);
|
||||
}
|
||||
|
||||
try {
|
||||
await window.fetch(url, {
|
||||
await window.fetch(url.toString(), {
|
||||
method: "GET",
|
||||
mode: "no-cors",
|
||||
cache: "no-cache",
|
||||
|
@ -277,14 +303,14 @@ class Analytics {
|
|||
}
|
||||
}
|
||||
|
||||
ping() {
|
||||
public ping() {
|
||||
this._track({
|
||||
ping: 1,
|
||||
ping: "1",
|
||||
});
|
||||
localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts
|
||||
localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
|
||||
}
|
||||
|
||||
trackPageChange(generationTimeMs) {
|
||||
public trackPageChange(generationTimeMs?: number) {
|
||||
if (this.disabled) return;
|
||||
if (this.firstPage) {
|
||||
// De-duplicate first page
|
||||
|
@ -299,11 +325,11 @@ class Analytics {
|
|||
}
|
||||
|
||||
this._track({
|
||||
gt_ms: generationTimeMs,
|
||||
gt_ms: String(generationTimeMs),
|
||||
});
|
||||
}
|
||||
|
||||
trackEvent(category, action, name, value) {
|
||||
public trackEvent(category: string, action: string, name?: string, value?: string) {
|
||||
if (this.disabled) return;
|
||||
this._track({
|
||||
e_c: category,
|
||||
|
@ -313,12 +339,12 @@ class Analytics {
|
|||
});
|
||||
}
|
||||
|
||||
_setVisitVariable(key, value) {
|
||||
private setVisitVariable(key: keyof typeof customVariables, value: string) {
|
||||
if (this.disabled) return;
|
||||
this.visitVariables[customVariables[key].id] = [key, value];
|
||||
}
|
||||
|
||||
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
|
||||
public setLoggedIn(isGuest: boolean, homeserverUrl: string) {
|
||||
if (this.disabled) return;
|
||||
|
||||
const config = SdkConfig.get();
|
||||
|
@ -326,16 +352,16 @@ class Analytics {
|
|||
|
||||
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
|
||||
|
||||
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
|
||||
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
|
||||
this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
|
||||
this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
|
||||
}
|
||||
|
||||
setBreadcrumbs(state) {
|
||||
public setBreadcrumbs(state: boolean) {
|
||||
if (this.disabled) return;
|
||||
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
|
||||
this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
|
||||
}
|
||||
|
||||
showDetailsModal = () => {
|
||||
public showDetailsModal = () => {
|
||||
let rows = [];
|
||||
if (!this.disabled) {
|
||||
rows = Object.values(this.visitVariables);
|
||||
|
@ -356,7 +382,7 @@ class Analytics {
|
|||
'e.g. <CurrentPageURL>',
|
||||
{},
|
||||
{
|
||||
CurrentPageURL: getRedactedUrl(),
|
||||
CurrentPageURL: getRedactedUrl,
|
||||
},
|
||||
),
|
||||
},
|
||||
|
@ -397,7 +423,7 @@ class Analytics {
|
|||
};
|
||||
}
|
||||
|
||||
if (!global.mxAnalytics) {
|
||||
global.mxAnalytics = new Analytics();
|
||||
if (!window.mxAnalytics) {
|
||||
window.mxAnalytics = new Analytics();
|
||||
}
|
||||
export default global.mxAnalytics;
|
||||
export default window.mxAnalytics;
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import createReactClass from 'create-react-class';
|
||||
import React from "react";
|
||||
import * as sdk from './index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from './languageHandler';
|
||||
|
@ -24,21 +24,19 @@ import { _t } from './languageHandler';
|
|||
* Wrap an asynchronous loader function with a react component which shows a
|
||||
* spinner until the real component loads.
|
||||
*/
|
||||
export default createReactClass({
|
||||
propTypes: {
|
||||
export default class AsyncWrapper extends React.Component {
|
||||
static propTypes = {
|
||||
/** A promise which resolves with the real component
|
||||
*/
|
||||
prom: PropTypes.object.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
component: null,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
component: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
// XXX: temporary logging to try to diagnose
|
||||
// https://github.com/vector-im/element-web/issues/3148
|
||||
|
@ -56,17 +54,17 @@ export default createReactClass({
|
|||
console.warn('AsyncWrapper promise failed', e);
|
||||
this.setState({error: e});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
}
|
||||
|
||||
_onWrapperCancelClick: function() {
|
||||
_onWrapperCancelClick = () => {
|
||||
this.props.onFinished(false);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (this.state.component) {
|
||||
const Component = this.state.component;
|
||||
return <Component {...this.props} />;
|
||||
|
@ -87,6 +85,6 @@ export default createReactClass({
|
|||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,14 +14,19 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import {User} from "matrix-js-sdk/src/models/user";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
|
||||
export type ResizeMethod = "crop" | "scale";
|
||||
|
||||
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
|
||||
export function avatarUrlForMember(member, width, height, resizeMethod) {
|
||||
let url;
|
||||
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
|
||||
let url: string;
|
||||
if (member && member.getAvatarUrl) {
|
||||
url = member.getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
|
@ -41,7 +46,7 @@ export function avatarUrlForMember(member, width, height, resizeMethod) {
|
|||
return url;
|
||||
}
|
||||
|
||||
export function avatarUrlForUser(user, width, height, resizeMethod) {
|
||||
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
const url = getHttpUriForMxc(
|
||||
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
|
||||
Math.floor(width * window.devicePixelRatio),
|
||||
|
@ -54,14 +59,14 @@ export function avatarUrlForUser(user, width, height, resizeMethod) {
|
|||
return url;
|
||||
}
|
||||
|
||||
function isValidHexColor(color) {
|
||||
function isValidHexColor(color: string): boolean {
|
||||
return typeof color === "string" &&
|
||||
(color.length === 7 || color.lengh === 9) &&
|
||||
(color.length === 7 || color.length === 9) &&
|
||||
color.charAt(0) === "#" &&
|
||||
!color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
|
||||
}
|
||||
|
||||
function urlForColor(color) {
|
||||
function urlForColor(color: string): string {
|
||||
const size = 40;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
|
@ -79,9 +84,10 @@ function urlForColor(color) {
|
|||
// XXX: Ideally we'd clear this cache when the theme changes
|
||||
// but since this function is at global scope, it's a bit
|
||||
// hard to install a listener here, even if there were a clear event to listen to
|
||||
const colorToDataURLCache = new Map();
|
||||
const colorToDataURLCache = new Map<string, string>();
|
||||
|
||||
export function defaultAvatarUrlForString(s) {
|
||||
export function defaultAvatarUrlForString(s: string): string {
|
||||
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
|
||||
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
|
||||
let total = 0;
|
||||
for (let i = 0; i < s.length; ++i) {
|
||||
|
@ -112,7 +118,7 @@ export function defaultAvatarUrlForString(s) {
|
|||
* @param {string} name
|
||||
* @return {string} the first letter
|
||||
*/
|
||||
export function getInitialLetter(name) {
|
||||
export function getInitialLetter(name: string): string {
|
||||
if (!name) {
|
||||
// XXX: We should find out what causes the name to sometimes be falsy.
|
||||
console.trace("`name` argument to `getInitialLetter` not supplied");
|
||||
|
@ -145,7 +151,7 @@ export function getInitialLetter(name) {
|
|||
return firstChar.toUpperCase();
|
||||
}
|
||||
|
||||
export function avatarUrlForRoom(room, width, height, resizeMethod) {
|
||||
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
if (!room) return null; // null-guard
|
||||
|
||||
const explicitRoomAvatar = room.getAvatarUrl(
|
|
@ -1,509 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Manages a list of all the currently active calls.
|
||||
*
|
||||
* This handler dispatches when voip calls are added/updated/removed from this list:
|
||||
* {
|
||||
* action: 'call_state'
|
||||
* room_id: <room ID of the call>
|
||||
* }
|
||||
*
|
||||
* To know the state of the call, this handler exposes a getter to
|
||||
* obtain the call for a room:
|
||||
* var call = CallHandler.getCall(roomId)
|
||||
* var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
|
||||
*
|
||||
* This handler listens for and handles the following actions:
|
||||
* {
|
||||
* action: 'place_call',
|
||||
* type: 'voice|video',
|
||||
* room_id: <room that the place call button was pressed in>
|
||||
* }
|
||||
*
|
||||
* {
|
||||
* action: 'incoming_call'
|
||||
* call: MatrixCall
|
||||
* }
|
||||
*
|
||||
* {
|
||||
* action: 'hangup'
|
||||
* room_id: <room that the hangup button was pressed in>
|
||||
* }
|
||||
*
|
||||
* {
|
||||
* action: 'answer'
|
||||
* room_id: <room that the answer button was pressed in>
|
||||
* }
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import { _t } from './languageHandler';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import {generateHumanReadableId} from "./utils/NamingUtils";
|
||||
import {Jitsi} from "./widgets/Jitsi";
|
||||
import {WidgetType} from "./widgets/WidgetType";
|
||||
import {SettingLevel} from "./settings/SettingLevel";
|
||||
|
||||
global.mxCalls = {
|
||||
//room_id: MatrixCall
|
||||
};
|
||||
const calls = global.mxCalls;
|
||||
let ConferenceHandler = null;
|
||||
|
||||
const audioPromises = {};
|
||||
|
||||
function play(audioId) {
|
||||
// TODO: Attach an invisible element for this instead
|
||||
// which listens?
|
||||
const audio = document.getElementById(audioId);
|
||||
if (audio) {
|
||||
const playAudio = async () => {
|
||||
try {
|
||||
// This still causes the chrome debugger to break on promise rejection if
|
||||
// the promise is rejected, even though we're catching the exception.
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
// This is usually because the user hasn't interacted with the document,
|
||||
// or chrome doesn't think so and is denying the request. Not sure what
|
||||
// we can really do here...
|
||||
// https://github.com/vector-im/element-web/issues/7657
|
||||
console.log("Unable to play audio clip", e);
|
||||
}
|
||||
};
|
||||
if (audioPromises[audioId]) {
|
||||
audioPromises[audioId] = audioPromises[audioId].then(()=>{
|
||||
audio.load();
|
||||
return playAudio();
|
||||
});
|
||||
} else {
|
||||
audioPromises[audioId] = playAudio();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pause(audioId) {
|
||||
// TODO: Attach an invisible element for this instead
|
||||
// which listens?
|
||||
const audio = document.getElementById(audioId);
|
||||
if (audio) {
|
||||
if (audioPromises[audioId]) {
|
||||
audioPromises[audioId] = audioPromises[audioId].then(()=>audio.pause());
|
||||
} else {
|
||||
// pause doesn't actually return a promise, but might as well do this for symmetry with play();
|
||||
audioPromises[audioId] = audio.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _setCallListeners(call) {
|
||||
call.on("error", function(err) {
|
||||
console.error("Call error:", err);
|
||||
if (
|
||||
MatrixClientPeg.get().getTurnServers().length === 0 &&
|
||||
SettingsStore.getValue("fallbackICEServerAllowed") === null
|
||||
) {
|
||||
_showICEFallbackPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
title: _t('Call Failed'),
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
call.on("hangup", function() {
|
||||
_setCallState(undefined, call.roomId, "ended");
|
||||
});
|
||||
// map web rtc states to dummy UI state
|
||||
// ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
|
||||
call.on("state", function(newState, oldState) {
|
||||
if (newState === "ringing") {
|
||||
_setCallState(call, call.roomId, "ringing");
|
||||
pause("ringbackAudio");
|
||||
} else if (newState === "invite_sent") {
|
||||
_setCallState(call, call.roomId, "ringback");
|
||||
play("ringbackAudio");
|
||||
} else if (newState === "ended" && oldState === "connected") {
|
||||
_setCallState(undefined, call.roomId, "ended");
|
||||
pause("ringbackAudio");
|
||||
play("callendAudio");
|
||||
} else if (newState === "ended" && oldState === "invite_sent" &&
|
||||
(call.hangupParty === "remote" ||
|
||||
(call.hangupParty === "local" && call.hangupReason === "invite_timeout")
|
||||
)) {
|
||||
_setCallState(call, call.roomId, "busy");
|
||||
pause("ringbackAudio");
|
||||
play("busyAudio");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
|
||||
title: _t('Call Timeout'),
|
||||
description: _t('The remote side failed to pick up') + '.',
|
||||
});
|
||||
} else if (oldState === "invite_sent") {
|
||||
_setCallState(call, call.roomId, "stop_ringback");
|
||||
pause("ringbackAudio");
|
||||
} else if (oldState === "ringing") {
|
||||
_setCallState(call, call.roomId, "stop_ringing");
|
||||
pause("ringbackAudio");
|
||||
} else if (newState === "connected") {
|
||||
_setCallState(call, call.roomId, "connected");
|
||||
pause("ringbackAudio");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _setCallState(call, roomId, status) {
|
||||
console.log(
|
||||
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
|
||||
);
|
||||
calls[roomId] = call;
|
||||
|
||||
if (status === "ringing") {
|
||||
play("ringAudio");
|
||||
} else if (call && call.call_state === "ringing") {
|
||||
pause("ringAudio");
|
||||
}
|
||||
|
||||
if (call) {
|
||||
call.call_state = status;
|
||||
}
|
||||
dis.dispatch({
|
||||
action: 'call_state',
|
||||
room_id: roomId,
|
||||
state: status,
|
||||
});
|
||||
}
|
||||
|
||||
function _showICEFallbackPrompt() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const code = sub => <code>{sub}</code>;
|
||||
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
|
||||
title: _t("Call failed due to misconfigured server"),
|
||||
description: <div>
|
||||
<p>{_t(
|
||||
"Please ask the administrator of your homeserver " +
|
||||
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
|
||||
"order for calls to work reliably.",
|
||||
{ homeserverDomain: cli.getDomain() }, { code },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Alternatively, you can try to use the public server at " +
|
||||
"<code>turn.matrix.org</code>, but this will not be as reliable, and " +
|
||||
"it will share your IP address with that server. You can also manage " +
|
||||
"this in Settings.",
|
||||
null, { code },
|
||||
)}</p>
|
||||
</div>,
|
||||
button: _t('Try using turn.matrix.org'),
|
||||
cancelButton: _t('OK'),
|
||||
onFinished: (allow) => {
|
||||
SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
|
||||
cli.setFallbackICEServerAllowed(allow);
|
||||
},
|
||||
}, null, true);
|
||||
}
|
||||
|
||||
function _onAction(payload) {
|
||||
function placeCall(newCall) {
|
||||
_setCallListeners(newCall);
|
||||
if (payload.type === 'voice') {
|
||||
newCall.placeVoiceCall();
|
||||
} else if (payload.type === 'video') {
|
||||
newCall.placeVideoCall(
|
||||
payload.remote_element,
|
||||
payload.local_element,
|
||||
);
|
||||
} else if (payload.type === 'screensharing') {
|
||||
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
|
||||
if (screenCapErrorString) {
|
||||
_setCallState(undefined, newCall.roomId, "ended");
|
||||
console.log("Can't capture screen: " + screenCapErrorString);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
|
||||
title: _t('Unable to capture screen'),
|
||||
description: screenCapErrorString,
|
||||
});
|
||||
return;
|
||||
}
|
||||
newCall.placeScreenSharingCall(
|
||||
payload.remote_element,
|
||||
payload.local_element,
|
||||
);
|
||||
} else {
|
||||
console.error("Unknown conf call type: %s", payload.type);
|
||||
}
|
||||
}
|
||||
|
||||
switch (payload.action) {
|
||||
case 'place_call':
|
||||
{
|
||||
if (callHandler.getAnyActiveCall()) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
|
||||
title: _t('Existing Call'),
|
||||
description: _t('You are already in a call.'),
|
||||
});
|
||||
return; // don't allow >1 call to be placed.
|
||||
}
|
||||
|
||||
// if the runtime env doesn't do VoIP, whine.
|
||||
if (!MatrixClientPeg.get().supportsVoip()) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
|
||||
title: _t('VoIP is unsupported'),
|
||||
description: _t('You cannot place VoIP calls in this browser.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(payload.room_id);
|
||||
if (!room) {
|
||||
console.error("Room %s does not exist.", payload.room_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const members = room.getJoinedMembers();
|
||||
if (members.length <= 1) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
|
||||
description: _t('You cannot place a call with yourself.'),
|
||||
});
|
||||
return;
|
||||
} else if (members.length === 2) {
|
||||
console.info("Place %s call in %s", payload.type, payload.room_id);
|
||||
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
|
||||
placeCall(call);
|
||||
} else { // > 2
|
||||
dis.dispatch({
|
||||
action: "place_conference_call",
|
||||
room_id: payload.room_id,
|
||||
type: payload.type,
|
||||
remote_element: payload.remote_element,
|
||||
local_element: payload.local_element,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'place_conference_call':
|
||||
console.info("Place conference call in %s", payload.room_id);
|
||||
_startCallApp(payload.room_id, payload.type);
|
||||
break;
|
||||
case 'incoming_call':
|
||||
{
|
||||
if (callHandler.getAnyActiveCall()) {
|
||||
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
|
||||
// we avoid rejecting with "busy" in case the user wants to answer it on a different device.
|
||||
// in future we could signal a "local busy" as a warning to the caller.
|
||||
// see https://github.com/vector-im/vector-web/issues/1964
|
||||
return;
|
||||
}
|
||||
|
||||
// if the runtime env doesn't do VoIP, stop here.
|
||||
if (!MatrixClientPeg.get().supportsVoip()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const call = payload.call;
|
||||
_setCallListeners(call);
|
||||
_setCallState(call, call.roomId, "ringing");
|
||||
}
|
||||
break;
|
||||
case 'hangup':
|
||||
if (!calls[payload.room_id]) {
|
||||
return; // no call to hangup
|
||||
}
|
||||
calls[payload.room_id].hangup();
|
||||
_setCallState(null, payload.room_id, "ended");
|
||||
break;
|
||||
case 'answer':
|
||||
if (!calls[payload.room_id]) {
|
||||
return; // no call to answer
|
||||
}
|
||||
calls[payload.room_id].answer();
|
||||
_setCallState(calls[payload.room_id], payload.room_id, "connected");
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: payload.room_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function _startCallApp(roomId, type) {
|
||||
dis.dispatch({
|
||||
action: 'appsDrawer',
|
||||
show: true,
|
||||
});
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
|
||||
|
||||
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
|
||||
title: _t('Call in Progress'),
|
||||
description: _t('A call is currently being placed!'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentJitsiWidgets.length > 0) {
|
||||
console.warn(
|
||||
"Refusing to start conference call widget in " + roomId +
|
||||
" a conference call widget is already present",
|
||||
);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, {
|
||||
title: _t('Call in Progress'),
|
||||
description: _t('A call is already in progress!'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confId = `JitsiConference${generateHumanReadableId()}`;
|
||||
const jitsiDomain = Jitsi.getInstance().preferredDomain;
|
||||
|
||||
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
|
||||
|
||||
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
|
||||
const parsedUrl = new URL(widgetUrl);
|
||||
parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
|
||||
parsedUrl.searchParams.set('confId', confId);
|
||||
widgetUrl = parsedUrl.toString();
|
||||
|
||||
const widgetData = {
|
||||
conferenceId: confId,
|
||||
isAudioOnly: type === 'voice',
|
||||
domain: jitsiDomain,
|
||||
};
|
||||
|
||||
const widgetId = (
|
||||
'jitsi_' +
|
||||
MatrixClientPeg.get().credentials.userId +
|
||||
'_' +
|
||||
Date.now()
|
||||
);
|
||||
|
||||
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
|
||||
console.log('Jitsi widget added');
|
||||
}).catch((e) => {
|
||||
if (e.errcode === 'M_FORBIDDEN') {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
title: _t('Permission Required'),
|
||||
description: _t("You do not have permission to start a conference call in this room"),
|
||||
});
|
||||
}
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
// FIXME: Nasty way of making sure we only register
|
||||
// with the dispatcher once
|
||||
if (!global.mxCallHandler) {
|
||||
dis.register(_onAction);
|
||||
// add empty handlers for media actions, otherwise the media keys
|
||||
// end up causing the audio elements with our ring/ringback etc
|
||||
// audio clips in to play.
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.setActionHandler('play', function() {});
|
||||
navigator.mediaSession.setActionHandler('pause', function() {});
|
||||
navigator.mediaSession.setActionHandler('seekbackward', function() {});
|
||||
navigator.mediaSession.setActionHandler('seekforward', function() {});
|
||||
navigator.mediaSession.setActionHandler('previoustrack', function() {});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', function() {});
|
||||
}
|
||||
}
|
||||
|
||||
const callHandler = {
|
||||
getCallForRoom: function(roomId) {
|
||||
let call = callHandler.getCall(roomId);
|
||||
if (call) return call;
|
||||
|
||||
if (ConferenceHandler) {
|
||||
call = ConferenceHandler.getConferenceCallForRoom(roomId);
|
||||
}
|
||||
if (call) return call;
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
getCall: function(roomId) {
|
||||
return calls[roomId] || null;
|
||||
},
|
||||
|
||||
getAnyActiveCall: function() {
|
||||
const roomsWithCalls = Object.keys(calls);
|
||||
for (let i = 0; i < roomsWithCalls.length; i++) {
|
||||
if (calls[roomsWithCalls[i]] &&
|
||||
calls[roomsWithCalls[i]].call_state !== "ended") {
|
||||
return calls[roomsWithCalls[i]];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* The conference handler is a module that deals with implementation-specific
|
||||
* multi-party calling implementations. Element passes in its own which creates
|
||||
* a one-to-one call with a freeswitch conference bridge. As of July 2018,
|
||||
* the de-facto way of conference calling is a Jitsi widget, so this is
|
||||
* deprecated. It reamins here for two reasons:
|
||||
* 1. So Element still supports joining existing freeswitch conference calls
|
||||
* (but doesn't support creating them). After a transition period, we can
|
||||
* remove support for joining them too.
|
||||
* 2. To hide the one-to-one rooms that old-style conferencing creates. This
|
||||
* is much harder to remove: probably either we make Element leave & forget these
|
||||
* rooms after we remove support for joining freeswitch conferences, or we
|
||||
* accept that random rooms with cryptic users will suddently appear for
|
||||
* anyone who's ever used conference calling, or we are stuck with this
|
||||
* code forever.
|
||||
*
|
||||
* @param {object} confHandler The conference handler object
|
||||
*/
|
||||
setConferenceHandler: function(confHandler) {
|
||||
ConferenceHandler = confHandler;
|
||||
},
|
||||
|
||||
getConferenceHandler: function() {
|
||||
return ConferenceHandler;
|
||||
},
|
||||
};
|
||||
// Only things in here which actually need to be global are the
|
||||
// calls list (done separately) and making sure we only register
|
||||
// with the dispatcher once (which uses this mechanism but checks
|
||||
// separately). This could be tidied up.
|
||||
if (global.mxCallHandler === undefined) {
|
||||
global.mxCallHandler = callHandler;
|
||||
}
|
||||
|
||||
export default global.mxCallHandler;
|
565
src/CallHandler.tsx
Normal file
565
src/CallHandler.tsx
Normal file
|
@ -0,0 +1,565 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Manages a list of all the currently active calls.
|
||||
*
|
||||
* This handler dispatches when voip calls are added/updated/removed from this list:
|
||||
* {
|
||||
* action: 'call_state'
|
||||
* room_id: <room ID of the call>
|
||||
* }
|
||||
*
|
||||
* To know the state of the call, this handler exposes a getter to
|
||||
* obtain the call for a room:
|
||||
* var call = CallHandler.getCall(roomId)
|
||||
* var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
|
||||
*
|
||||
* This handler listens for and handles the following actions:
|
||||
* {
|
||||
* action: 'place_call',
|
||||
* type: 'voice|video',
|
||||
* room_id: <room that the place call button was pressed in>
|
||||
* }
|
||||
*
|
||||
* {
|
||||
* action: 'incoming_call'
|
||||
* call: MatrixCall
|
||||
* }
|
||||
*
|
||||
* {
|
||||
* action: 'hangup'
|
||||
* room_id: <room that the hangup button was pressed in>
|
||||
* }
|
||||
*
|
||||
* {
|
||||
* action: 'answer'
|
||||
* room_id: <room that the answer button was pressed in>
|
||||
* }
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import {generateHumanReadableId} from "./utils/NamingUtils";
|
||||
import {Jitsi} from "./widgets/Jitsi";
|
||||
import {WidgetType} from "./widgets/WidgetType";
|
||||
import {SettingLevel} from "./settings/SettingLevel";
|
||||
import { ActionPayload } from "./dispatcher/payloads";
|
||||
import {base32} from "rfc4648";
|
||||
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
import WidgetStore from "./stores/WidgetStore";
|
||||
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
|
||||
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
|
||||
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/lib/webrtc/call";
|
||||
|
||||
enum AudioID {
|
||||
Ring = 'ringAudio',
|
||||
Ringback = 'ringbackAudio',
|
||||
CallEnd = 'callendAudio',
|
||||
Busy = 'busyAudio',
|
||||
}
|
||||
|
||||
// Unlike 'CallType' in js-sdk, this one includes screen sharing
|
||||
// (because a screen sharing call is only a screen sharing call to the caller,
|
||||
// to the callee it's just a video call, at least as far as the current impl
|
||||
// is concerned).
|
||||
export enum PlaceCallType {
|
||||
Voice = 'voice',
|
||||
Video = 'video',
|
||||
ScreenSharing = 'screensharing',
|
||||
}
|
||||
|
||||
export default class CallHandler {
|
||||
private calls = new Map<string, MatrixCall>();
|
||||
private audioPromises = new Map<AudioID, Promise<void>>();
|
||||
|
||||
static sharedInstance() {
|
||||
if (!window.mxCallHandler) {
|
||||
window.mxCallHandler = new CallHandler()
|
||||
}
|
||||
|
||||
return window.mxCallHandler;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
dis.register(this.onAction);
|
||||
// add empty handlers for media actions, otherwise the media keys
|
||||
// end up causing the audio elements with our ring/ringback etc
|
||||
// audio clips in to play.
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.setActionHandler('play', function() {});
|
||||
navigator.mediaSession.setActionHandler('pause', function() {});
|
||||
navigator.mediaSession.setActionHandler('seekbackward', function() {});
|
||||
navigator.mediaSession.setActionHandler('seekforward', function() {});
|
||||
navigator.mediaSession.setActionHandler('previoustrack', function() {});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', function() {});
|
||||
}
|
||||
}
|
||||
|
||||
getCallForRoom(roomId: string): MatrixCall {
|
||||
return this.calls.get(roomId) || null;
|
||||
}
|
||||
|
||||
getAnyActiveCall() {
|
||||
for (const call of this.calls.values()) {
|
||||
if (call.state !== CallState.Ended) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
play(audioId: AudioID) {
|
||||
// TODO: Attach an invisible element for this instead
|
||||
// which listens?
|
||||
const audio = document.getElementById(audioId) as HTMLMediaElement;
|
||||
if (audio) {
|
||||
const playAudio = async () => {
|
||||
try {
|
||||
// This still causes the chrome debugger to break on promise rejection if
|
||||
// the promise is rejected, even though we're catching the exception.
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
// This is usually because the user hasn't interacted with the document,
|
||||
// or chrome doesn't think so and is denying the request. Not sure what
|
||||
// we can really do here...
|
||||
// https://github.com/vector-im/element-web/issues/7657
|
||||
console.log("Unable to play audio clip", e);
|
||||
}
|
||||
};
|
||||
if (this.audioPromises.has(audioId)) {
|
||||
this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => {
|
||||
audio.load();
|
||||
return playAudio();
|
||||
}));
|
||||
} else {
|
||||
this.audioPromises.set(audioId, playAudio());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pause(audioId: AudioID) {
|
||||
// TODO: Attach an invisible element for this instead
|
||||
// which listens?
|
||||
const audio = document.getElementById(audioId) as HTMLMediaElement;
|
||||
if (audio) {
|
||||
if (this.audioPromises.has(audioId)) {
|
||||
this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => audio.pause()));
|
||||
} else {
|
||||
// pause doesn't return a promise, so just do it
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private matchesCallForThisRoom(call: MatrixCall) {
|
||||
// We don't allow placing more than one call per room, but that doesn't mean there
|
||||
// can't be more than one, eg. in a glare situation. This checks that the given call
|
||||
// is the call we consider 'the' call for its room.
|
||||
const callForThisRoom = this.getCallForRoom(call.roomId);
|
||||
return callForThisRoom && call.callId === callForThisRoom.callId;
|
||||
}
|
||||
|
||||
private setCallListeners(call: MatrixCall) {
|
||||
call.on(CallEvent.Error, (err) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
console.error("Call error:", err);
|
||||
if (
|
||||
MatrixClientPeg.get().getTurnServers().length === 0 &&
|
||||
SettingsStore.getValue("fallbackICEServerAllowed") === null
|
||||
) {
|
||||
this.showICEFallbackPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
title: _t('Call Failed'),
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
call.on(CallEvent.Hangup, () => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
this.removeCallForRoom(call.roomId);
|
||||
});
|
||||
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
this.setCallState(call, newState);
|
||||
|
||||
switch (oldState) {
|
||||
case CallState.Ringing:
|
||||
this.pause(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.pause(AudioID.Ringback);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing:
|
||||
this.play(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
case CallState.Ended:
|
||||
this.removeCallForRoom(call.roomId);
|
||||
if (oldState === CallState.InviteSent && (
|
||||
call.hangupParty === CallParty.Remote ||
|
||||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
|
||||
)) {
|
||||
this.play(AudioID.Busy);
|
||||
let title;
|
||||
let description;
|
||||
if (call.hangupReason === CallErrorCode.UserHangup) {
|
||||
title = _t("Call Declined");
|
||||
description = _t("The other party declined the call.");
|
||||
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
|
||||
title = _t("Call Failed");
|
||||
// XXX: full stop appended as some relic here, but these
|
||||
// strings need proper input from design anyway, so let's
|
||||
// not change this string until we have a proper one.
|
||||
description = _t('The remote side failed to pick up') + '.';
|
||||
} else {
|
||||
title = _t("Call Failed");
|
||||
description = _t("The call could not be established");
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title, description,
|
||||
});
|
||||
} else {
|
||||
this.play(AudioID.CallEnd);
|
||||
}
|
||||
}
|
||||
});
|
||||
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
|
||||
|
||||
if (call.state === CallState.Ringing) {
|
||||
this.pause(AudioID.Ring);
|
||||
} else if (call.state === CallState.InviteSent) {
|
||||
this.pause(AudioID.Ringback);
|
||||
}
|
||||
|
||||
this.calls.set(newCall.roomId, newCall);
|
||||
this.setCallListeners(newCall);
|
||||
this.setCallState(newCall, newCall.state);
|
||||
});
|
||||
}
|
||||
|
||||
private setCallState(call: MatrixCall, status: CallState) {
|
||||
console.log(
|
||||
`Call state in ${call.roomId} changed to ${status}`,
|
||||
);
|
||||
|
||||
dis.dispatch({
|
||||
action: 'call_state',
|
||||
room_id: call.roomId,
|
||||
state: status,
|
||||
});
|
||||
}
|
||||
|
||||
private removeCallForRoom(roomId: string) {
|
||||
this.calls.delete(roomId);
|
||||
}
|
||||
|
||||
private showICEFallbackPrompt() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const code = sub => <code>{sub}</code>;
|
||||
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
|
||||
title: _t("Call failed due to misconfigured server"),
|
||||
description: <div>
|
||||
<p>{_t(
|
||||
"Please ask the administrator of your homeserver " +
|
||||
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
|
||||
"order for calls to work reliably.",
|
||||
{ homeserverDomain: cli.getDomain() }, { code },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Alternatively, you can try to use the public server at " +
|
||||
"<code>turn.matrix.org</code>, but this will not be as reliable, and " +
|
||||
"it will share your IP address with that server. You can also manage " +
|
||||
"this in Settings.",
|
||||
null, { code },
|
||||
)}</p>
|
||||
</div>,
|
||||
button: _t('Try using turn.matrix.org'),
|
||||
cancelButton: _t('OK'),
|
||||
onFinished: (allow) => {
|
||||
SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
|
||||
cli.setFallbackICEServerAllowed(allow);
|
||||
},
|
||||
}, null, true);
|
||||
}
|
||||
|
||||
|
||||
private placeCall(
|
||||
roomId: string, type: PlaceCallType,
|
||||
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
|
||||
) {
|
||||
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId);
|
||||
this.calls.set(roomId, call);
|
||||
this.setCallListeners(call);
|
||||
if (type === PlaceCallType.Voice) {
|
||||
call.placeVoiceCall();
|
||||
} else if (type === 'video') {
|
||||
call.placeVideoCall(
|
||||
remoteElement,
|
||||
localElement,
|
||||
);
|
||||
} else if (type === PlaceCallType.ScreenSharing) {
|
||||
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
|
||||
if (screenCapErrorString) {
|
||||
this.removeCallForRoom(roomId);
|
||||
console.log("Can't capture screen: " + screenCapErrorString);
|
||||
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
|
||||
title: _t('Unable to capture screen'),
|
||||
description: screenCapErrorString,
|
||||
});
|
||||
return;
|
||||
}
|
||||
call.placeScreenSharingCall(remoteElement, localElement);
|
||||
} else {
|
||||
console.error("Unknown conf call type: %s", type);
|
||||
}
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
switch (payload.action) {
|
||||
case 'place_call':
|
||||
{
|
||||
if (this.getAnyActiveCall()) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
|
||||
title: _t('Existing Call'),
|
||||
description: _t('You are already in a call.'),
|
||||
});
|
||||
return; // don't allow >1 call to be placed.
|
||||
}
|
||||
|
||||
// if the runtime env doesn't do VoIP, whine.
|
||||
if (!MatrixClientPeg.get().supportsVoip()) {
|
||||
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
|
||||
title: _t('VoIP is unsupported'),
|
||||
description: _t('You cannot place VoIP calls in this browser.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(payload.room_id);
|
||||
if (!room) {
|
||||
console.error("Room %s does not exist.", payload.room_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const members = room.getJoinedMembers();
|
||||
if (members.length <= 1) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
|
||||
description: _t('You cannot place a call with yourself.'),
|
||||
});
|
||||
return;
|
||||
} else if (members.length === 2) {
|
||||
console.info("Place %s call in %s", payload.type, payload.room_id);
|
||||
|
||||
this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
|
||||
} else { // > 2
|
||||
dis.dispatch({
|
||||
action: "place_conference_call",
|
||||
room_id: payload.room_id,
|
||||
type: payload.type,
|
||||
remote_element: payload.remote_element,
|
||||
local_element: payload.local_element,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'place_conference_call':
|
||||
console.info("Place conference call in %s", payload.room_id);
|
||||
this.startCallApp(payload.room_id, payload.type);
|
||||
break;
|
||||
case 'end_conference':
|
||||
console.info("Terminating conference call in %s", payload.room_id);
|
||||
this.terminateCallApp(payload.room_id);
|
||||
break;
|
||||
case 'hangup_conference':
|
||||
console.info("Leaving conference call in %s", payload.room_id);
|
||||
this.hangupCallApp(payload.room_id);
|
||||
break;
|
||||
case 'incoming_call':
|
||||
{
|
||||
if (this.getAnyActiveCall()) {
|
||||
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
|
||||
// we avoid rejecting with "busy" in case the user wants to answer it on a different device.
|
||||
// in future we could signal a "local busy" as a warning to the caller.
|
||||
// see https://github.com/vector-im/vector-web/issues/1964
|
||||
return;
|
||||
}
|
||||
|
||||
// if the runtime env doesn't do VoIP, stop here.
|
||||
if (!MatrixClientPeg.get().supportsVoip()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const call = payload.call as MatrixCall;
|
||||
this.calls.set(call.roomId, call)
|
||||
this.setCallListeners(call);
|
||||
}
|
||||
break;
|
||||
case 'hangup':
|
||||
case 'reject':
|
||||
if (!this.calls.get(payload.room_id)) {
|
||||
return; // no call to hangup
|
||||
}
|
||||
if (payload.action === 'reject') {
|
||||
this.calls.get(payload.room_id).reject();
|
||||
} else {
|
||||
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
|
||||
}
|
||||
this.removeCallForRoom(payload.room_id);
|
||||
break;
|
||||
case 'answer':
|
||||
if (!this.calls.has(payload.room_id)) {
|
||||
return; // no call to answer
|
||||
}
|
||||
this.calls.get(payload.room_id).answer();
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: payload.room_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async startCallApp(roomId: string, type: string) {
|
||||
dis.dispatch({
|
||||
action: 'appsDrawer',
|
||||
show: true,
|
||||
});
|
||||
|
||||
// prevent double clicking the call button
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
|
||||
const hasJitsi = currentJitsiWidgets.length > 0
|
||||
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
|
||||
if (hasJitsi) {
|
||||
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
|
||||
title: _t('Call in Progress'),
|
||||
description: _t('A call is currently being placed!'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const jitsiDomain = Jitsi.getInstance().preferredDomain;
|
||||
const jitsiAuth = await Jitsi.getInstance().getJitsiAuth();
|
||||
let confId;
|
||||
if (jitsiAuth === 'openidtoken-jwt') {
|
||||
// Create conference ID from room ID
|
||||
// For compatibility with Jitsi, use base32 without padding.
|
||||
// More details here:
|
||||
// https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
|
||||
confId = base32.stringify(Buffer.from(roomId), { pad: false });
|
||||
} else {
|
||||
// Create a random human readable conference ID
|
||||
confId = `JitsiConference${generateHumanReadableId()}`;
|
||||
}
|
||||
|
||||
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
|
||||
|
||||
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
|
||||
const parsedUrl = new URL(widgetUrl);
|
||||
parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
|
||||
parsedUrl.searchParams.set('confId', confId);
|
||||
widgetUrl = parsedUrl.toString();
|
||||
|
||||
const widgetData = {
|
||||
conferenceId: confId,
|
||||
isAudioOnly: type === 'voice',
|
||||
domain: jitsiDomain,
|
||||
auth: jitsiAuth,
|
||||
};
|
||||
|
||||
const widgetId = (
|
||||
'jitsi_' +
|
||||
MatrixClientPeg.get().credentials.userId +
|
||||
'_' +
|
||||
Date.now()
|
||||
);
|
||||
|
||||
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
|
||||
console.log('Jitsi widget added');
|
||||
}).catch((e) => {
|
||||
if (e.errcode === 'M_FORBIDDEN') {
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
title: _t('Permission Required'),
|
||||
description: _t("You do not have permission to start a conference call in this room"),
|
||||
});
|
||||
}
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
private terminateCallApp(roomId: string) {
|
||||
Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, {
|
||||
hasCancelButton: true,
|
||||
title: _t("End conference"),
|
||||
description: _t("This will end the conference for everyone. Continue?"),
|
||||
button: _t("End conference"),
|
||||
onFinished: (proceed) => {
|
||||
if (!proceed) return;
|
||||
|
||||
// We'll just obliterate them all. There should only ever be one, but might as well
|
||||
// be safe.
|
||||
const roomInfo = WidgetStore.instance.getRoom(roomId);
|
||||
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
|
||||
jitsiWidgets.forEach(w => {
|
||||
// setting invalid content removes it
|
||||
WidgetUtils.setRoomWidget(roomId, w.id);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private hangupCallApp(roomId: string) {
|
||||
const roomInfo = WidgetStore.instance.getRoom(roomId);
|
||||
if (!roomInfo) return; // "should never happen" clauses go here
|
||||
|
||||
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
|
||||
jitsiWidgets.forEach(w => {
|
||||
const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id);
|
||||
if (!messaging) return; // more "should never happen" words
|
||||
|
||||
messaging.transport.send(ElementWidgetActions.HangupCall, {});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import extend from './extend';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
@ -70,6 +69,7 @@ interface IContent {
|
|||
|
||||
interface IThumbnail {
|
||||
info: {
|
||||
// eslint-disable-next-line camelcase
|
||||
thumbnail_info: {
|
||||
w: number;
|
||||
h: number;
|
||||
|
@ -104,7 +104,12 @@ interface IAbortablePromise<T> extends Promise<T> {
|
|||
* @return {Promise} A promise that resolves with an object with an info key
|
||||
* and a thumbnail key.
|
||||
*/
|
||||
function createThumbnail(element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string): Promise<IThumbnail> {
|
||||
function createThumbnail(
|
||||
element: ThumbnailableElement,
|
||||
inputWidth: number,
|
||||
inputHeight: number,
|
||||
mimeType: string,
|
||||
): Promise<IThumbnail> {
|
||||
return new Promise((resolve) => {
|
||||
let targetWidth = inputWidth;
|
||||
let targetHeight = inputHeight;
|
||||
|
@ -437,11 +442,13 @@ export default class ContentMessages {
|
|||
for (let i = 0; i < okFiles.length; ++i) {
|
||||
const file = okFiles[i];
|
||||
if (!uploadAll) {
|
||||
const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, {
|
||||
file,
|
||||
currentIndex: i,
|
||||
totalFiles: okFiles.length,
|
||||
});
|
||||
const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation',
|
||||
'', UploadConfirmDialog, {
|
||||
file,
|
||||
currentIndex: i,
|
||||
totalFiles: okFiles.length,
|
||||
},
|
||||
);
|
||||
const [shouldContinue, shouldUploadAll] = await finished;
|
||||
if (!shouldContinue) break;
|
||||
if (shouldUploadAll) {
|
||||
|
@ -489,7 +496,7 @@ export default class ContentMessages {
|
|||
if (file.type.indexOf('image/') === 0) {
|
||||
content.msgtype = 'm.image';
|
||||
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
|
||||
extend(content.info, imageInfo);
|
||||
Object.assign(content.info, imageInfo);
|
||||
resolve();
|
||||
}, (e) => {
|
||||
console.error(e);
|
||||
|
@ -502,7 +509,7 @@ export default class ContentMessages {
|
|||
} else if (file.type.indexOf('video/') === 0) {
|
||||
content.msgtype = 'm.video';
|
||||
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
|
||||
extend(content.info, videoInfo);
|
||||
Object.assign(content.info, videoInfo);
|
||||
resolve();
|
||||
}, (e) => {
|
||||
content.msgtype = 'm.file';
|
||||
|
|
|
@ -1,248 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
|
||||
import { _t } from './languageHandler';
|
||||
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
|
||||
|
||||
// This stores the secret storage private keys in memory for the JS SDK. This is
|
||||
// only meant to act as a cache to avoid prompting the user multiple times
|
||||
// during the same single operation. Use `accessSecretStorage` below to scope a
|
||||
// single secret storage operation, as it will clear the cached keys once the
|
||||
// operation ends.
|
||||
let secretStorageKeys = {};
|
||||
let secretStorageBeingAccessed = false;
|
||||
|
||||
function isCachingAllowed() {
|
||||
return secretStorageBeingAccessed;
|
||||
}
|
||||
|
||||
export class AccessCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Secret storage access canceled");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmToDismiss() {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const [sure] = await Modal.createDialog(QuestionDialog, {
|
||||
title: _t("Cancel entering passphrase?"),
|
||||
description: _t("Are you sure you want to cancel entering passphrase?"),
|
||||
danger: false,
|
||||
button: _t("Go Back"),
|
||||
cancelButton: _t("Cancel"),
|
||||
}).finished;
|
||||
return !sure;
|
||||
}
|
||||
|
||||
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
||||
const keyInfoEntries = Object.entries(keyInfos);
|
||||
if (keyInfoEntries.length > 1) {
|
||||
throw new Error("Multiple storage key requests not implemented");
|
||||
}
|
||||
const [name, info] = keyInfoEntries[0];
|
||||
|
||||
// Check the in-memory cache
|
||||
if (isCachingAllowed() && secretStorageKeys[name]) {
|
||||
return [name, secretStorageKeys[name]];
|
||||
}
|
||||
|
||||
const inputToKey = async ({ passphrase, recoveryKey }) => {
|
||||
if (passphrase) {
|
||||
return deriveKey(
|
||||
passphrase,
|
||||
info.passphrase.salt,
|
||||
info.passphrase.iterations,
|
||||
);
|
||||
} else {
|
||||
return decodeRecoveryKey(recoveryKey);
|
||||
}
|
||||
};
|
||||
const AccessSecretStorageDialog =
|
||||
sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog");
|
||||
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
||||
AccessSecretStorageDialog,
|
||||
/* props= */
|
||||
{
|
||||
keyInfo: info,
|
||||
checkPrivateKey: async (input) => {
|
||||
const key = await inputToKey(input);
|
||||
return await MatrixClientPeg.get().checkSecretStorageKey(key, info);
|
||||
},
|
||||
},
|
||||
/* className= */ null,
|
||||
/* isPriorityModal= */ false,
|
||||
/* isStaticModal= */ false,
|
||||
/* options= */ {
|
||||
onBeforeClose: async (reason) => {
|
||||
if (reason === "backgroundClick") {
|
||||
return confirmToDismiss();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [input] = await finished;
|
||||
if (!input) {
|
||||
throw new AccessCancelledError();
|
||||
}
|
||||
const key = await inputToKey(input);
|
||||
|
||||
// Save to cache to avoid future prompts in the current session
|
||||
if (isCachingAllowed()) {
|
||||
secretStorageKeys[name] = key;
|
||||
}
|
||||
|
||||
return [name, key];
|
||||
}
|
||||
|
||||
const onSecretRequested = async function({
|
||||
user_id: userId,
|
||||
device_id: deviceId,
|
||||
request_id: requestId,
|
||||
name,
|
||||
device_trust: deviceTrust,
|
||||
}) {
|
||||
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (userId !== client.getUserId()) {
|
||||
return;
|
||||
}
|
||||
if (!deviceTrust || !deviceTrust.isVerified()) {
|
||||
console.log(`CrossSigningManager: Ignoring request from untrusted device ${deviceId}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === "m.cross_signing.master" ||
|
||||
name === "m.cross_signing.self_signing" ||
|
||||
name === "m.cross_signing.user_signing"
|
||||
) {
|
||||
const callbacks = client.getCrossSigningCacheCallbacks();
|
||||
if (!callbacks.getCrossSigningKeyCache) return;
|
||||
const keyId = name.replace("m.cross_signing.", "");
|
||||
const key = await callbacks.getCrossSigningKeyCache(keyId);
|
||||
if (!key) {
|
||||
console.log(
|
||||
`${keyId} requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
return key && encodeBase64(key);
|
||||
} else if (name === "m.megolm_backup.v1") {
|
||||
const key = await client._crypto.getSessionBackupPrivateKey();
|
||||
if (!key) {
|
||||
console.log(
|
||||
`session backup key requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
return key && encodeBase64(key);
|
||||
}
|
||||
console.warn("onSecretRequested didn't recognise the secret named ", name);
|
||||
};
|
||||
|
||||
export const crossSigningCallbacks = {
|
||||
getSecretStorageKey,
|
||||
onSecretRequested,
|
||||
};
|
||||
|
||||
export async function promptForBackupPassphrase() {
|
||||
let key;
|
||||
|
||||
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
|
||||
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
|
||||
showSummary: false, keyCallback: k => key = k,
|
||||
}, null, /* priority = */ false, /* static = */ true);
|
||||
|
||||
const success = await finished;
|
||||
if (!success) throw new Error("Key backup prompt cancelled");
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper should be used whenever you need to access secret storage. It
|
||||
* ensures that secret storage (and also cross-signing since they each depend on
|
||||
* each other in a cycle of sorts) have been bootstrapped before running the
|
||||
* provided function.
|
||||
*
|
||||
* Bootstrapping secret storage may take one of these paths:
|
||||
* 1. Create secret storage from a passphrase and store cross-signing keys
|
||||
* in secret storage.
|
||||
* 2. Access existing secret storage by requesting passphrase and accessing
|
||||
* cross-signing keys as needed.
|
||||
* 3. All keys are loaded and there's nothing to do.
|
||||
*
|
||||
* Additionally, the secret storage keys are cached during the scope of this function
|
||||
* to ensure the user is prompted only once for their secret storage
|
||||
* passphrase. The cache is then cleared once the provided function completes.
|
||||
*
|
||||
* @param {Function} [func] An operation to perform once secret storage has been
|
||||
* bootstrapped. Optional.
|
||||
* @param {bool} [forceReset] Reset secret storage even if it's already set up
|
||||
*/
|
||||
export async function accessSecretStorage(func = async () => { }, forceReset = false) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
secretStorageBeingAccessed = true;
|
||||
try {
|
||||
if (!await cli.hasSecretStorageKey() || forceReset) {
|
||||
// This dialog calls bootstrap itself after guiding the user through
|
||||
// passphrase creation.
|
||||
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
|
||||
import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"),
|
||||
{
|
||||
force: forceReset,
|
||||
},
|
||||
null, /* priority = */ false, /* static = */ true,
|
||||
);
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Secret storage creation canceled");
|
||||
}
|
||||
} else {
|
||||
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
|
||||
await cli.bootstrapSecretStorage({
|
||||
authUploadDeviceSigningKeys: async (makeRequest) => {
|
||||
const { finished } = Modal.createTrackedDialog(
|
||||
'Cross-signing keys dialog', '', InteractiveAuthDialog,
|
||||
{
|
||||
title: _t("Setting up keys"),
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
makeRequest,
|
||||
},
|
||||
);
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
},
|
||||
getBackupPassphrase: promptForBackupPassphrase,
|
||||
});
|
||||
}
|
||||
|
||||
// `return await` needed here to ensure `finally` block runs after the
|
||||
// inner operation completes.
|
||||
return await func();
|
||||
} finally {
|
||||
// Clear secret storage key cache now that work is complete
|
||||
secretStorageBeingAccessed = false;
|
||||
if (!isCachingAllowed()) {
|
||||
secretStorageKeys = {};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
function getDaysArray() {
|
||||
function getDaysArray(): string[] {
|
||||
return [
|
||||
_t('Sun'),
|
||||
_t('Mon'),
|
||||
|
@ -29,7 +29,7 @@ function getDaysArray() {
|
|||
];
|
||||
}
|
||||
|
||||
function getMonthsArray() {
|
||||
function getMonthsArray(): string[] {
|
||||
return [
|
||||
_t('Jan'),
|
||||
_t('Feb'),
|
||||
|
@ -46,11 +46,11 @@ function getMonthsArray() {
|
|||
];
|
||||
}
|
||||
|
||||
function pad(n) {
|
||||
function pad(n: number): string {
|
||||
return (n < 10 ? '0' : '') + n;
|
||||
}
|
||||
|
||||
function twelveHourTime(date, showSeconds=false) {
|
||||
function twelveHourTime(date: Date, showSeconds = false): string {
|
||||
let hours = date.getHours() % 12;
|
||||
const minutes = pad(date.getMinutes());
|
||||
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
|
||||
|
@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) {
|
|||
return `${hours}:${minutes}${ampm}`;
|
||||
}
|
||||
|
||||
export function formatDate(date, showTwelveHour=false) {
|
||||
export function formatDate(date: Date, showTwelveHour = false): string {
|
||||
const now = new Date();
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
|
@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) {
|
|||
return formatFullDate(date, showTwelveHour);
|
||||
}
|
||||
|
||||
export function formatFullDateNoTime(date) {
|
||||
export function formatFullDateNoTime(date: Date): string {
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
|
||||
|
@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) {
|
|||
});
|
||||
}
|
||||
|
||||
export function formatFullDate(date, showTwelveHour=false) {
|
||||
export function formatFullDate(date: Date, showTwelveHour = false): string {
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
|
||||
|
@ -109,14 +109,14 @@ export function formatFullDate(date, showTwelveHour=false) {
|
|||
});
|
||||
}
|
||||
|
||||
export function formatFullTime(date, showTwelveHour=false) {
|
||||
export function formatFullTime(date: Date, showTwelveHour = false): string {
|
||||
if (showTwelveHour) {
|
||||
return twelveHourTime(date, true);
|
||||
}
|
||||
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
|
||||
}
|
||||
|
||||
export function formatTime(date, showTwelveHour=false) {
|
||||
export function formatTime(date: Date, showTwelveHour = false): string {
|
||||
if (showTwelveHour) {
|
||||
return twelveHourTime(date);
|
||||
}
|
||||
|
@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) {
|
|||
}
|
||||
|
||||
const MILLIS_IN_DAY = 86400000;
|
||||
export function wantsDateSeparator(prevEventDate, nextEventDate) {
|
||||
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
|
||||
if (!nextEventDate || !prevEventDate) {
|
||||
return false;
|
||||
}
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import {
|
||||
hideToast as hideBulkUnverifiedSessionsToast,
|
||||
showToast as showBulkUnverifiedSessionsToast,
|
||||
|
@ -28,11 +29,15 @@ import {
|
|||
hideToast as hideUnverifiedSessionsToast,
|
||||
showToast as showUnverifiedSessionsToast,
|
||||
} from "./toasts/UnverifiedSessionToast";
|
||||
import {privateShouldBeEncrypted} from "./createRoom";
|
||||
import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityManager";
|
||||
import { isSecureBackupRequired } from './utils/WellKnownUtils';
|
||||
import { isLoggedIn } from './components/structures/MatrixChat';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
export default class DeviceListener {
|
||||
private dispatcherRef: string;
|
||||
// device IDs for which the user has dismissed the verify toast ('Later')
|
||||
private dismissed = new Set<string>();
|
||||
// has the user dismissed any of the various nag toasts to setup encryption on this device?
|
||||
|
@ -60,6 +65,8 @@ export default class DeviceListener {
|
|||
MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
|
||||
MatrixClientPeg.get().on('accountData', this._onAccountData);
|
||||
MatrixClientPeg.get().on('sync', this._onSync);
|
||||
MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents);
|
||||
this.dispatcherRef = dis.register(this._onAction);
|
||||
this._recheck();
|
||||
}
|
||||
|
||||
|
@ -72,6 +79,11 @@ export default class DeviceListener {
|
|||
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
|
||||
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
|
||||
MatrixClientPeg.get().removeListener('sync', this._onSync);
|
||||
MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents);
|
||||
}
|
||||
if (this.dispatcherRef) {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.dispatcherRef = null;
|
||||
}
|
||||
this.dismissed.clear();
|
||||
this.dismissedThisDeviceToast = false;
|
||||
|
@ -158,6 +170,21 @@ export default class DeviceListener {
|
|||
if (state === 'PREPARED' && prevState === null) this._recheck();
|
||||
};
|
||||
|
||||
_onRoomStateEvents = (ev: MatrixEvent) => {
|
||||
if (ev.getType() !== "m.room.encryption") {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a room changes to encrypted, re-check as it may be our first
|
||||
// encrypted room. This also catches encrypted room creation as well.
|
||||
this._recheck();
|
||||
};
|
||||
|
||||
_onAction = ({ action }) => {
|
||||
if (action !== "on_logged_in") return;
|
||||
this._recheck();
|
||||
};
|
||||
|
||||
// The server doesn't tell us when key backup is set up, so we poll
|
||||
// & cache the result
|
||||
async _getKeyBackupInfo() {
|
||||
|
@ -170,9 +197,10 @@ export default class DeviceListener {
|
|||
}
|
||||
|
||||
private shouldShowSetupEncryptionToast() {
|
||||
// In a default configuration, show the toasts. If the well-known config causes e2ee default to be false
|
||||
// then do not show the toasts until user is in at least one encrypted room.
|
||||
if (privateShouldBeEncrypted()) return true;
|
||||
// If we're in the middle of a secret storage operation, we're likely
|
||||
// modifying the state involved here, so don't add new toasts to setup.
|
||||
if (isSecretStorageBeingAccessed()) return false;
|
||||
// Show setup toasts once the user is in at least one encrypted room.
|
||||
const cli = MatrixClientPeg.get();
|
||||
return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId));
|
||||
}
|
||||
|
@ -189,15 +217,20 @@ export default class DeviceListener {
|
|||
if (!cli.isInitialSyncComplete()) return;
|
||||
|
||||
const crossSigningReady = await cli.isCrossSigningReady();
|
||||
const secretStorageReady = await cli.isSecretStorageReady();
|
||||
const allSystemsReady = crossSigningReady && secretStorageReady;
|
||||
|
||||
if (this.dismissedThisDeviceToast || crossSigningReady) {
|
||||
if (this.dismissedThisDeviceToast || allSystemsReady) {
|
||||
hideSetupEncryptionToast();
|
||||
} else if (this.shouldShowSetupEncryptionToast()) {
|
||||
// make sure our keys are finished downloading
|
||||
await cli.downloadKeys([cli.getUserId()]);
|
||||
// cross signing isn't enabled - nag to enable it
|
||||
// There are 3 different toasts for:
|
||||
if (cli.getStoredCrossSigningForUser(cli.getUserId())) {
|
||||
if (
|
||||
!cli.getCrossSigningId() &&
|
||||
cli.getStoredCrossSigningForUser(cli.getUserId())
|
||||
) {
|
||||
// Cross-signing on account but this device doesn't trust the master key (verify this session)
|
||||
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
|
||||
} else {
|
||||
|
@ -207,7 +240,15 @@ export default class DeviceListener {
|
|||
showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION);
|
||||
} else {
|
||||
// No cross-signing or key backup on account (set up encryption)
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
|
||||
await cli.waitForClientWellKnown();
|
||||
if (isSecureBackupRequired() && isLoggedIn()) {
|
||||
// If we're meant to set up, and Secure Backup is required,
|
||||
// trigger the flow directly without a toast once logged in.
|
||||
hideSetupEncryptionToast();
|
||||
accessSecretStorage();
|
||||
} else {
|
||||
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,275 +0,0 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Travis Ralston
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import URL from 'url';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
|
||||
import ActiveWidgetStore from './stores/ActiveWidgetStore';
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import {Capability} from "./widgets/WidgetApi";
|
||||
import {objectClone} from "./utils/objects";
|
||||
|
||||
const WIDGET_API_VERSION = '0.0.2'; // Current API version
|
||||
const SUPPORTED_WIDGET_API_VERSIONS = [
|
||||
'0.0.1',
|
||||
'0.0.2',
|
||||
];
|
||||
const INBOUND_API_NAME = 'fromWidget';
|
||||
|
||||
// Listen for and handle incoming requests using the 'fromWidget' postMessage
|
||||
// API and initiate responses
|
||||
export default class FromWidgetPostMessageApi {
|
||||
constructor() {
|
||||
this.widgetMessagingEndpoints = [];
|
||||
this.widgetListeners = {}; // {action: func[]}
|
||||
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.onPostMessage = this.onPostMessage.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
window.addEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a listener for a given action
|
||||
* @param {string} action The action to listen for.
|
||||
* @param {Function} callbackFn A callback function to be called when the action is
|
||||
* encountered. Called with two parameters: the interesting request information and
|
||||
* the raw event received from the postMessage API. The raw event is meant to be used
|
||||
* for sendResponse and similar functions.
|
||||
*/
|
||||
addListener(action, callbackFn) {
|
||||
if (!this.widgetListeners[action]) this.widgetListeners[action] = [];
|
||||
this.widgetListeners[action].push(callbackFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a listener for a given action.
|
||||
* @param {string} action The action that was subscribed to.
|
||||
* @param {Function} callbackFn The original callback function that was used to subscribe
|
||||
* to updates.
|
||||
*/
|
||||
removeListener(action, callbackFn) {
|
||||
if (!this.widgetListeners[action]) return;
|
||||
|
||||
const idx = this.widgetListeners[action].indexOf(callbackFn);
|
||||
if (idx !== -1) this.widgetListeners[action].splice(idx, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a widget endpoint for trusted postMessage communication
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
*/
|
||||
addEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
const endpoint = new WidgetMessagingEndpoint(widgetId, origin);
|
||||
if (this.widgetMessagingEndpoints.some(function(ep) {
|
||||
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
|
||||
})) {
|
||||
// Message endpoint already registered
|
||||
console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
|
||||
return;
|
||||
} else {
|
||||
console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
|
||||
this.widgetMessagingEndpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* De-register a widget endpoint from trusted communication sources
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
* @return {boolean} True if endpoint was successfully removed
|
||||
*/
|
||||
removeEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn('Remove widget messaging endpoint - Invalid origin');
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) {
|
||||
const length = this.widgetMessagingEndpoints.length;
|
||||
this.widgetMessagingEndpoints = this.widgetMessagingEndpoints
|
||||
.filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin);
|
||||
return (length > this.widgetMessagingEndpoints.length);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle widget postMessage events
|
||||
* Messages are only handled where a valid, registered messaging endpoints
|
||||
* @param {Event} event Event to handle
|
||||
* @return {undefined}
|
||||
*/
|
||||
onPostMessage(event) {
|
||||
if (!event.origin) { // Handle chrome
|
||||
event.origin = event.originalEvent.origin;
|
||||
}
|
||||
|
||||
// Event origin is empty string if undefined
|
||||
if (
|
||||
event.origin.length === 0 ||
|
||||
!this.trustedEndpoint(event.origin) ||
|
||||
event.data.api !== INBOUND_API_NAME ||
|
||||
!event.data.widgetId
|
||||
) {
|
||||
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
|
||||
}
|
||||
|
||||
// Call any listeners we have registered
|
||||
if (this.widgetListeners[event.data.action]) {
|
||||
for (const fn of this.widgetListeners[event.data.action]) {
|
||||
fn(event.data, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Although the requestId is required, we don't use it. We'll be nice and process the message
|
||||
// if the property is missing, but with a warning for widget developers.
|
||||
if (!event.data.requestId) {
|
||||
console.warn("fromWidget action '" + event.data.action + "' does not have a requestId");
|
||||
}
|
||||
|
||||
const action = event.data.action;
|
||||
const widgetId = event.data.widgetId;
|
||||
if (action === 'content_loaded') {
|
||||
console.log('Widget reported content loaded for', widgetId);
|
||||
dis.dispatch({
|
||||
action: 'widget_content_loaded',
|
||||
widgetId: widgetId,
|
||||
});
|
||||
this.sendResponse(event, {success: true});
|
||||
} else if (action === 'supported_api_versions') {
|
||||
this.sendResponse(event, {
|
||||
api: INBOUND_API_NAME,
|
||||
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
|
||||
});
|
||||
} else if (action === 'api_version') {
|
||||
this.sendResponse(event, {
|
||||
api: INBOUND_API_NAME,
|
||||
version: WIDGET_API_VERSION,
|
||||
});
|
||||
} else if (action === 'm.sticker') {
|
||||
// console.warn('Got sticker message from widget', widgetId);
|
||||
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
|
||||
const data = event.data.data || event.data.widgetData;
|
||||
dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId});
|
||||
} else if (action === 'integration_manager_open') {
|
||||
// Close the stickerpicker
|
||||
dis.dispatch({action: 'stickerpicker_close'});
|
||||
// Open the integration manager
|
||||
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
|
||||
const data = event.data.data || event.data.widgetData;
|
||||
const integType = (data && data.integType) ? data.integType : null;
|
||||
const integId = (data && data.integId) ? data.integId : null;
|
||||
|
||||
// TODO: Open the right integration manager for the widget
|
||||
if (SettingsStore.getValue("feature_many_integration_managers")) {
|
||||
IntegrationManagers.sharedInstance().openAll(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
} else {
|
||||
IntegrationManagers.sharedInstance().getPrimaryManager().open(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
}
|
||||
} else if (action === 'set_always_on_screen') {
|
||||
// This is a new message: there is no reason to support the deprecated widgetData here
|
||||
const data = event.data.data;
|
||||
const val = data.value;
|
||||
|
||||
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
|
||||
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
|
||||
}
|
||||
} else if (action === 'get_openid') {
|
||||
// Handled by caller
|
||||
} else {
|
||||
console.warn('Widget postMessage event unhandled');
|
||||
this.sendError(event, {message: 'The postMessage was unhandled'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message origin is registered as trusted
|
||||
* @param {string} origin PostMessage origin to check
|
||||
* @return {boolean} True if trusted
|
||||
*/
|
||||
trustedEndpoint(origin) {
|
||||
if (!origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.widgetMessagingEndpoints.some((endpoint) => {
|
||||
// TODO / FIXME -- Should this also check the widgetId?
|
||||
return endpoint.endpointUrl === origin;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a postmessage response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {Object} res Response data
|
||||
*/
|
||||
sendResponse(event, res) {
|
||||
const data = objectClone(event.data);
|
||||
data.response = res;
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {string} msg Error message
|
||||
* @param {Error} nestedError Nested error event (optional)
|
||||
*/
|
||||
sendError(event, msg, nestedError) {
|
||||
console.error('Action:' + event.data.action + ' failed with message: ' + msg);
|
||||
const data = objectClone(event.data);
|
||||
data.response = {
|
||||
error: {
|
||||
message: msg,
|
||||
},
|
||||
};
|
||||
if (nestedError) {
|
||||
data.response.error._error = nestedError;
|
||||
}
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
}
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
|
||||
import * as linkify from 'linkifyjs';
|
||||
import linkifyMatrix from './linkify-matrix';
|
||||
import _linkifyElement from 'linkifyjs/element';
|
||||
|
@ -52,7 +53,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
|
|||
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||
|
||||
/*
|
||||
* Return true if the given string contains emoji
|
||||
|
@ -151,7 +152,7 @@ export function isUrlPermitted(inputUrl: string) {
|
|||
}
|
||||
}
|
||||
|
||||
const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
|
||||
const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to matrix
|
||||
// add blank targets to all hyperlinks except vector URLs
|
||||
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
if (attribs.href) {
|
||||
|
@ -224,7 +225,7 @@ const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to mat
|
|||
},
|
||||
};
|
||||
|
||||
const sanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||
const sanitizeHtmlParams: IExtendedSanitizeOptions = {
|
||||
allowedTags: [
|
||||
'font', // custom to matrix for IRC-style font coloring
|
||||
'del', // for markdown
|
||||
|
@ -245,13 +246,14 @@ const sanitizeHtmlParams: sanitizeHtml.IOptions = {
|
|||
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
||||
// URL schemes we permit
|
||||
allowedSchemes: PERMITTED_URL_SCHEMES,
|
||||
|
||||
allowProtocolRelative: false,
|
||||
transformTags,
|
||||
// 50 levels deep "should be enough for anyone"
|
||||
nestingLimit: 50,
|
||||
};
|
||||
|
||||
// this is the same as the above except with less rewriting
|
||||
const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||
const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
|
||||
...sanitizeHtmlParams,
|
||||
transformTags: {
|
||||
'code': transformTags['code'],
|
||||
|
@ -339,33 +341,9 @@ class HtmlHighlighter extends BaseHighlighter<string> {
|
|||
}
|
||||
}
|
||||
|
||||
class TextHighlighter extends BaseHighlighter<React.ReactNode> {
|
||||
private key = 0;
|
||||
|
||||
/* create a <span> node to hold the given content
|
||||
*
|
||||
* snippet: content of the span
|
||||
* highlight: true to highlight as a search match
|
||||
*
|
||||
* returns a React node
|
||||
*/
|
||||
protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
|
||||
const key = this.key++;
|
||||
|
||||
let node = <span key={key} className={highlight ? this.highlightClass : null}>
|
||||
{ snippet }
|
||||
</span>;
|
||||
|
||||
if (highlight && this.highlightLink) {
|
||||
node = <a key={key} href={this.highlightLink}>{ node }</a>;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
interface IContent {
|
||||
format?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
formatted_body?: string;
|
||||
body: string;
|
||||
}
|
||||
|
@ -474,8 +452,13 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
|
|||
});
|
||||
|
||||
return isDisplayedWithHtml ?
|
||||
<span key="body" ref={opts.ref} className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
|
||||
<span key="body" ref={opts.ref} className={className} dir="auto">{ strippedBody }</span>;
|
||||
<span
|
||||
key="body"
|
||||
ref={opts.ref}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: safeBody }}
|
||||
dir="auto"
|
||||
/> : <span key="body" ref={opts.ref} className={className} dir="auto">{ strippedBody }</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -17,9 +17,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg';
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
import EventIndexPeg from './indexing/EventIndexPeg';
|
||||
import createMatrixClient from './utils/createMatrixClient';
|
||||
import Analytics from './Analytics';
|
||||
|
@ -42,48 +46,51 @@ 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";
|
||||
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
|
||||
|
||||
const HOMESERVER_URL_KEY = "mx_hs_url";
|
||||
const ID_SERVER_URL_KEY = "mx_is_url";
|
||||
|
||||
interface ILoadSessionOpts {
|
||||
enableGuest?: boolean;
|
||||
guestHsUrl?: string;
|
||||
guestIsUrl?: string;
|
||||
ignoreGuest?: boolean;
|
||||
defaultDeviceDisplayName?: string;
|
||||
fragmentQueryParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at startup, to attempt to build a logged-in Matrix session. It tries
|
||||
* a number of things:
|
||||
*
|
||||
*
|
||||
* 1. if we have a guest access token in the fragment query params, it uses
|
||||
* that.
|
||||
*
|
||||
* 2. if an access token is stored in local storage (from a previous session),
|
||||
* it uses that.
|
||||
*
|
||||
* 3. it attempts to auto-register as a guest user.
|
||||
*
|
||||
* If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in
|
||||
* turn will raise on_logged_in and will_start_client events.
|
||||
*
|
||||
* @param {object} opts
|
||||
*
|
||||
* @param {object} opts.fragmentQueryParams: string->string map of the
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.fragmentQueryParams]: string->string map of the
|
||||
* query-parameters extracted from the #-fragment of the starting URI.
|
||||
*
|
||||
* @param {boolean} opts.enableGuest: set to true to enable guest access tokens
|
||||
* and auto-guest registrations.
|
||||
*
|
||||
* @params {string} opts.guestHsUrl: homeserver URL. Only used if enableGuest is
|
||||
* true; defines the HS to register against.
|
||||
*
|
||||
* @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is
|
||||
* true; defines the IS to use.
|
||||
*
|
||||
* @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore
|
||||
* it and don't load it.
|
||||
*
|
||||
* @param {boolean} [opts.enableGuest]: set to true to enable guest access
|
||||
* tokens and auto-guest registrations.
|
||||
* @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest
|
||||
* is true; defines the HS to register against.
|
||||
* @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest
|
||||
* is true; defines the IS to use.
|
||||
* @param {bool} [opts.ignoreGuest]: If the stored session is a guest account,
|
||||
* ignore it and don't load it.
|
||||
* @param {string} [opts.defaultDeviceDisplayName]: Default display name to use
|
||||
* when registering as a guest.
|
||||
* @returns {Promise} a promise which resolves when the above process completes.
|
||||
* Resolves to `true` if we ended up starting a session, or `false` if we
|
||||
* failed.
|
||||
*/
|
||||
export async function loadSession(opts) {
|
||||
export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean> {
|
||||
try {
|
||||
let enableGuest = opts.enableGuest || false;
|
||||
const guestHsUrl = opts.guestHsUrl;
|
||||
|
@ -96,12 +103,13 @@ export async function loadSession(opts) {
|
|||
enableGuest = false;
|
||||
}
|
||||
|
||||
if (enableGuest &&
|
||||
if (
|
||||
enableGuest &&
|
||||
fragmentQueryParams.guest_user_id &&
|
||||
fragmentQueryParams.guest_access_token
|
||||
) {
|
||||
) {
|
||||
console.log("Using guest access credentials");
|
||||
return _doSetLoggedIn({
|
||||
return doSetLoggedIn({
|
||||
userId: fragmentQueryParams.guest_user_id,
|
||||
accessToken: fragmentQueryParams.guest_access_token,
|
||||
homeserverUrl: guestHsUrl,
|
||||
|
@ -109,7 +117,7 @@ export async function loadSession(opts) {
|
|||
guest: true,
|
||||
}, true).then(() => true);
|
||||
}
|
||||
const success = await _restoreFromLocalStorage({
|
||||
const success = await restoreFromLocalStorage({
|
||||
ignoreGuest: Boolean(opts.ignoreGuest),
|
||||
});
|
||||
if (success) {
|
||||
|
@ -117,7 +125,7 @@ export async function loadSession(opts) {
|
|||
}
|
||||
|
||||
if (enableGuest) {
|
||||
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
|
||||
return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
|
||||
}
|
||||
|
||||
// fall back to welcome screen
|
||||
|
@ -128,7 +136,7 @@ export async function loadSession(opts) {
|
|||
// need to show the general failure dialog. Instead, just go back to welcome.
|
||||
return false;
|
||||
}
|
||||
return _handleLoadSessionFailure(e);
|
||||
return handleLoadSessionFailure(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,7 +146,7 @@ export async function loadSession(opts) {
|
|||
* is associated with them. The session is not loaded.
|
||||
* @returns {String} The persisted session's owner, if an owner exists. Null otherwise.
|
||||
*/
|
||||
export function getStoredSessionOwner() {
|
||||
export function getStoredSessionOwner(): string {
|
||||
const {hsUrl, userId, accessToken} = getLocalStorageSessionVars();
|
||||
return hsUrl && userId && accessToken ? userId : null;
|
||||
}
|
||||
|
@ -147,7 +155,7 @@ export function getStoredSessionOwner() {
|
|||
* @returns {bool} True if the stored session is for a guest user or false if it is
|
||||
* for a real user. If there is no stored session, return null.
|
||||
*/
|
||||
export function getStoredSessionIsGuest() {
|
||||
export function getStoredSessionIsGuest(): boolean {
|
||||
const sessVars = getLocalStorageSessionVars();
|
||||
return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null;
|
||||
}
|
||||
|
@ -162,7 +170,10 @@ export function getStoredSessionIsGuest() {
|
|||
* @returns {Promise} promise which resolves to true if we completed the token
|
||||
* login, else false
|
||||
*/
|
||||
export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
|
||||
export function attemptTokenLogin(
|
||||
queryParams: Record<string, string>,
|
||||
defaultDeviceDisplayName?: string,
|
||||
): Promise<boolean> {
|
||||
if (!queryParams.loginToken) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
@ -183,8 +194,10 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
|
|||
},
|
||||
).then(function(creds) {
|
||||
console.log("Logged in with token");
|
||||
return _clearStorage().then(() => {
|
||||
_persistCredentialsToLocalStorage(creds);
|
||||
return clearStorage().then(() => {
|
||||
persistCredentialsToLocalStorage(creds);
|
||||
// remember that we just logged in
|
||||
sessionStorage.setItem("mx_fresh_login", String(true));
|
||||
return true;
|
||||
});
|
||||
}).catch((err) => {
|
||||
|
@ -194,8 +207,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
|
|||
});
|
||||
}
|
||||
|
||||
export function handleInvalidStoreError(e) {
|
||||
if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) {
|
||||
export function handleInvalidStoreError(e: InvalidStoreError): Promise<void> {
|
||||
if (e.reason === InvalidStoreError.TOGGLED_LAZY_LOADING) {
|
||||
return Promise.resolve().then(() => {
|
||||
const lazyLoadEnabled = e.value;
|
||||
if (lazyLoadEnabled) {
|
||||
|
@ -228,7 +241,11 @@ export function handleInvalidStoreError(e) {
|
|||
}
|
||||
}
|
||||
|
||||
function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
|
||||
function registerAsGuest(
|
||||
hsUrl: string,
|
||||
isUrl: string,
|
||||
defaultDeviceDisplayName: string,
|
||||
): Promise<boolean> {
|
||||
console.log(`Doing guest login on ${hsUrl}`);
|
||||
|
||||
// create a temporary MatrixClient to do the login
|
||||
|
@ -242,7 +259,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
|
|||
},
|
||||
}).then((creds) => {
|
||||
console.log(`Registered as guest: ${creds.user_id}`);
|
||||
return _doSetLoggedIn({
|
||||
return doSetLoggedIn({
|
||||
userId: creds.user_id,
|
||||
deviceId: creds.device_id,
|
||||
accessToken: creds.access_token,
|
||||
|
@ -256,12 +273,21 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
|
|||
});
|
||||
}
|
||||
|
||||
export interface ILocalStorageSession {
|
||||
hsUrl: string;
|
||||
isUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
isGuest: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves information about the stored session in localstorage. The session
|
||||
* may not be valid, as it is not tested for consistency here.
|
||||
* @returns {Object} Information about the session - see implementation for variables.
|
||||
*/
|
||||
export function getLocalStorageSessionVars() {
|
||||
export function getLocalStorageSessionVars(): ILocalStorageSession {
|
||||
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
|
||||
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
|
||||
const accessToken = localStorage.getItem("mx_access_token");
|
||||
|
@ -289,8 +315,8 @@ export function getLocalStorageSessionVars() {
|
|||
// The plan is to gradually move the localStorage access done here into
|
||||
// SessionStore to avoid bugs where the view becomes out-of-sync with
|
||||
// localStorage (e.g. isGuest etc.)
|
||||
async function _restoreFromLocalStorage(opts) {
|
||||
const ignoreGuest = opts.ignoreGuest;
|
||||
async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise<boolean> {
|
||||
const ignoreGuest = opts?.ignoreGuest;
|
||||
|
||||
if (!localStorage) {
|
||||
return false;
|
||||
|
@ -311,8 +337,11 @@ async function _restoreFromLocalStorage(opts) {
|
|||
console.log("No pickle key available");
|
||||
}
|
||||
|
||||
const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true";
|
||||
sessionStorage.removeItem("mx_fresh_login");
|
||||
|
||||
console.log(`Restoring session for ${userId}`);
|
||||
await _doSetLoggedIn({
|
||||
await doSetLoggedIn({
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
accessToken: accessToken,
|
||||
|
@ -320,6 +349,7 @@ async function _restoreFromLocalStorage(opts) {
|
|||
identityServerUrl: isUrl,
|
||||
guest: isGuest,
|
||||
pickleKey: pickleKey,
|
||||
freshLogin: freshLogin,
|
||||
}, false);
|
||||
return true;
|
||||
} else {
|
||||
|
@ -328,7 +358,7 @@ async function _restoreFromLocalStorage(opts) {
|
|||
}
|
||||
}
|
||||
|
||||
async function _handleLoadSessionFailure(e) {
|
||||
async function handleLoadSessionFailure(e: Error): Promise<boolean> {
|
||||
console.error("Unable to load session", e);
|
||||
|
||||
const SessionRestoreErrorDialog =
|
||||
|
@ -341,7 +371,7 @@ async function _handleLoadSessionFailure(e) {
|
|||
const [success] = await modal.finished;
|
||||
if (success) {
|
||||
// user clicked continue.
|
||||
await _clearStorage();
|
||||
await clearStorage();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -362,11 +392,12 @@ async function _handleLoadSessionFailure(e) {
|
|||
*
|
||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||
*/
|
||||
export async function setLoggedIn(credentials) {
|
||||
export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<MatrixClient> {
|
||||
credentials.freshLogin = true;
|
||||
stopMatrixClient();
|
||||
const pickleKey = credentials.userId && credentials.deviceId
|
||||
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
|
||||
: null;
|
||||
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
|
||||
: null;
|
||||
|
||||
if (pickleKey) {
|
||||
console.log("Created pickle key");
|
||||
|
@ -374,7 +405,7 @@ export async function setLoggedIn(credentials) {
|
|||
console.log("Pickle key not created");
|
||||
}
|
||||
|
||||
return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
|
||||
return doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -392,7 +423,7 @@ export async function setLoggedIn(credentials) {
|
|||
*
|
||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||
*/
|
||||
export function hydrateSession(credentials) {
|
||||
export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
|
||||
const oldUserId = MatrixClientPeg.get().getUserId();
|
||||
const oldDeviceId = MatrixClientPeg.get().getDeviceId();
|
||||
|
||||
|
@ -405,7 +436,7 @@ export function hydrateSession(credentials) {
|
|||
console.warn("Clearing all data: Old session belongs to a different user/session");
|
||||
}
|
||||
|
||||
return _doSetLoggedIn(credentials, overwrite);
|
||||
return doSetLoggedIn(credentials, overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -417,7 +448,10 @@ export function hydrateSession(credentials) {
|
|||
*
|
||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||
*/
|
||||
async function _doSetLoggedIn(credentials, clearStorage) {
|
||||
async function doSetLoggedIn(
|
||||
credentials: IMatrixClientCreds,
|
||||
clearStorageEnabled: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
credentials.guest = Boolean(credentials.guest);
|
||||
|
||||
const softLogout = isSoftLogout();
|
||||
|
@ -428,6 +462,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
" guest: " + credentials.guest +
|
||||
" hs: " + credentials.homeserverUrl +
|
||||
" softLogout: " + softLogout,
|
||||
" freshLogin: " + credentials.freshLogin,
|
||||
);
|
||||
|
||||
// This is dispatched to indicate that the user is still in the process of logging in
|
||||
|
@ -439,8 +474,8 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
// (dis.dispatch uses `setTimeout`, which does not guarantee ordering.)
|
||||
dis.dispatch({action: 'on_logging_in'}, true);
|
||||
|
||||
if (clearStorage) {
|
||||
await _clearStorage();
|
||||
if (clearStorageEnabled) {
|
||||
await clearStorage();
|
||||
}
|
||||
|
||||
const results = await StorageManager.checkConsistency();
|
||||
|
@ -448,9 +483,9 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
// crypto store, we'll be generally confused when handling encrypted data.
|
||||
// Show a modal recommending a full reset of storage.
|
||||
if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
|
||||
const signOut = await _showStorageEvictedDialog();
|
||||
const signOut = await showStorageEvictedDialog();
|
||||
if (signOut) {
|
||||
await _clearStorage();
|
||||
await clearStorage();
|
||||
// This error feels a bit clunky, but we want to make sure we don't go any
|
||||
// further and instead head back to sign in.
|
||||
throw new AbortLoginAndRebuildStorage(
|
||||
|
@ -461,19 +496,26 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
|
||||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
|
||||
// If we just logged in, try to rehydrate a device instead of using a
|
||||
// new device. If it succeeds, we'll get a new device ID, so make sure
|
||||
// we persist that ID to localStorage
|
||||
const newDeviceId = await client.rehydrateDevice();
|
||||
if (newDeviceId) {
|
||||
credentials.deviceId = newDeviceId;
|
||||
}
|
||||
|
||||
delete credentials.freshLogin;
|
||||
}
|
||||
|
||||
if (localStorage) {
|
||||
try {
|
||||
_persistCredentialsToLocalStorage(credentials);
|
||||
|
||||
// The user registered as a PWLU (PassWord-Less User), the generated password
|
||||
// is cached here such that the user can change it at a later time.
|
||||
if (credentials.password) {
|
||||
// Update SessionStore
|
||||
dis.dispatch({
|
||||
action: 'cached_password',
|
||||
cachedPassword: credentials.password,
|
||||
});
|
||||
}
|
||||
persistCredentialsToLocalStorage(credentials);
|
||||
// make sure we don't think that it's a fresh login any more
|
||||
sessionStorage.removeItem("mx_fresh_login");
|
||||
} catch (e) {
|
||||
console.warn("Error using local storage: can't persist session!", e);
|
||||
}
|
||||
|
@ -481,15 +523,13 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
console.warn("No local storage available: can't persist session!");
|
||||
}
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
|
||||
dis.dispatch({ action: 'on_logged_in' });
|
||||
|
||||
await startMatrixClient(/*startSyncing=*/!softLogout);
|
||||
return MatrixClientPeg.get();
|
||||
return client;
|
||||
}
|
||||
|
||||
function _showStorageEvictedDialog() {
|
||||
function showStorageEvictedDialog(): Promise<boolean> {
|
||||
const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog');
|
||||
return new Promise(resolve => {
|
||||
Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, {
|
||||
|
@ -502,7 +542,7 @@ function _showStorageEvictedDialog() {
|
|||
// `instanceof`. Babel 7 supports this natively in their class handling.
|
||||
class AbortLoginAndRebuildStorage extends Error { }
|
||||
|
||||
function _persistCredentialsToLocalStorage(credentials) {
|
||||
function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void {
|
||||
localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl);
|
||||
if (credentials.identityServerUrl) {
|
||||
localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl);
|
||||
|
@ -512,7 +552,7 @@ function _persistCredentialsToLocalStorage(credentials) {
|
|||
localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
|
||||
|
||||
if (credentials.pickleKey) {
|
||||
localStorage.setItem("mx_has_pickle_key", true);
|
||||
localStorage.setItem("mx_has_pickle_key", String(true));
|
||||
} else {
|
||||
if (localStorage.getItem("mx_has_pickle_key")) {
|
||||
console.error("Expected a pickle key, but none provided. Encryption may not work.");
|
||||
|
@ -528,6 +568,8 @@ function _persistCredentialsToLocalStorage(credentials) {
|
|||
localStorage.setItem("mx_device_id", credentials.deviceId);
|
||||
}
|
||||
|
||||
SecurityCustomisations.persistCredentials?.(credentials);
|
||||
|
||||
console.log(`Session persisted for ${credentials.userId}`);
|
||||
}
|
||||
|
||||
|
@ -536,7 +578,7 @@ let _isLoggingOut = false;
|
|||
/**
|
||||
* Logs the current session out and transitions to the logged-out state
|
||||
*/
|
||||
export function logout() {
|
||||
export function logout(): void {
|
||||
if (!MatrixClientPeg.get()) return;
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
|
@ -565,7 +607,7 @@ export function logout() {
|
|||
);
|
||||
}
|
||||
|
||||
export function softLogout() {
|
||||
export function softLogout(): void {
|
||||
if (!MatrixClientPeg.get()) return;
|
||||
|
||||
// Track that we've detected and trapped a soft logout. This helps prevent other
|
||||
|
@ -586,11 +628,11 @@ export function softLogout() {
|
|||
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
|
||||
}
|
||||
|
||||
export function isSoftLogout() {
|
||||
export function isSoftLogout(): boolean {
|
||||
return localStorage.getItem("mx_soft_logout") === "true";
|
||||
}
|
||||
|
||||
export function isLoggingOut() {
|
||||
export function isLoggingOut(): boolean {
|
||||
return _isLoggingOut;
|
||||
}
|
||||
|
||||
|
@ -600,7 +642,7 @@ export function isLoggingOut() {
|
|||
* @param {boolean} startSyncing True (default) to actually start
|
||||
* syncing the client.
|
||||
*/
|
||||
async function startMatrixClient(startSyncing=true) {
|
||||
async function startMatrixClient(startSyncing = true): Promise<void> {
|
||||
console.log(`Lifecycle: Starting MatrixClient`);
|
||||
|
||||
// dispatch this before starting the matrix client: it's used
|
||||
|
@ -659,24 +701,37 @@ async function startMatrixClient(startSyncing=true) {
|
|||
* Stops a running client and all related services, and clears persistent
|
||||
* storage. Used after a session has been logged out.
|
||||
*/
|
||||
export async function onLoggedOut() {
|
||||
export async function onLoggedOut(): Promise<void> {
|
||||
_isLoggingOut = false;
|
||||
// Ensure that we dispatch a view change **before** stopping the client so
|
||||
// so that React components unmount first. This avoids React soft crashes
|
||||
// that can occur when components try to use a null client.
|
||||
dis.dispatch({action: 'on_logged_out'}, true);
|
||||
stopMatrixClient();
|
||||
await _clearStorage();
|
||||
await clearStorage({deleteEverything: true});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts Options for how to clear storage.
|
||||
* @returns {Promise} promise which resolves once the stores have been cleared
|
||||
*/
|
||||
async function _clearStorage() {
|
||||
async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void> {
|
||||
Analytics.disable();
|
||||
|
||||
if (window.localStorage) {
|
||||
// try to save any 3pid invites from being obliterated
|
||||
const pendingInvites = ThreepidInviteStore.instance.getWireInvites();
|
||||
|
||||
window.localStorage.clear();
|
||||
|
||||
// now restore those invites
|
||||
if (!opts?.deleteEverything) {
|
||||
pendingInvites.forEach(i => {
|
||||
const roomId = i.roomId;
|
||||
delete i.roomId; // delete to avoid confusing the store
|
||||
ThreepidInviteStore.instance.storeInvite(roomId, i);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (window.sessionStorage) {
|
||||
|
@ -698,7 +753,7 @@ async function _clearStorage() {
|
|||
* @param {boolean} unsetClient True (default) to abandon the client
|
||||
* on MatrixClientPeg after stopping.
|
||||
*/
|
||||
export function stopMatrixClient(unsetClient=true) {
|
||||
export function stopMatrixClient(unsetClient = true): void {
|
||||
Notifier.stop();
|
||||
UserActivity.sharedInstance().stop();
|
||||
TypingStore.sharedInstance().reset();
|
|
@ -18,35 +18,73 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
|
||||
import Matrix from "matrix-js-sdk";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
||||
interface ILoginOptions {
|
||||
defaultDeviceDisplayName?: string;
|
||||
}
|
||||
|
||||
// TODO: Move this to JS SDK
|
||||
interface ILoginFlow {
|
||||
type: string;
|
||||
}
|
||||
|
||||
// TODO: Move this to JS SDK
|
||||
/* eslint-disable camelcase */
|
||||
interface ILoginParams {
|
||||
identifier?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
device_id?: string;
|
||||
initial_device_display_name?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export default class Login {
|
||||
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
|
||||
this._hsUrl = hsUrl;
|
||||
this._isUrl = isUrl;
|
||||
this._fallbackHsUrl = fallbackHsUrl;
|
||||
this._currentFlowIndex = 0;
|
||||
this._flows = [];
|
||||
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||
this._tempClient = null; // memoize
|
||||
private hsUrl: string;
|
||||
private isUrl: string;
|
||||
private fallbackHsUrl: string;
|
||||
private currentFlowIndex: number;
|
||||
// TODO: Flows need a type in JS SDK
|
||||
private flows: Array<ILoginFlow>;
|
||||
private defaultDeviceDisplayName: string;
|
||||
private tempClient: MatrixClient;
|
||||
|
||||
constructor(
|
||||
hsUrl: string,
|
||||
isUrl: string,
|
||||
fallbackHsUrl?: string,
|
||||
opts?: ILoginOptions,
|
||||
) {
|
||||
this.hsUrl = hsUrl;
|
||||
this.isUrl = isUrl;
|
||||
this.fallbackHsUrl = fallbackHsUrl;
|
||||
this.currentFlowIndex = 0;
|
||||
this.flows = [];
|
||||
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||
this.tempClient = null; // memoize
|
||||
}
|
||||
|
||||
getHomeserverUrl() {
|
||||
return this._hsUrl;
|
||||
public getHomeserverUrl(): string {
|
||||
return this.hsUrl;
|
||||
}
|
||||
|
||||
getIdentityServerUrl() {
|
||||
return this._isUrl;
|
||||
public getIdentityServerUrl(): string {
|
||||
return this.isUrl;
|
||||
}
|
||||
|
||||
setHomeserverUrl(hsUrl) {
|
||||
this._tempClient = null; // clear memoization
|
||||
this._hsUrl = hsUrl;
|
||||
public setHomeserverUrl(hsUrl: string): void {
|
||||
this.tempClient = null; // clear memoization
|
||||
this.hsUrl = hsUrl;
|
||||
}
|
||||
|
||||
setIdentityServerUrl(isUrl) {
|
||||
this._tempClient = null; // clear memoization
|
||||
this._isUrl = isUrl;
|
||||
public setIdentityServerUrl(isUrl: string): void {
|
||||
this.tempClient = null; // clear memoization
|
||||
this.isUrl = isUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -54,40 +92,41 @@ export default class Login {
|
|||
* requests.
|
||||
* @returns {MatrixClient}
|
||||
*/
|
||||
createTemporaryClient() {
|
||||
if (this._tempClient) return this._tempClient; // use memoization
|
||||
return this._tempClient = Matrix.createClient({
|
||||
baseUrl: this._hsUrl,
|
||||
idBaseUrl: this._isUrl,
|
||||
public createTemporaryClient(): MatrixClient {
|
||||
if (this.tempClient) return this.tempClient; // use memoization
|
||||
return this.tempClient = Matrix.createClient({
|
||||
baseUrl: this.hsUrl,
|
||||
idBaseUrl: this.isUrl,
|
||||
});
|
||||
}
|
||||
|
||||
getFlows() {
|
||||
const self = this;
|
||||
public async getFlows(): Promise<Array<ILoginFlow>> {
|
||||
const client = this.createTemporaryClient();
|
||||
return client.loginFlows().then(function(result) {
|
||||
self._flows = result.flows;
|
||||
self._currentFlowIndex = 0;
|
||||
// technically the UI should display options for all flows for the
|
||||
// user to then choose one, so return all the flows here.
|
||||
return self._flows;
|
||||
});
|
||||
const { flows } = await client.loginFlows();
|
||||
this.flows = flows;
|
||||
this.currentFlowIndex = 0;
|
||||
// technically the UI should display options for all flows for the
|
||||
// user to then choose one, so return all the flows here.
|
||||
return this.flows;
|
||||
}
|
||||
|
||||
chooseFlow(flowIndex) {
|
||||
this._currentFlowIndex = flowIndex;
|
||||
public chooseFlow(flowIndex): void {
|
||||
this.currentFlowIndex = flowIndex;
|
||||
}
|
||||
|
||||
getCurrentFlowStep() {
|
||||
public getCurrentFlowStep(): string {
|
||||
// technically the flow can have multiple steps, but no one does this
|
||||
// for login so we can ignore it.
|
||||
const flowStep = this._flows[this._currentFlowIndex];
|
||||
const flowStep = this.flows[this.currentFlowIndex];
|
||||
return flowStep ? flowStep.type : null;
|
||||
}
|
||||
|
||||
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
|
||||
const self = this;
|
||||
|
||||
public loginViaPassword(
|
||||
username: string,
|
||||
phoneCountry: string,
|
||||
phoneNumber: string,
|
||||
password: string,
|
||||
): Promise<IMatrixClientCreds> {
|
||||
const isEmail = username.indexOf("@") > 0;
|
||||
|
||||
let identifier;
|
||||
|
@ -113,14 +152,14 @@ export default class Login {
|
|||
}
|
||||
|
||||
const loginParams = {
|
||||
password: pass,
|
||||
identifier: identifier,
|
||||
initial_device_display_name: this._defaultDeviceDisplayName,
|
||||
password,
|
||||
identifier,
|
||||
initial_device_display_name: this.defaultDeviceDisplayName,
|
||||
};
|
||||
|
||||
const tryFallbackHs = (originalError) => {
|
||||
return sendLoginRequest(
|
||||
self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams,
|
||||
this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams,
|
||||
).catch((fallbackError) => {
|
||||
console.log("fallback HS login failed", fallbackError);
|
||||
// throw the original error
|
||||
|
@ -130,11 +169,11 @@ export default class Login {
|
|||
|
||||
let originalLoginError = null;
|
||||
return sendLoginRequest(
|
||||
self._hsUrl, self._isUrl, 'm.login.password', loginParams,
|
||||
this.hsUrl, this.isUrl, 'm.login.password', loginParams,
|
||||
).catch((error) => {
|
||||
originalLoginError = error;
|
||||
if (error.httpStatus === 403) {
|
||||
if (self._fallbackHsUrl) {
|
||||
if (this.fallbackHsUrl) {
|
||||
return tryFallbackHs(originalLoginError);
|
||||
}
|
||||
}
|
||||
|
@ -154,11 +193,16 @@ export default class Login {
|
|||
* @param {string} hsUrl the base url of the Homeserver used to log in.
|
||||
* @param {string} isUrl the base url of the default identity server
|
||||
* @param {string} loginType the type of login to do
|
||||
* @param {object} loginParams the parameters for the login
|
||||
* @param {ILoginParams} loginParams the parameters for the login
|
||||
*
|
||||
* @returns {MatrixClientCreds}
|
||||
*/
|
||||
export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) {
|
||||
export async function sendLoginRequest(
|
||||
hsUrl: string,
|
||||
isUrl: string,
|
||||
loginType: string,
|
||||
loginParams: ILoginParams,
|
||||
): Promise<IMatrixClientCreds> {
|
||||
const client = Matrix.createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
|
@ -179,11 +223,15 @@ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) {
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const creds: IMatrixClientCreds = {
|
||||
homeserverUrl: hsUrl,
|
||||
identityServerUrl: isUrl,
|
||||
userId: data.user_id,
|
||||
deviceId: data.device_id,
|
||||
accessToken: data.access_token,
|
||||
};
|
||||
|
||||
SecurityCustomisations.examineLoginResponse?.(data, creds);
|
||||
|
||||
return creds;
|
||||
}
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import commonmark from 'commonmark';
|
||||
import escape from 'lodash/escape';
|
||||
import {escape} from "lodash";
|
||||
|
||||
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
|
||||
import {MatrixClient} from 'matrix-js-sdk/src/client';
|
||||
import {MemoryStore} from 'matrix-js-sdk/src/store/memory';
|
||||
import * as utils from 'matrix-js-sdk/src/utils';
|
||||
|
@ -31,17 +32,18 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
|
|||
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
|
||||
import * as StorageManager from './utils/StorageManager';
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
import { crossSigningCallbacks } from './CrossSigningManager';
|
||||
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
|
||||
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
|
||||
|
||||
export interface IMatrixClientCreds {
|
||||
homeserverUrl: string;
|
||||
identityServerUrl: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
deviceId?: string;
|
||||
accessToken: string;
|
||||
guest: boolean;
|
||||
guest?: boolean;
|
||||
pickleKey?: string;
|
||||
freshLogin?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Move this to the js-sdk
|
||||
|
@ -192,6 +194,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
this.matrixClient.setCryptoTrustCrossSignedDevices(
|
||||
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
|
||||
);
|
||||
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
|
||||
StorageManager.setCryptoInitialised(true);
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -247,8 +250,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
}
|
||||
|
||||
private createClient(creds: IMatrixClientCreds): void {
|
||||
// TODO: Make these opts typesafe with the js-sdk
|
||||
const opts = {
|
||||
const opts: ICreateClientOpts = {
|
||||
baseUrl: creds.homeserverUrl,
|
||||
idBaseUrl: creds.identityServerUrl,
|
||||
accessToken: creds.accessToken,
|
||||
|
|
|
@ -132,7 +132,7 @@ export class ModalManager {
|
|||
public createTrackedDialogAsync<T extends any[]>(
|
||||
analyticsAction: string,
|
||||
analyticsInfo: string,
|
||||
...rest: Parameters<ModalManager["appendDialogAsync"]>
|
||||
...rest: Parameters<ModalManager["createDialogAsync"]>
|
||||
) {
|
||||
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
|
||||
return this.createDialogAsync<T>(...rest);
|
||||
|
@ -151,7 +151,7 @@ export class ModalManager {
|
|||
prom: Promise<React.ComponentType>,
|
||||
props?: IProps<T>,
|
||||
className?: string,
|
||||
options?: IOptions<T>
|
||||
options?: IOptions<T>,
|
||||
) {
|
||||
const modal: IModal<T> = {
|
||||
onFinished: props ? props.onFinished : null,
|
||||
|
@ -182,7 +182,7 @@ export class ModalManager {
|
|||
|
||||
private getCloseFn<T extends any[]>(
|
||||
modal: IModal<T>,
|
||||
props: IProps<T>
|
||||
props: IProps<T>,
|
||||
): [IHandle<T>["close"], IHandle<T>["finished"]] {
|
||||
const deferred = defer<T>();
|
||||
return [async (...args: T) => {
|
||||
|
@ -264,7 +264,7 @@ export class ModalManager {
|
|||
className?: string,
|
||||
isPriorityModal = false,
|
||||
isStaticModal = false,
|
||||
options: IOptions<T> = {}
|
||||
options: IOptions<T> = {},
|
||||
): IHandle<T> {
|
||||
const {modal, closeDialog, onFinishedProm} = this.buildModal<T>(prom, props, className, options);
|
||||
if (isPriorityModal) {
|
||||
|
@ -287,7 +287,7 @@ export class ModalManager {
|
|||
private appendDialogAsync<T extends any[]>(
|
||||
prom: Promise<React.ComponentType>,
|
||||
props?: IProps<T>,
|
||||
className?: string
|
||||
className?: string,
|
||||
): IHandle<T> {
|
||||
const {modal, closeDialog, onFinishedProm} = this.buildModal<T>(prom, props, className, {});
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ import Modal from './Modal';
|
|||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast";
|
||||
import {SettingLevel} from "./settings/SettingLevel";
|
||||
import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers";
|
||||
|
||||
/*
|
||||
* Dispatches:
|
||||
|
@ -217,7 +218,7 @@ export const Notifier = {
|
|||
// calculated value. It is determined based upon whether or not the master rule is enabled
|
||||
// and other flags. Setting it here would cause a circular reference.
|
||||
|
||||
Analytics.trackEvent('Notifier', 'Set Enabled', enable);
|
||||
Analytics.trackEvent('Notifier', 'Set Enabled', String(enable));
|
||||
|
||||
// make sure that we persist the current setting audio_enabled setting
|
||||
// before changing anything
|
||||
|
@ -258,7 +259,7 @@ export const Notifier = {
|
|||
}
|
||||
// set the notifications_hidden flag, as the user has knowingly interacted
|
||||
// with the setting we shouldn't nag them any further
|
||||
this.setToolbarHidden(true);
|
||||
this.setPromptHidden(true);
|
||||
},
|
||||
|
||||
isEnabled: function() {
|
||||
|
@ -283,10 +284,10 @@ export const Notifier = {
|
|||
return SettingsStore.getValue("audioNotificationsEnabled");
|
||||
},
|
||||
|
||||
setToolbarHidden: function(hidden: boolean, persistent = true) {
|
||||
setPromptHidden: function(hidden: boolean, persistent = true) {
|
||||
this.toolbarHidden = hidden;
|
||||
|
||||
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden);
|
||||
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden));
|
||||
|
||||
hideNotificationsToast();
|
||||
|
||||
|
@ -296,17 +297,17 @@ export const Notifier = {
|
|||
}
|
||||
},
|
||||
|
||||
shouldShowToolbar: function() {
|
||||
shouldShowPrompt: function() {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const isGuest = client.isGuest();
|
||||
return !isGuest && this.supportsDesktopNotifications() &&
|
||||
!this.isEnabled() && !this._isToolbarHidden();
|
||||
return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() &&
|
||||
!this.isEnabled() && !this._isPromptHidden();
|
||||
},
|
||||
|
||||
_isToolbarHidden: function() {
|
||||
_isPromptHidden: function() {
|
||||
// Check localStorage for any such meta data
|
||||
if (global.localStorage) {
|
||||
return global.localStorage.getItem("notifications_hidden") === "true";
|
||||
|
|
|
@ -19,30 +19,34 @@ limitations under the License.
|
|||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import Timer from './utils/Timer';
|
||||
import {ActionPayload} from "./dispatcher/payloads";
|
||||
|
||||
// Time in ms after that a user is considered as unavailable/away
|
||||
// Time in ms after that a user is considered as unavailable/away
|
||||
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
|
||||
const PRESENCE_STATES = ["online", "offline", "unavailable"];
|
||||
|
||||
enum State {
|
||||
Online = "online",
|
||||
Offline = "offline",
|
||||
Unavailable = "unavailable",
|
||||
}
|
||||
|
||||
class Presence {
|
||||
constructor() {
|
||||
this._activitySignal = null;
|
||||
this._unavailableTimer = null;
|
||||
this._onAction = this._onAction.bind(this);
|
||||
this._dispatcherRef = null;
|
||||
}
|
||||
private unavailableTimer: Timer = null;
|
||||
private dispatcherRef: string = null;
|
||||
private state: State = null;
|
||||
|
||||
/**
|
||||
* Start listening the user activity to evaluate his presence state.
|
||||
* Any state change will be sent to the homeserver.
|
||||
*/
|
||||
async start() {
|
||||
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
|
||||
public async start() {
|
||||
this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
|
||||
// the user_activity_start action starts the timer
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
while (this._unavailableTimer) {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
while (this.unavailableTimer) {
|
||||
try {
|
||||
await this._unavailableTimer.finished();
|
||||
this.setState("unavailable");
|
||||
await this.unavailableTimer.finished();
|
||||
this.setState(State.Unavailable);
|
||||
} catch (e) { /* aborted, stop got called */ }
|
||||
}
|
||||
}
|
||||
|
@ -50,14 +54,14 @@ class Presence {
|
|||
/**
|
||||
* Stop tracking user activity
|
||||
*/
|
||||
stop() {
|
||||
if (this._dispatcherRef) {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
this._dispatcherRef = null;
|
||||
public stop() {
|
||||
if (this.dispatcherRef) {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.dispatcherRef = null;
|
||||
}
|
||||
if (this._unavailableTimer) {
|
||||
this._unavailableTimer.abort();
|
||||
this._unavailableTimer = null;
|
||||
if (this.unavailableTimer) {
|
||||
this.unavailableTimer.abort();
|
||||
this.unavailableTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,14 +69,14 @@ class Presence {
|
|||
* Get the current presence state.
|
||||
* @returns {string} the presence state (see PRESENCE enum)
|
||||
*/
|
||||
getState() {
|
||||
public getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
_onAction(payload) {
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === 'user_activity') {
|
||||
this.setState("online");
|
||||
this._unavailableTimer.restart();
|
||||
this.setState(State.Online);
|
||||
this.unavailableTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,13 +85,11 @@ class Presence {
|
|||
* If the state has changed, the homeserver will be notified.
|
||||
* @param {string} newState the new presence state (see PRESENCE enum)
|
||||
*/
|
||||
async setState(newState) {
|
||||
private async setState(newState: State) {
|
||||
if (newState === this.state) {
|
||||
return;
|
||||
}
|
||||
if (PRESENCE_STATES.indexOf(newState) === -1) {
|
||||
throw new Error("Bad presence state: " + newState);
|
||||
}
|
||||
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
|
|
@ -24,7 +24,6 @@ import dis from './dispatcher/dispatcher';
|
|||
import * as sdk from './index';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
// import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
|
||||
// Regex for what a "safe" or "Matrix-looking" localpart would be.
|
||||
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
|
||||
|
@ -44,70 +43,27 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
|
|||
*/
|
||||
export async function startAnyRegistrationFlow(options) {
|
||||
if (options === undefined) options = {};
|
||||
// look for an ILAG compatible flow. We define this as one
|
||||
// which has only dummy or recaptcha flows. In practice it
|
||||
// would support any stage InteractiveAuth supports, just not
|
||||
// ones like email & msisdn which require the user to supply
|
||||
// the relevant details in advance. We err on the side of
|
||||
// caution though.
|
||||
|
||||
// XXX: ILAG is disabled for now,
|
||||
// see https://github.com/vector-im/element-web/issues/8222
|
||||
|
||||
// const flows = await _getRegistrationFlows();
|
||||
// const hasIlagFlow = flows.some((flow) => {
|
||||
// return flow.stages.every((stage) => {
|
||||
// return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage);
|
||||
// });
|
||||
// });
|
||||
|
||||
// if (hasIlagFlow) {
|
||||
// dis.dispatch({
|
||||
// action: 'view_set_mxid',
|
||||
// go_home_on_cancel: options.go_home_on_cancel,
|
||||
// });
|
||||
//} else {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
|
||||
hasCancelButton: true,
|
||||
quitOnly: true,
|
||||
title: _t("Sign In or Create Account"),
|
||||
description: _t("Use your account or create a new one to continue."),
|
||||
button: _t("Create Account"),
|
||||
extraButtons: [
|
||||
<button key="start_login" onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
|
||||
}}>{ _t('Sign In') }</button>,
|
||||
],
|
||||
onFinished: (proceed) => {
|
||||
if (proceed) {
|
||||
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
|
||||
} else if (options.go_home_on_cancel) {
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
} else if (options.go_welcome_on_cancel) {
|
||||
dis.dispatch({action: 'view_welcome_page'});
|
||||
}
|
||||
},
|
||||
});
|
||||
//}
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
|
||||
hasCancelButton: true,
|
||||
quitOnly: true,
|
||||
title: _t("Sign In or Create Account"),
|
||||
description: _t("Use your account or create a new one to continue."),
|
||||
button: _t("Create Account"),
|
||||
extraButtons: [
|
||||
<button key="start_login" onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
|
||||
}}>{ _t('Sign In') }</button>,
|
||||
],
|
||||
onFinished: (proceed) => {
|
||||
if (proceed) {
|
||||
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
|
||||
} else if (options.go_home_on_cancel) {
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
} else if (options.go_welcome_on_cancel) {
|
||||
dis.dispatch({action: 'view_welcome_page'});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// async function _getRegistrationFlows() {
|
||||
// try {
|
||||
// await MatrixClientPeg.get().register(
|
||||
// null,
|
||||
// null,
|
||||
// undefined,
|
||||
// {},
|
||||
// {},
|
||||
// );
|
||||
// console.log("Register request succeeded when it should have returned 401!");
|
||||
// } catch (e) {
|
||||
// if (e.httpStatus === 401) {
|
||||
// return e.data.flows;
|
||||
// }
|
||||
// throw e;
|
||||
// }
|
||||
// throw new Error("Register request succeeded when it should have returned 401!");
|
||||
// }
|
||||
|
|
|
@ -13,9 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
export function levelRoleMap(usersDefault) {
|
||||
export function levelRoleMap(usersDefault: number) {
|
||||
return {
|
||||
undefined: _t('Default'),
|
||||
0: _t('Restricted'),
|
||||
|
@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) {
|
|||
};
|
||||
}
|
||||
|
||||
export function textualPowerLevel(level, usersDefault) {
|
||||
export function textualPowerLevel(level: number, usersDefault: number): string {
|
||||
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
|
||||
if (LEVEL_ROLE_MAP[level]) {
|
||||
return LEVEL_ROLE_MAP[level];
|
|
@ -23,6 +23,8 @@ import Modal from './Modal';
|
|||
import * as sdk from './';
|
||||
import { _t } from './languageHandler';
|
||||
import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
|
||||
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
|
||||
import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
|
||||
|
||||
/**
|
||||
* Invites multiple addresses to a room
|
||||
|
@ -56,6 +58,23 @@ export function showRoomInviteDialog(roomId) {
|
|||
);
|
||||
}
|
||||
|
||||
export function showCommunityRoomInviteDialog(roomId, communityName) {
|
||||
Modal.createTrackedDialog(
|
||||
'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
}
|
||||
|
||||
export function showCommunityInviteDialog(communityId) {
|
||||
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
|
||||
if (chat) {
|
||||
const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
|
||||
showCommunityRoomInviteDialog(chat.roomId, name);
|
||||
} else {
|
||||
throw new Error("Failed to locate appropriate room to start an invite in");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given MatrixEvent is a valid 3rd party user invite.
|
||||
* @param {MatrixEvent} event The event to check
|
||||
|
@ -77,7 +96,7 @@ export function isValid3pidInvite(event) {
|
|||
export function inviteUsersToRoom(roomId, userIds) {
|
||||
return inviteMultipleToRoom(roomId, userIds).then((result) => {
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
return _showAnyInviteErrors(result.states, room, result.inviter);
|
||||
showAnyInviteErrors(result.states, room, result.inviter);
|
||||
}).catch((err) => {
|
||||
console.error(err.stack);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
@ -88,7 +107,7 @@ export function inviteUsersToRoom(roomId, userIds) {
|
|||
});
|
||||
}
|
||||
|
||||
function _showAnyInviteErrors(addrs, room, inviter) {
|
||||
export function showAnyInviteErrors(addrs, room, inviter) {
|
||||
// Show user any errors
|
||||
const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error');
|
||||
if (failedUsers.length === 1 && inviter.fatal) {
|
||||
|
@ -100,6 +119,7 @@ function _showAnyInviteErrors(addrs, room, inviter) {
|
|||
title: _t("Failed to invite users to the room:", {roomName: room.name}),
|
||||
description: inviter.getErrorText(failedUsers[0]),
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
const errorList = [];
|
||||
for (const addr of failedUsers) {
|
||||
|
@ -118,8 +138,9 @@ function _showAnyInviteErrors(addrs, room, inviter) {
|
|||
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
|
||||
description,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return addrs;
|
||||
return true;
|
||||
}
|
||||
|
|
52
src/Rooms.js
52
src/Rooms.js
|
@ -26,58 +26,6 @@ export function getDisplayAliasForRoom(room) {
|
|||
return room.getCanonicalAlias() || room.getAltAliases()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* If the room contains only two members including the logged-in user,
|
||||
* return the other one. Otherwise, return null.
|
||||
*/
|
||||
export function getOnlyOtherMember(room, myUserId) {
|
||||
if (room.currentState.getJoinedMemberCount() === 2) {
|
||||
return room.getJoinedMembers().filter(function(m) {
|
||||
return m.userId !== myUserId;
|
||||
})[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function _isConfCallRoom(room, myUserId, conferenceHandler) {
|
||||
if (!conferenceHandler) return false;
|
||||
|
||||
const myMembership = room.getMyMembership();
|
||||
if (myMembership != "join") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const otherMember = getOnlyOtherMember(room, myUserId);
|
||||
if (!otherMember) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conferenceHandler.isConferenceUser(otherMember.userId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cache whether a room is a conference call. Assumes that rooms will always
|
||||
// either will or will not be a conference call room.
|
||||
const isConfCallRoomCache = {
|
||||
// $roomId: bool
|
||||
};
|
||||
|
||||
export function isConfCallRoom(room, myUserId, conferenceHandler) {
|
||||
if (isConfCallRoomCache[room.roomId] !== undefined) {
|
||||
return isConfCallRoomCache[room.roomId];
|
||||
}
|
||||
|
||||
const result = _isConfCallRoom(room, myUserId, conferenceHandler);
|
||||
|
||||
isConfCallRoomCache[room.roomId] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function looksLikeDirectMessageRoom(room, myUserId) {
|
||||
const myMembership = room.getMyMembership();
|
||||
const me = room.getMember(myUserId);
|
||||
|
|
|
@ -33,6 +33,11 @@ export const DEFAULTS: ConfigOptions = {
|
|||
// Default conference domain
|
||||
preferredDomain: "jitsi.riot.im",
|
||||
},
|
||||
desktopBuilds: {
|
||||
available: true,
|
||||
logo: require("../res/img/element-desktop-logo.svg"),
|
||||
url: "https://element.io/get-started",
|
||||
},
|
||||
};
|
||||
|
||||
export default class SdkConfig {
|
||||
|
|
442
src/SecurityManager.ts
Normal file
442
src/SecurityManager.ts
Normal file
|
@ -0,0 +1,442 @@
|
|||
/*
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
|
||||
import { _t } from './languageHandler';
|
||||
import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib";
|
||||
import { isSecureBackupRequired } from './utils/WellKnownUtils';
|
||||
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
|
||||
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
||||
// This stores the secret storage private keys in memory for the JS SDK. This is
|
||||
// only meant to act as a cache to avoid prompting the user multiple times
|
||||
// during the same single operation. Use `accessSecretStorage` below to scope a
|
||||
// single secret storage operation, as it will clear the cached keys once the
|
||||
// operation ends.
|
||||
let secretStorageKeys: Record<string, Uint8Array> = {};
|
||||
let secretStorageKeyInfo: Record<string, ISecretStorageKeyInfo> = {};
|
||||
let secretStorageBeingAccessed = false;
|
||||
|
||||
let nonInteractive = false;
|
||||
|
||||
let dehydrationCache: {
|
||||
key?: Uint8Array,
|
||||
keyInfo?: ISecretStorageKeyInfo,
|
||||
} = {};
|
||||
|
||||
function isCachingAllowed(): boolean {
|
||||
return secretStorageBeingAccessed;
|
||||
}
|
||||
|
||||
/**
|
||||
* This can be used by other components to check if secret storage access is in
|
||||
* progress, so that we can e.g. avoid intermittently showing toasts during
|
||||
* secret storage setup.
|
||||
*
|
||||
* @returns {bool}
|
||||
*/
|
||||
export function isSecretStorageBeingAccessed(): boolean {
|
||||
return secretStorageBeingAccessed;
|
||||
}
|
||||
|
||||
export class AccessCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Secret storage access canceled");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmToDismiss(): Promise<boolean> {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const [sure] = await Modal.createDialog(QuestionDialog, {
|
||||
title: _t("Cancel entering passphrase?"),
|
||||
description: _t("Are you sure you want to cancel entering passphrase?"),
|
||||
danger: false,
|
||||
button: _t("Go Back"),
|
||||
cancelButton: _t("Cancel"),
|
||||
}).finished;
|
||||
return !sure;
|
||||
}
|
||||
|
||||
function makeInputToKey(
|
||||
keyInfo: ISecretStorageKeyInfo,
|
||||
): (keyParams: { passphrase: string, recoveryKey: string }) => Promise<Uint8Array> {
|
||||
return async ({ passphrase, recoveryKey }) => {
|
||||
if (passphrase) {
|
||||
return deriveKey(
|
||||
passphrase,
|
||||
keyInfo.passphrase.salt,
|
||||
keyInfo.passphrase.iterations,
|
||||
);
|
||||
} else {
|
||||
return decodeRecoveryKey(recoveryKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getSecretStorageKey(
|
||||
{ keys: keyInfos }: { keys: Record<string, ISecretStorageKeyInfo> },
|
||||
ssssItemName,
|
||||
): Promise<[string, Uint8Array]> {
|
||||
const keyInfoEntries = Object.entries(keyInfos);
|
||||
if (keyInfoEntries.length > 1) {
|
||||
throw new Error("Multiple storage key requests not implemented");
|
||||
}
|
||||
const [keyId, keyInfo] = keyInfoEntries[0];
|
||||
|
||||
// Check the in-memory cache
|
||||
if (isCachingAllowed() && secretStorageKeys[keyId]) {
|
||||
return [keyId, secretStorageKeys[keyId]];
|
||||
}
|
||||
|
||||
if (dehydrationCache.key) {
|
||||
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
|
||||
cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
|
||||
return [keyId, dehydrationCache.key];
|
||||
}
|
||||
}
|
||||
|
||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Using key from security customisations (secret storage)")
|
||||
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
|
||||
return [keyId, keyFromCustomisations];
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error("Could not unlock non-interactively");
|
||||
}
|
||||
|
||||
const inputToKey = makeInputToKey(keyInfo);
|
||||
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
||||
AccessSecretStorageDialog,
|
||||
/* props= */
|
||||
{
|
||||
keyInfo,
|
||||
checkPrivateKey: async (input) => {
|
||||
const key = await inputToKey(input);
|
||||
return await MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo);
|
||||
},
|
||||
},
|
||||
/* className= */ null,
|
||||
/* isPriorityModal= */ false,
|
||||
/* isStaticModal= */ false,
|
||||
/* options= */ {
|
||||
onBeforeClose: async (reason) => {
|
||||
if (reason === "backgroundClick") {
|
||||
return confirmToDismiss();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [input] = await finished;
|
||||
if (!input) {
|
||||
throw new AccessCancelledError();
|
||||
}
|
||||
const key = await inputToKey(input);
|
||||
|
||||
// Save to cache to avoid future prompts in the current session
|
||||
cacheSecretStorageKey(keyId, keyInfo, key);
|
||||
|
||||
return [keyId, key];
|
||||
}
|
||||
|
||||
export async function getDehydrationKey(
|
||||
keyInfo: ISecretStorageKeyInfo,
|
||||
checkFunc: (Uint8Array) => void,
|
||||
): Promise<Uint8Array> {
|
||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Using key from security customisations (dehydration)")
|
||||
return keyFromCustomisations;
|
||||
}
|
||||
|
||||
const inputToKey = makeInputToKey(keyInfo);
|
||||
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
||||
AccessSecretStorageDialog,
|
||||
/* props= */
|
||||
{
|
||||
keyInfo,
|
||||
checkPrivateKey: async (input) => {
|
||||
const key = await inputToKey(input);
|
||||
try {
|
||||
checkFunc(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
/* className= */ null,
|
||||
/* isPriorityModal= */ false,
|
||||
/* isStaticModal= */ false,
|
||||
/* options= */ {
|
||||
onBeforeClose: async (reason) => {
|
||||
if (reason === "backgroundClick") {
|
||||
return confirmToDismiss();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [input] = await finished;
|
||||
if (!input) {
|
||||
throw new AccessCancelledError();
|
||||
}
|
||||
const key = await inputToKey(input);
|
||||
|
||||
// need to copy the key because rehydration (unpickling) will clobber it
|
||||
dehydrationCache = {key: new Uint8Array(key), keyInfo};
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function cacheSecretStorageKey(
|
||||
keyId: string,
|
||||
keyInfo: ISecretStorageKeyInfo,
|
||||
key: Uint8Array,
|
||||
): void {
|
||||
if (isCachingAllowed()) {
|
||||
secretStorageKeys[keyId] = key;
|
||||
secretStorageKeyInfo[keyId] = keyInfo;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSecretRequested(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
requestId: string,
|
||||
name: string,
|
||||
deviceTrust: IDeviceTrustLevel,
|
||||
): Promise<string> {
|
||||
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (userId !== client.getUserId()) {
|
||||
return;
|
||||
}
|
||||
if (!deviceTrust || !deviceTrust.isVerified()) {
|
||||
console.log(`Ignoring secret request from untrusted device ${deviceId}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === "m.cross_signing.master" ||
|
||||
name === "m.cross_signing.self_signing" ||
|
||||
name === "m.cross_signing.user_signing"
|
||||
) {
|
||||
const callbacks = client.getCrossSigningCacheCallbacks();
|
||||
if (!callbacks.getCrossSigningKeyCache) return;
|
||||
const keyId = name.replace("m.cross_signing.", "");
|
||||
const key = await callbacks.getCrossSigningKeyCache(keyId);
|
||||
if (!key) {
|
||||
console.log(
|
||||
`${keyId} requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
return key && encodeBase64(key);
|
||||
} else if (name === "m.megolm_backup.v1") {
|
||||
const key = await client._crypto.getSessionBackupPrivateKey();
|
||||
if (!key) {
|
||||
console.log(
|
||||
`session backup key requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
return key && encodeBase64(key);
|
||||
}
|
||||
console.warn("onSecretRequested didn't recognise the secret named ", name);
|
||||
}
|
||||
|
||||
export const crossSigningCallbacks: ICryptoCallbacks = {
|
||||
getSecretStorageKey,
|
||||
cacheSecretStorageKey,
|
||||
onSecretRequested,
|
||||
getDehydrationKey,
|
||||
};
|
||||
|
||||
export async function promptForBackupPassphrase(): Promise<Uint8Array> {
|
||||
let key;
|
||||
|
||||
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
|
||||
showSummary: false, keyCallback: k => key = k,
|
||||
}, null, /* priority = */ false, /* static = */ true);
|
||||
|
||||
const success = await finished;
|
||||
if (!success) throw new Error("Key backup prompt cancelled");
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper should be used whenever you need to access secret storage. It
|
||||
* ensures that secret storage (and also cross-signing since they each depend on
|
||||
* each other in a cycle of sorts) have been bootstrapped before running the
|
||||
* provided function.
|
||||
*
|
||||
* Bootstrapping secret storage may take one of these paths:
|
||||
* 1. Create secret storage from a passphrase and store cross-signing keys
|
||||
* in secret storage.
|
||||
* 2. Access existing secret storage by requesting passphrase and accessing
|
||||
* cross-signing keys as needed.
|
||||
* 3. All keys are loaded and there's nothing to do.
|
||||
*
|
||||
* Additionally, the secret storage keys are cached during the scope of this function
|
||||
* to ensure the user is prompted only once for their secret storage
|
||||
* passphrase. The cache is then cleared once the provided function completes.
|
||||
*
|
||||
* @param {Function} [func] An operation to perform once secret storage has been
|
||||
* bootstrapped. Optional.
|
||||
* @param {bool} [forceReset] Reset secret storage even if it's already set up
|
||||
*/
|
||||
export async function accessSecretStorage(func = async () => { }, forceReset = false) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
secretStorageBeingAccessed = true;
|
||||
try {
|
||||
if (!await cli.hasSecretStorageKey() || forceReset) {
|
||||
// This dialog calls bootstrap itself after guiding the user through
|
||||
// passphrase creation.
|
||||
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
|
||||
import("./async-components/views/dialogs/security/CreateSecretStorageDialog"),
|
||||
{
|
||||
forceReset,
|
||||
},
|
||||
null,
|
||||
/* priority = */ false,
|
||||
/* static = */ true,
|
||||
/* options = */ {
|
||||
onBeforeClose: async (reason) => {
|
||||
// If Secure Backup is required, you cannot leave the modal.
|
||||
if (reason === "backgroundClick") {
|
||||
return !isSecureBackupRequired();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Secret storage creation canceled");
|
||||
}
|
||||
} else {
|
||||
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
|
||||
await cli.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (makeRequest) => {
|
||||
const { finished } = Modal.createTrackedDialog(
|
||||
'Cross-signing keys dialog', '', InteractiveAuthDialog,
|
||||
{
|
||||
title: _t("Setting up keys"),
|
||||
matrixClient: cli,
|
||||
makeRequest,
|
||||
},
|
||||
);
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
},
|
||||
});
|
||||
await cli.bootstrapSecretStorage({
|
||||
getKeyBackupPassphrase: promptForBackupPassphrase,
|
||||
});
|
||||
|
||||
const keyId = Object.keys(secretStorageKeys)[0];
|
||||
if (keyId && SettingsStore.getValue("feature_dehydration")) {
|
||||
let dehydrationKeyInfo = {};
|
||||
if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) {
|
||||
dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase };
|
||||
}
|
||||
console.log("Setting dehydration key");
|
||||
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
|
||||
} else if (!keyId) {
|
||||
console.warn("Not setting dehydration key: no SSSS key found");
|
||||
} else {
|
||||
console.log("Not setting dehydration key: feature disabled");
|
||||
}
|
||||
}
|
||||
|
||||
// `return await` needed here to ensure `finally` block runs after the
|
||||
// inner operation completes.
|
||||
return await func();
|
||||
} catch (e) {
|
||||
SecurityCustomisations.catchAccessSecretStorageError?.(e);
|
||||
console.error(e);
|
||||
} finally {
|
||||
// Clear secret storage key cache now that work is complete
|
||||
secretStorageBeingAccessed = false;
|
||||
if (!isCachingAllowed()) {
|
||||
secretStorageKeys = {};
|
||||
secretStorageKeyInfo = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: this function name is a bit of a mouthful
|
||||
export async function tryToUnlockSecretStorageWithDehydrationKey(
|
||||
client: MatrixClient,
|
||||
): Promise<void> {
|
||||
const key = dehydrationCache.key;
|
||||
let restoringBackup = false;
|
||||
if (key && await client.isSecretStorageReady()) {
|
||||
console.log("Trying to set up cross-signing using dehydration key");
|
||||
secretStorageBeingAccessed = true;
|
||||
nonInteractive = true;
|
||||
try {
|
||||
await client.checkOwnCrossSigningTrust();
|
||||
|
||||
// we also need to set a new dehydrated device to replace the
|
||||
// device we rehydrated
|
||||
let dehydrationKeyInfo = {};
|
||||
if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) {
|
||||
dehydrationKeyInfo = { passphrase: dehydrationCache.keyInfo.passphrase };
|
||||
}
|
||||
await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device");
|
||||
|
||||
// and restore from backup
|
||||
const backupInfo = await client.getKeyBackupVersion();
|
||||
if (backupInfo) {
|
||||
restoringBackup = true;
|
||||
// don't await, because this can take a long time
|
||||
client.restoreKeyBackupWithSecretStorage(backupInfo)
|
||||
.finally(() => {
|
||||
secretStorageBeingAccessed = false;
|
||||
nonInteractive = false;
|
||||
if (!isCachingAllowed()) {
|
||||
secretStorageKeys = {};
|
||||
secretStorageKeyInfo = {};
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dehydrationCache = {};
|
||||
// the secret storage cache is needed for restoring from backup, so
|
||||
// don't clear it yet if we're restoring from backup
|
||||
if (!restoringBackup) {
|
||||
secretStorageBeingAccessed = false;
|
||||
nonInteractive = false;
|
||||
if (!isCachingAllowed()) {
|
||||
secretStorageKeys = {};
|
||||
secretStorageKeyInfo = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,13 +15,22 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import _clamp from 'lodash/clamp';
|
||||
import {clamp} from "lodash";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import {SerializedPart} from "./editor/parts";
|
||||
import EditorModel from "./editor/model";
|
||||
|
||||
interface IHistoryItem {
|
||||
parts: SerializedPart[];
|
||||
replyEventId?: string;
|
||||
}
|
||||
|
||||
export default class SendHistoryManager {
|
||||
history: Array<HistoryItem> = [];
|
||||
history: Array<IHistoryItem> = [];
|
||||
prefix: string;
|
||||
lastIndex: number = 0; // used for indexing the storage
|
||||
currentIndex: number = 0; // used for indexing the loaded validated history Array
|
||||
lastIndex = 0; // used for indexing the storage
|
||||
currentIndex = 0; // used for indexing the loaded validated history Array
|
||||
|
||||
constructor(roomId: string, prefix: string) {
|
||||
this.prefix = prefix + roomId;
|
||||
|
@ -32,8 +41,7 @@ export default class SendHistoryManager {
|
|||
|
||||
while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) {
|
||||
try {
|
||||
const serializedParts = JSON.parse(itemJSON);
|
||||
this.history.push(serializedParts);
|
||||
this.history.push(JSON.parse(itemJSON));
|
||||
} catch (e) {
|
||||
console.warn("Throwing away unserialisable history", e);
|
||||
break;
|
||||
|
@ -45,16 +53,23 @@ export default class SendHistoryManager {
|
|||
this.currentIndex = this.lastIndex + 1;
|
||||
}
|
||||
|
||||
save(editorModel: Object) {
|
||||
const serializedParts = editorModel.serializeParts();
|
||||
this.history.push(serializedParts);
|
||||
this.currentIndex = this.history.length;
|
||||
this.lastIndex += 1;
|
||||
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts));
|
||||
static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem {
|
||||
return {
|
||||
parts: model.serializeParts(),
|
||||
replyEventId: replyEvent ? replyEvent.getId() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getItem(offset: number): ?HistoryItem {
|
||||
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
|
||||
save(editorModel: EditorModel, replyEvent?: MatrixEvent) {
|
||||
const item = SendHistoryManager.createItem(editorModel, replyEvent);
|
||||
this.history.push(item);
|
||||
this.currentIndex = this.history.length;
|
||||
this.lastIndex += 1;
|
||||
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item));
|
||||
}
|
||||
|
||||
getItem(offset: number): IHistoryItem {
|
||||
this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
|
||||
return this.history[this.currentIndex];
|
||||
}
|
||||
}
|
|
@ -38,13 +38,14 @@ import {inviteUsersToRoom} from "./RoomInvite";
|
|||
import { WidgetType } from "./widgets/WidgetType";
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { parseFragment as parseHtml } from "parse5";
|
||||
import sendBugReport from "./rageshake/submit-rageshake";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import BugReportDialog from "./components/views/dialogs/BugReportDialog";
|
||||
import { ensureDMExists } from "./createRoom";
|
||||
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import {UIFeature} from "./settings/UIFeature";
|
||||
|
||||
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
||||
interface HTMLInputEvent extends Event {
|
||||
|
@ -89,6 +90,7 @@ interface ICommandOpts {
|
|||
runFn?: RunFn;
|
||||
category: string;
|
||||
hideCompletionAfterSpace?: boolean;
|
||||
isEnabled?(): boolean;
|
||||
}
|
||||
|
||||
export class Command {
|
||||
|
@ -99,6 +101,7 @@ export class Command {
|
|||
runFn: undefined | RunFn;
|
||||
category: string;
|
||||
hideCompletionAfterSpace: boolean;
|
||||
_isEnabled?: () => boolean;
|
||||
|
||||
constructor(opts: ICommandOpts) {
|
||||
this.command = opts.command;
|
||||
|
@ -108,6 +111,7 @@ export class Command {
|
|||
this.runFn = opts.runFn;
|
||||
this.category = opts.category || CommandCategories.other;
|
||||
this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false;
|
||||
this._isEnabled = opts.isEnabled;
|
||||
}
|
||||
|
||||
getCommand() {
|
||||
|
@ -127,6 +131,10 @@ export class Command {
|
|||
getUsage() {
|
||||
return _t('Usage') + ': ' + this.getCommandWithArgs();
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this._isEnabled ? this._isEnabled() : true;
|
||||
}
|
||||
}
|
||||
|
||||
function reject(error) {
|
||||
|
@ -155,6 +163,19 @@ export const Commands = [
|
|||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: 'lenny',
|
||||
args: '<message>',
|
||||
description: _td('Prepends ( ͡° ͜ʖ ͡°) to a plain-text message'),
|
||||
runFn: function(roomId, args) {
|
||||
let message = '( ͡° ͜ʖ ͡°)';
|
||||
if (args) {
|
||||
message = message + ' ' + args;
|
||||
}
|
||||
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: 'plain',
|
||||
args: '<message>',
|
||||
|
@ -778,6 +799,7 @@ export const Commands = [
|
|||
command: 'addwidget',
|
||||
args: '<url | embed code | Jitsi url>',
|
||||
description: _td('Adds a custom widget by URL to the room'),
|
||||
isEnabled: () => SettingsStore.getValue(UIFeature.Widgets),
|
||||
runFn: function(roomId, widgetUrl) {
|
||||
if (!widgetUrl) {
|
||||
return reject(_t("Please supply a widget URL or embed code"));
|
||||
|
@ -861,12 +883,12 @@ export const Commands = [
|
|||
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session' +
|
||||
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
|
||||
'"%(fingerprint)s". This could mean your communications are being intercepted!',
|
||||
{
|
||||
fprint,
|
||||
userId,
|
||||
deviceId,
|
||||
fingerprint,
|
||||
}));
|
||||
{
|
||||
fprint,
|
||||
userId,
|
||||
deviceId,
|
||||
fingerprint,
|
||||
}));
|
||||
}
|
||||
|
||||
await cli.setDeviceVerified(userId, deviceId, true);
|
||||
|
@ -880,7 +902,7 @@ export const Commands = [
|
|||
{
|
||||
_t('The signing key you provided matches the signing key you received ' +
|
||||
'from %(userId)s\'s session %(deviceId)s. Session marked as verified.',
|
||||
{userId, deviceId})
|
||||
{userId, deviceId})
|
||||
}
|
||||
</p>
|
||||
</div>,
|
||||
|
@ -960,19 +982,13 @@ export const Commands = [
|
|||
command: "rageshake",
|
||||
aliases: ["bugreport"],
|
||||
description: _td("Send a bug report with logs"),
|
||||
isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url,
|
||||
args: "<description>",
|
||||
runFn: function(roomId, args) {
|
||||
return success(
|
||||
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
|
||||
userText: args,
|
||||
sendLogs: true,
|
||||
}).then(() => {
|
||||
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
||||
Modal.createTrackedDialog('Slash Commands', 'Rageshake sent', InfoDialog, {
|
||||
title: _t('Logs sent'),
|
||||
description: _t('Thank you!'),
|
||||
});
|
||||
}),
|
||||
Modal.createTrackedDialog('Slash Commands', 'Bug Report Dialog', BugReportDialog, {
|
||||
initialText: args,
|
||||
}).finished,
|
||||
);
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
|
@ -1064,7 +1080,7 @@ Commands.forEach(cmd => {
|
|||
});
|
||||
});
|
||||
|
||||
export function parseCommandString(input) {
|
||||
export function parseCommandString(input: string) {
|
||||
// trim any trailing whitespace, as it can confuse the parser for
|
||||
// IRC-style commands
|
||||
input = input.replace(/\s+$/, '');
|
||||
|
@ -1091,10 +1107,10 @@ export function parseCommandString(input) {
|
|||
* processing the command, or 'promise' if a request was sent out.
|
||||
* Returns null if the input didn't match a command.
|
||||
*/
|
||||
export function getCommand(roomId, input) {
|
||||
export function getCommand(roomId: string, input: string) {
|
||||
const {cmd, args} = parseCommandString(input);
|
||||
|
||||
if (CommandMap.has(cmd)) {
|
||||
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {
|
||||
return () => CommandMap.get(cmd).run(roomId, args, cmd);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import CallHandler from './CallHandler';
|
||||
import { _t } from './languageHandler';
|
||||
import * as Roles from './Roles';
|
||||
import {isValid3pidInvite} from "./RoomInvite";
|
||||
|
@ -28,7 +27,6 @@ function textForMemberEvent(ev) {
|
|||
const prevContent = ev.getPrevContent();
|
||||
const content = ev.getContent();
|
||||
|
||||
const ConferenceHandler = CallHandler.getConferenceHandler();
|
||||
const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : '';
|
||||
switch (content.membership) {
|
||||
case 'invite': {
|
||||
|
@ -43,11 +41,7 @@ function textForMemberEvent(ev) {
|
|||
return _t('%(targetName)s accepted an invitation.', {targetName});
|
||||
}
|
||||
} else {
|
||||
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
|
||||
return _t('%(senderName)s requested a VoIP conference.', {senderName});
|
||||
} else {
|
||||
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
|
||||
}
|
||||
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
|
||||
}
|
||||
}
|
||||
case 'ban':
|
||||
|
@ -84,17 +78,11 @@ function textForMemberEvent(ev) {
|
|||
}
|
||||
} else {
|
||||
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
|
||||
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
|
||||
return _t('VoIP conference started.');
|
||||
} else {
|
||||
return _t('%(targetName)s joined the room.', {targetName});
|
||||
}
|
||||
return _t('%(targetName)s joined the room.', {targetName});
|
||||
}
|
||||
case 'leave':
|
||||
if (ev.getSender() === ev.getStateKey()) {
|
||||
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
|
||||
return _t('VoIP conference finished.');
|
||||
} else if (prevContent.membership === "invite") {
|
||||
if (prevContent.membership === "invite") {
|
||||
return _t('%(targetName)s rejected the invitation.', {targetName});
|
||||
} else {
|
||||
return _t('%(targetName)s left the room.', {targetName});
|
||||
|
@ -210,59 +198,30 @@ function textForRelatedGroupsEvent(ev) {
|
|||
function textForServerACLEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const changes = [];
|
||||
const current = ev.getContent();
|
||||
const prev = {
|
||||
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
|
||||
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
|
||||
allow_ip_literals: !(prevContent.allow_ip_literals === false),
|
||||
};
|
||||
|
||||
let text = "";
|
||||
if (prev.deny.length === 0 && prev.allow.length === 0) {
|
||||
text = `${senderDisplayName} set server ACLs for this room: `;
|
||||
text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName});
|
||||
} else {
|
||||
text = `${senderDisplayName} changed the server ACLs for this room: `;
|
||||
text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName});
|
||||
}
|
||||
|
||||
if (!Array.isArray(current.allow)) {
|
||||
current.allow = [];
|
||||
}
|
||||
/* If we know for sure everyone is banned, don't bother showing the diff view */
|
||||
|
||||
// If we know for sure everyone is banned, mark the room as obliterated
|
||||
if (current.allow.length === 0) {
|
||||
return text + "🎉 All servers are banned from participating! This room can no longer be used.";
|
||||
return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(current.deny)) {
|
||||
current.deny = [];
|
||||
}
|
||||
|
||||
const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv));
|
||||
const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv));
|
||||
const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv));
|
||||
const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv));
|
||||
|
||||
if (bannedServers.length > 0) {
|
||||
changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`);
|
||||
}
|
||||
|
||||
if (unbannedServers.length > 0) {
|
||||
changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`);
|
||||
}
|
||||
|
||||
if (allowedServers.length > 0) {
|
||||
changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`);
|
||||
}
|
||||
|
||||
if (unallowedServers.length > 0) {
|
||||
changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`);
|
||||
}
|
||||
|
||||
if (prev.allow_ip_literals !== current.allow_ip_literals) {
|
||||
const allowban = current.allow_ip_literals ? "allowed" : "banned";
|
||||
changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`);
|
||||
}
|
||||
|
||||
return text + changes.join(" ");
|
||||
return text;
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev) {
|
||||
|
@ -341,14 +300,27 @@ function textForCallHangupEvent(event) {
|
|||
reason = _t('(not supported by this browser)');
|
||||
} else if (eventContent.reason) {
|
||||
if (eventContent.reason === "ice_failed") {
|
||||
// We couldn't establish a connection at all
|
||||
reason = _t('(could not connect media)');
|
||||
} else if (eventContent.reason === "ice_timeout") {
|
||||
// We established a connection but it died
|
||||
reason = _t('(connection failed)');
|
||||
} else if (eventContent.reason === "user_media_failed") {
|
||||
// The other side couldn't open capture devices
|
||||
reason = _t("(their device couldn't start the camera / microphone)");
|
||||
} else if (eventContent.reason === "unknown_error") {
|
||||
// An error code the other side doesn't have a way to express
|
||||
// (as opposed to an error code they gave but we don't know about,
|
||||
// in which case we show the error code)
|
||||
reason = _t("(an error occurred)");
|
||||
} else if (eventContent.reason === "invite_timeout") {
|
||||
reason = _t('(no answer)');
|
||||
} else if (eventContent.reason === "user hangup") {
|
||||
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
|
||||
// workaround for https://github.com/vector-im/element-web/issues/5178
|
||||
// it seems Android randomly sets a reason of "user hangup" which is
|
||||
// interpreted as an error code :(
|
||||
// https://github.com/vector-im/riot-android/issues/2623
|
||||
// Also the correct hangup code as of VoIP v1 (with underscore)
|
||||
reason = '';
|
||||
} else {
|
||||
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
|
||||
|
@ -357,6 +329,11 @@ function textForCallHangupEvent(event) {
|
|||
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
|
||||
}
|
||||
|
||||
function textForCallRejectEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
return _t('%(senderName)s declined the call.', {senderName});
|
||||
}
|
||||
|
||||
function textForCallInviteEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
|
@ -586,6 +563,7 @@ const handlers = {
|
|||
'm.call.invite': textForCallInviteEvent,
|
||||
'm.call.answer': textForCallAnswerEvent,
|
||||
'm.call.hangup': textForCallHangupEvent,
|
||||
'm.call.reject': textForCallRejectEvent,
|
||||
};
|
||||
|
||||
const stateHandlers = {
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// const OUTBOUND_API_NAME = 'toWidget';
|
||||
|
||||
// Initiate requests using the "toWidget" postMessage API and handle responses
|
||||
// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a
|
||||
// response field
|
||||
export default class ToWidgetPostMessageApi {
|
||||
constructor(timeoutMs) {
|
||||
this._timeoutMs = timeoutMs || 5000; // default to 5s timer
|
||||
this._counter = 0;
|
||||
this._requestMap = {
|
||||
// $ID: {resolve, reject}
|
||||
};
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.onPostMessage = this.onPostMessage.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
window.addEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
onPostMessage(ev) {
|
||||
// THIS IS ALL UNSAFE EXECUTION.
|
||||
// We do not verify who the sender of `ev` is!
|
||||
const payload = ev.data;
|
||||
// NOTE: Workaround for running in a mobile WebView where a
|
||||
// postMessage immediately triggers this callback even though it is
|
||||
// not the response.
|
||||
if (payload.response === undefined) {
|
||||
return;
|
||||
}
|
||||
const promise = this._requestMap[payload.requestId];
|
||||
if (!promise) {
|
||||
return;
|
||||
}
|
||||
delete this._requestMap[payload.requestId];
|
||||
promise.resolve(payload);
|
||||
}
|
||||
|
||||
// Initiate outbound requests (toWidget)
|
||||
exec(action, targetWindow, targetOrigin) {
|
||||
targetWindow = targetWindow || window.parent; // default to parent window
|
||||
targetOrigin = targetOrigin || "*";
|
||||
this._counter += 1;
|
||||
action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._requestMap[action.requestId] = {resolve, reject};
|
||||
targetWindow.postMessage(action, targetOrigin);
|
||||
|
||||
if (this._timeoutMs > 0) {
|
||||
setTimeout(() => {
|
||||
if (!this._requestMap[action.requestId]) {
|
||||
return;
|
||||
}
|
||||
console.error("postMessage request timed out. Sent object: " + JSON.stringify(action),
|
||||
this._requestMap);
|
||||
this._requestMap[action.requestId].reject(new Error("Timed out"));
|
||||
delete this._requestMap[action.requestId];
|
||||
}, this._timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
|
|||
* see doc on the userActive* functions for what these mean.
|
||||
*/
|
||||
export default class UserActivity {
|
||||
constructor(windowObj, documentObj) {
|
||||
this._window = windowObj;
|
||||
this._document = documentObj;
|
||||
private readonly activeNowTimeout: Timer;
|
||||
private readonly activeRecentlyTimeout: Timer;
|
||||
private attachedActiveNowTimers: Timer[] = [];
|
||||
private attachedActiveRecentlyTimers: Timer[] = [];
|
||||
private lastScreenX = 0;
|
||||
private lastScreenY = 0;
|
||||
|
||||
this._attachedActiveNowTimers = [];
|
||||
this._attachedActiveRecentlyTimers = [];
|
||||
this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
|
||||
this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
|
||||
this._onUserActivity = this._onUserActivity.bind(this);
|
||||
this._onWindowBlurred = this._onWindowBlurred.bind(this);
|
||||
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
|
||||
this.lastScreenX = 0;
|
||||
this.lastScreenY = 0;
|
||||
constructor(private readonly window: Window, private readonly document: Document) {
|
||||
this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
|
||||
this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
|
||||
}
|
||||
|
||||
static sharedInstance() {
|
||||
if (global.mxUserActivity === undefined) {
|
||||
global.mxUserActivity = new UserActivity(window, document);
|
||||
if (window.mxUserActivity === undefined) {
|
||||
window.mxUserActivity = new UserActivity(window, document);
|
||||
}
|
||||
return global.mxUserActivity;
|
||||
return window.mxUserActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,8 +66,8 @@ export default class UserActivity {
|
|||
* later on when the user does become active.
|
||||
* @param {Timer} timer the timer to use
|
||||
*/
|
||||
timeWhileActiveNow(timer) {
|
||||
this._timeWhile(timer, this._attachedActiveNowTimers);
|
||||
public timeWhileActiveNow(timer: Timer) {
|
||||
this.timeWhile(timer, this.attachedActiveNowTimers);
|
||||
if (this.userActiveNow()) {
|
||||
timer.start();
|
||||
}
|
||||
|
@ -85,14 +82,14 @@ export default class UserActivity {
|
|||
* later on when the user does become active.
|
||||
* @param {Timer} timer the timer to use
|
||||
*/
|
||||
timeWhileActiveRecently(timer) {
|
||||
this._timeWhile(timer, this._attachedActiveRecentlyTimers);
|
||||
public timeWhileActiveRecently(timer: Timer) {
|
||||
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
|
||||
if (this.userActiveRecently()) {
|
||||
timer.start();
|
||||
}
|
||||
}
|
||||
|
||||
_timeWhile(timer, attachedTimers) {
|
||||
private timeWhile(timer: Timer, attachedTimers: Timer[]) {
|
||||
// important this happens first
|
||||
const index = attachedTimers.indexOf(timer);
|
||||
if (index === -1) {
|
||||
|
@ -112,36 +109,36 @@ export default class UserActivity {
|
|||
/**
|
||||
* Start listening to user activity
|
||||
*/
|
||||
start() {
|
||||
this._document.addEventListener('mousedown', this._onUserActivity);
|
||||
this._document.addEventListener('mousemove', this._onUserActivity);
|
||||
this._document.addEventListener('keydown', this._onUserActivity);
|
||||
this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
|
||||
this._window.addEventListener("blur", this._onWindowBlurred);
|
||||
this._window.addEventListener("focus", this._onUserActivity);
|
||||
public start() {
|
||||
this.document.addEventListener('mousedown', this.onUserActivity);
|
||||
this.document.addEventListener('mousemove', this.onUserActivity);
|
||||
this.document.addEventListener('keydown', this.onUserActivity);
|
||||
this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
|
||||
this.window.addEventListener("blur", this.onWindowBlurred);
|
||||
this.window.addEventListener("focus", this.onUserActivity);
|
||||
// can't use document.scroll here because that's only the document
|
||||
// itself being scrolled. Need to use addEventListener's useCapture.
|
||||
// also this needs to be the wheel event, not scroll, as scroll is
|
||||
// fired when the view scrolls down for a new message.
|
||||
this._window.addEventListener('wheel', this._onUserActivity, {
|
||||
passive: true, capture: true,
|
||||
this.window.addEventListener('wheel', this.onUserActivity, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking user activity
|
||||
*/
|
||||
stop() {
|
||||
this._document.removeEventListener('mousedown', this._onUserActivity);
|
||||
this._document.removeEventListener('mousemove', this._onUserActivity);
|
||||
this._document.removeEventListener('keydown', this._onUserActivity);
|
||||
this._window.removeEventListener('wheel', this._onUserActivity, {
|
||||
passive: true, capture: true,
|
||||
public stop() {
|
||||
this.document.removeEventListener('mousedown', this.onUserActivity);
|
||||
this.document.removeEventListener('mousemove', this.onUserActivity);
|
||||
this.document.removeEventListener('keydown', this.onUserActivity);
|
||||
this.window.removeEventListener('wheel', this.onUserActivity, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
|
||||
this._window.removeEventListener("blur", this._onWindowBlurred);
|
||||
this._window.removeEventListener("focus", this._onUserActivity);
|
||||
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
|
||||
this.window.removeEventListener("blur", this.onWindowBlurred);
|
||||
this.window.removeEventListener("focus", this.onUserActivity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -151,8 +148,8 @@ export default class UserActivity {
|
|||
* user's attention at any given moment.
|
||||
* @returns {boolean} true if user is currently 'active'
|
||||
*/
|
||||
userActiveNow() {
|
||||
return this._activeNowTimeout.isRunning();
|
||||
public userActiveNow() {
|
||||
return this.activeNowTimeout.isRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -163,27 +160,27 @@ export default class UserActivity {
|
|||
* (or they may have gone to make tea and left the window focused).
|
||||
* @returns {boolean} true if user has been active recently
|
||||
*/
|
||||
userActiveRecently() {
|
||||
return this._activeRecentlyTimeout.isRunning();
|
||||
public userActiveRecently() {
|
||||
return this.activeRecentlyTimeout.isRunning();
|
||||
}
|
||||
|
||||
_onPageVisibilityChanged(e) {
|
||||
if (this._document.visibilityState === "hidden") {
|
||||
this._activeNowTimeout.abort();
|
||||
this._activeRecentlyTimeout.abort();
|
||||
private onPageVisibilityChanged = e => {
|
||||
if (this.document.visibilityState === "hidden") {
|
||||
this.activeNowTimeout.abort();
|
||||
this.activeRecentlyTimeout.abort();
|
||||
} else {
|
||||
this._onUserActivity(e);
|
||||
this.onUserActivity(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onWindowBlurred() {
|
||||
this._activeNowTimeout.abort();
|
||||
this._activeRecentlyTimeout.abort();
|
||||
}
|
||||
private onWindowBlurred = () => {
|
||||
this.activeNowTimeout.abort();
|
||||
this.activeRecentlyTimeout.abort();
|
||||
};
|
||||
|
||||
_onUserActivity(event) {
|
||||
private onUserActivity = (event: MouseEvent) => {
|
||||
// ignore anything if the window isn't focused
|
||||
if (!this._document.hasFocus()) return;
|
||||
if (!this.document.hasFocus()) return;
|
||||
|
||||
if (event.screenX && event.type === "mousemove") {
|
||||
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
|
||||
|
@ -195,25 +192,25 @@ export default class UserActivity {
|
|||
}
|
||||
|
||||
dis.dispatch({action: 'user_activity'});
|
||||
if (!this._activeNowTimeout.isRunning()) {
|
||||
this._activeNowTimeout.start();
|
||||
if (!this.activeNowTimeout.isRunning()) {
|
||||
this.activeNowTimeout.start();
|
||||
dis.dispatch({action: 'user_activity_start'});
|
||||
|
||||
this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout);
|
||||
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
|
||||
} else {
|
||||
this._activeNowTimeout.restart();
|
||||
this.activeNowTimeout.restart();
|
||||
}
|
||||
|
||||
if (!this._activeRecentlyTimeout.isRunning()) {
|
||||
this._activeRecentlyTimeout.start();
|
||||
if (!this.activeRecentlyTimeout.isRunning()) {
|
||||
this.activeRecentlyTimeout.start();
|
||||
|
||||
this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout);
|
||||
UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
|
||||
} else {
|
||||
this._activeRecentlyTimeout.restart();
|
||||
this.activeRecentlyTimeout.restart();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async _runTimersUntilTimeout(attachedTimers, timeout) {
|
||||
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) {
|
||||
attachedTimers.forEach((t) => t.start());
|
||||
try {
|
||||
await timeout.finished();
|
|
@ -1,135 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {createNewMatrixCall as jsCreateNewMatrixCall, Room} from "matrix-js-sdk";
|
||||
import CallHandler from './CallHandler';
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
|
||||
// FIXME: this is Element specific code, but will be removed shortly when we
|
||||
// switch over to Jitsi entirely for video conferencing.
|
||||
|
||||
// FIXME: This currently forces Element to try to hit the matrix.org AS for
|
||||
// conferencing. This is bad because it prevents people running their own ASes
|
||||
// from being used. This isn't permanent and will be customisable in the future:
|
||||
// see the proposal at docs/conferencing.md for more info.
|
||||
const USER_PREFIX = "fs_";
|
||||
const DOMAIN = "matrix.org";
|
||||
|
||||
export function ConferenceCall(matrixClient, groupChatRoomId) {
|
||||
this.client = matrixClient;
|
||||
this.groupRoomId = groupChatRoomId;
|
||||
this.confUserId = getConferenceUserIdForRoom(this.groupRoomId);
|
||||
}
|
||||
|
||||
ConferenceCall.prototype.setup = function() {
|
||||
const self = this;
|
||||
return this._joinConferenceUser().then(function() {
|
||||
return self._getConferenceUserRoom();
|
||||
}).then(function(room) {
|
||||
// return a call for *this* room to be placed. We also tack on
|
||||
// confUserId to speed up lookups (else we'd need to loop every room
|
||||
// looking for a 1:1 room with this conf user ID!)
|
||||
const call = jsCreateNewMatrixCall(self.client, room.roomId);
|
||||
call.confUserId = self.confUserId;
|
||||
call.groupRoomId = self.groupRoomId;
|
||||
return call;
|
||||
});
|
||||
};
|
||||
|
||||
ConferenceCall.prototype._joinConferenceUser = function() {
|
||||
// Make sure the conference user is in the group chat room
|
||||
const groupRoom = this.client.getRoom(this.groupRoomId);
|
||||
if (!groupRoom) {
|
||||
return Promise.reject("Bad group room ID");
|
||||
}
|
||||
const member = groupRoom.getMember(this.confUserId);
|
||||
if (member && member.membership === "join") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.client.invite(this.groupRoomId, this.confUserId);
|
||||
};
|
||||
|
||||
ConferenceCall.prototype._getConferenceUserRoom = function() {
|
||||
// Use an existing 1:1 with the conference user; else make one
|
||||
const rooms = this.client.getRooms();
|
||||
let confRoom = null;
|
||||
for (let i = 0; i < rooms.length; i++) {
|
||||
const confUser = rooms[i].getMember(this.confUserId);
|
||||
if (confUser && confUser.membership === "join" &&
|
||||
rooms[i].getJoinedMemberCount() === 2) {
|
||||
confRoom = rooms[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confRoom) {
|
||||
return Promise.resolve(confRoom);
|
||||
}
|
||||
return this.client.createRoom({
|
||||
preset: "private_chat",
|
||||
invite: [this.confUserId],
|
||||
}).then(function(res) {
|
||||
return new Room(res.room_id, null, MatrixClientPeg.get().getUserId());
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if this user ID is in fact a conference bot.
|
||||
* @param {string} userId The user ID to check.
|
||||
* @return {boolean} True if it is a conference bot.
|
||||
*/
|
||||
export function isConferenceUser(userId) {
|
||||
if (userId.indexOf("@" + USER_PREFIX) !== 0) {
|
||||
return false;
|
||||
}
|
||||
const base64part = userId.split(":")[0].substring(1 + USER_PREFIX.length);
|
||||
if (base64part) {
|
||||
const decoded = new Buffer(base64part, "base64").toString();
|
||||
// ! $STUFF : $STUFF
|
||||
return /^!.+:.+/.test(decoded);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getConferenceUserIdForRoom(roomId) {
|
||||
// abuse browserify's core node Buffer support (strip padding ='s)
|
||||
const base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, "");
|
||||
return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN;
|
||||
}
|
||||
|
||||
export function createNewMatrixCall(client, roomId) {
|
||||
const confCall = new ConferenceCall(
|
||||
client, roomId,
|
||||
);
|
||||
return confCall.setup();
|
||||
}
|
||||
|
||||
export function getConferenceCallForRoom(roomId) {
|
||||
// search for a conference 1:1 call for this group chat room ID
|
||||
const activeCall = CallHandler.getAnyActiveCall();
|
||||
if (activeCall && activeCall.confUserId) {
|
||||
const thisRoomConfUserId = getConferenceUserIdForRoom(
|
||||
roomId,
|
||||
);
|
||||
if (thisRoomConfUserId === activeCall.confUserId) {
|
||||
return activeCall;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Document this.
|
||||
export const slot = 'conference';
|
|
@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
export function usersTypingApartFromMeAndIgnored(room) {
|
||||
return usersTyping(
|
||||
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
|
||||
);
|
||||
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
|
||||
return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers()));
|
||||
}
|
||||
|
||||
export function usersTypingApartFromMe(room) {
|
||||
return usersTyping(
|
||||
room, [MatrixClientPeg.get().credentials.userId],
|
||||
);
|
||||
export function usersTypingApartFromMe(room: Room): RoomMember[] {
|
||||
return usersTyping(room, [MatrixClientPeg.get().getUserId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) {
|
|||
* to exclude, return a list of user objects who are typing.
|
||||
* @param {Room} room: room object to get users from.
|
||||
* @param {string[]} exclude: list of user mxids to exclude.
|
||||
* @returns {string[]} list of user objects who are typing.
|
||||
* @returns {RoomMember[]} list of user objects who are typing.
|
||||
*/
|
||||
export function usersTyping(room, exclude) {
|
||||
export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
|
||||
const whoIsTyping = [];
|
||||
|
||||
if (exclude === undefined) {
|
||||
exclude = [];
|
||||
}
|
||||
|
||||
const memberKeys = Object.keys(room.currentState.members);
|
||||
for (let i = 0; i < memberKeys.length; ++i) {
|
||||
const userId = memberKeys[i];
|
||||
|
@ -57,20 +52,21 @@ export function usersTyping(room, exclude) {
|
|||
return whoIsTyping;
|
||||
}
|
||||
|
||||
export function whoIsTypingString(whoIsTyping, limit) {
|
||||
export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string {
|
||||
let othersCount = 0;
|
||||
if (whoIsTyping.length > limit) {
|
||||
othersCount = whoIsTyping.length - limit + 1;
|
||||
}
|
||||
|
||||
if (whoIsTyping.length === 0) {
|
||||
return '';
|
||||
} else if (whoIsTyping.length === 1) {
|
||||
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
|
||||
}
|
||||
const names = whoIsTyping.map(function(m) {
|
||||
return m.name;
|
||||
});
|
||||
if (othersCount>=1) {
|
||||
|
||||
const names = whoIsTyping.map(m => m.name);
|
||||
|
||||
if (othersCount >= 1) {
|
||||
return _t('%(names)s and %(count)s others are typing …', {
|
||||
names: names.slice(0, limit - 1).join(', '),
|
||||
count: othersCount,
|
|
@ -1,205 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2019 Travis Ralston
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for
|
||||
* spec. details / documentation.
|
||||
*/
|
||||
|
||||
import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
|
||||
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
|
||||
import Modal from "./Modal";
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
|
||||
import WidgetUtils from "./utils/WidgetUtils";
|
||||
import {KnownWidgetActions} from "./widgets/WidgetApi";
|
||||
|
||||
if (!global.mxFromWidgetMessaging) {
|
||||
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
|
||||
global.mxFromWidgetMessaging.start();
|
||||
}
|
||||
if (!global.mxToWidgetMessaging) {
|
||||
global.mxToWidgetMessaging = new ToWidgetPostMessageApi();
|
||||
global.mxToWidgetMessaging.start();
|
||||
}
|
||||
|
||||
const OUTBOUND_API_NAME = 'toWidget';
|
||||
|
||||
export default class WidgetMessaging {
|
||||
/**
|
||||
* @param {string} widgetId The widget's ID
|
||||
* @param {string} wurl The raw URL of the widget as in the event (the 'wURL')
|
||||
* @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL
|
||||
* or a different URL of the clients choosing if it is using its own impl).
|
||||
* @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget
|
||||
* @param {object} target Where widget messages should be sent (eg. the iframe object)
|
||||
*/
|
||||
constructor(widgetId, wurl, renderedUrl, isUserWidget, target) {
|
||||
this.widgetId = widgetId;
|
||||
this.wurl = wurl;
|
||||
this.renderedUrl = renderedUrl;
|
||||
this.isUserWidget = isUserWidget;
|
||||
this.target = target;
|
||||
this.fromWidget = global.mxFromWidgetMessaging;
|
||||
this.toWidget = global.mxToWidgetMessaging;
|
||||
this._onOpenIdRequest = this._onOpenIdRequest.bind(this);
|
||||
this.start();
|
||||
}
|
||||
|
||||
messageToWidget(action) {
|
||||
action.widgetId = this.widgetId; // Required to be sent for all outbound requests
|
||||
|
||||
return this.toWidget.exec(action, this.target).then((data) => {
|
||||
// Check for errors and reject if found
|
||||
if (data.response === undefined) { // null is valid
|
||||
throw new Error("Missing 'response' field");
|
||||
}
|
||||
if (data.response && data.response.error) {
|
||||
const err = data.response.error;
|
||||
const msg = String(err.message ? err.message : "An error was returned");
|
||||
if (err._error) {
|
||||
console.error(err._error);
|
||||
}
|
||||
// Potential XSS attack if 'msg' is not appropriately sanitized,
|
||||
// as it is untrusted input by our parent window (which we assume is Element).
|
||||
// We can't aggressively sanitize [A-z0-9] since it might be a translation.
|
||||
throw new Error(msg);
|
||||
}
|
||||
// Return the response field for the request
|
||||
return data.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget that the client is ready to handle further widget requests.
|
||||
* @returns {Promise<*>} Resolves after the widget has acknowledged the ready message.
|
||||
*/
|
||||
flagReadyToContinue() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.ClientReady,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget that it should terminate now.
|
||||
* @returns {Promise<*>} Resolves when widget has acknowledged the message.
|
||||
*/
|
||||
terminate() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.Terminate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a screenshot from a widget
|
||||
* @return {Promise} To be resolved with screenshot data when it has been generated
|
||||
*/
|
||||
getScreenshot() {
|
||||
console.log('Requesting screenshot for', this.widgetId);
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "screenshot",
|
||||
})
|
||||
.catch((error) => new Error("Failed to get screenshot: " + error.message))
|
||||
.then((response) => response.screenshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request capabilities required by the widget
|
||||
* @return {Promise} To be resolved with an array of requested widget capabilities
|
||||
*/
|
||||
getCapabilities() {
|
||||
console.log('Requesting capabilities for', this.widgetId);
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "capabilities",
|
||||
}).then((response) => {
|
||||
console.log('Got capabilities for', this.widgetId, response.capabilities);
|
||||
return response.capabilities;
|
||||
});
|
||||
}
|
||||
|
||||
sendVisibility(visible) {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "visibility",
|
||||
visible,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to send visibility: ", error);
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl);
|
||||
this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl);
|
||||
this.fromWidget.removeListener("get_openid", this._onOpenIdRequest);
|
||||
}
|
||||
|
||||
async _onOpenIdRequest(ev, rawEv) {
|
||||
if (ev.widgetId !== this.widgetId) return; // not interesting
|
||||
|
||||
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget);
|
||||
|
||||
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
|
||||
if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
|
||||
this.fromWidget.sendResponse(rawEv, {state: "blocked"});
|
||||
return;
|
||||
}
|
||||
if (settings.allow && settings.allow.includes(widgetSecurityKey)) {
|
||||
const responseBody = {state: "allowed"};
|
||||
const credentials = await MatrixClientPeg.get().getOpenIdToken();
|
||||
Object.assign(responseBody, credentials);
|
||||
this.fromWidget.sendResponse(rawEv, responseBody);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm that we received the request
|
||||
this.fromWidget.sendResponse(rawEv, {state: "request"});
|
||||
|
||||
// Actually ask for permission to send the user's data
|
||||
Modal.createTrackedDialog("OpenID widget permissions", '',
|
||||
WidgetOpenIDPermissionsDialog, {
|
||||
widgetUrl: this.wurl,
|
||||
widgetId: this.widgetId,
|
||||
isUserWidget: this.isUserWidget,
|
||||
|
||||
onFinished: async (confirm) => {
|
||||
const responseBody = {success: confirm};
|
||||
if (confirm) {
|
||||
const credentials = await MatrixClientPeg.get().getOpenIdToken();
|
||||
Object.assign(responseBody, credentials);
|
||||
}
|
||||
this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "openid_credentials",
|
||||
data: responseBody,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send OpenID credentials: ", error);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Represents mapping of widget instance to URLs for trusted postMessage communication.
|
||||
*/
|
||||
export default class WidgetMessageEndpoint {
|
||||
/**
|
||||
* Mapping of widget instance to URL for trusted postMessage communication.
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin.
|
||||
*/
|
||||
constructor(widgetId, endpointUrl) {
|
||||
if (!widgetId) {
|
||||
throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
if (!endpointUrl) {
|
||||
throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
this.widgetId = widgetId;
|
||||
this.endpointUrl = endpointUrl;
|
||||
}
|
||||
}
|
|
@ -168,7 +168,7 @@ const shortcuts: Record<Categories, IShortcut[]> = {
|
|||
key: Key.U,
|
||||
}],
|
||||
description: _td("Upload a file"),
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
[Categories.ROOM_LIST]: [
|
||||
|
|
|
@ -166,7 +166,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
|
|||
|
||||
const onKeyDownHandler = useCallback((ev) => {
|
||||
let handled = false;
|
||||
if (handleHomeEnd) {
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (handleHomeEnd && ev.target.tagName !== "INPUT") {
|
||||
// check if we actually have any items
|
||||
switch (ev.key) {
|
||||
case Key.HOME:
|
||||
|
@ -190,7 +191,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
|
|||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
} else if (onKeyDown) {
|
||||
return onKeyDown(ev, state);
|
||||
return onKeyDown(ev, context.state);
|
||||
}
|
||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
||||
|
||||
|
|
|
@ -28,8 +28,12 @@ interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
|||
const Toolbar: React.FC<IProps> = ({children, ...props}) => {
|
||||
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (target.tagName === "INPUT") return;
|
||||
|
||||
let handled = true;
|
||||
|
||||
// HOME and END are handled by RovingTabIndexProvider
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
|
@ -47,8 +51,6 @@ const Toolbar: React.FC<IProps> = ({children, ...props}) => {
|
|||
}
|
||||
break;
|
||||
|
||||
// HOME and END are handled by RovingTabIndexProvider
|
||||
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import React from "react";
|
|||
|
||||
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof AccessibleTooltipButton> {
|
||||
interface IProps extends React.ComponentProps<typeof AccessibleTooltipButton> {
|
||||
// whether or not the context menu is currently open
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
|
|
@ -26,8 +26,9 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
|
||||
// Semantic component for representing a role=menuitem
|
||||
export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => {
|
||||
const ariaLabel = props["aria-label"] || label;
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
|
||||
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
|
|
|
@ -20,7 +20,8 @@ import AccessibleTooltipButton from "../../components/views/elements/AccessibleT
|
|||
import {useRovingTabIndex} from "../RovingTabIndex";
|
||||
import {Ref} from "./types";
|
||||
|
||||
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "onFocus" | "inputRef" | "tabIndex"> {
|
||||
type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>;
|
||||
interface IProps extends Omit<ATBProps, "onFocus" | "inputRef" | "tabIndex"> {
|
||||
inputRef?: Ref;
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import {useRovingTabIndex} from "../RovingTabIndex";
|
||||
import {FocusHandler, Ref} from "./types";
|
||||
|
||||
|
|
|
@ -17,14 +17,14 @@ limitations under the License.
|
|||
|
||||
import Analytics from '../Analytics';
|
||||
import { asyncAction } from './actionCreators';
|
||||
import TagOrderStore from '../stores/TagOrderStore';
|
||||
import GroupFilterOrderStore from '../stores/GroupFilterOrderStore';
|
||||
import { AsyncActionPayload } from "../dispatcher/payloads";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
export default class TagOrderActions {
|
||||
/**
|
||||
* Creates an action thunk that will do an asynchronous request to
|
||||
* move a tag in TagOrderStore to destinationIx.
|
||||
* move a tag in GroupFilterOrderStore to destinationIx.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client to set the
|
||||
* account data on.
|
||||
|
@ -36,8 +36,8 @@ export default class TagOrderActions {
|
|||
*/
|
||||
public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload {
|
||||
// Only commit tags if the state is ready, i.e. not null
|
||||
let tags = TagOrderStore.getOrderedTags();
|
||||
let removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
|
||||
let tags = GroupFilterOrderStore.getOrderedTags();
|
||||
let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
|
||||
if (!tags) {
|
||||
return;
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export default class TagOrderActions {
|
|||
|
||||
removedTags = removedTags.filter((t) => t !== tag);
|
||||
|
||||
const storeId = TagOrderStore.getStoreId();
|
||||
const storeId = GroupFilterOrderStore.getStoreId();
|
||||
|
||||
return asyncAction('TagOrderActions.moveTag', () => {
|
||||
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
|
||||
|
@ -83,8 +83,8 @@ export default class TagOrderActions {
|
|||
*/
|
||||
public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload {
|
||||
// Don't change tags, just removedTags
|
||||
const tags = TagOrderStore.getOrderedTags();
|
||||
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
|
||||
const tags = GroupFilterOrderStore.getOrderedTags();
|
||||
const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
|
||||
|
||||
if (removedTags.includes(tag)) {
|
||||
// Return a thunk that doesn't do anything, we don't even need
|
||||
|
@ -94,7 +94,7 @@ export default class TagOrderActions {
|
|||
|
||||
removedTags.push(tag);
|
||||
|
||||
const storeId = TagOrderStore.getStoreId();
|
||||
const storeId = GroupFilterOrderStore.getStoreId();
|
||||
|
||||
return asyncAction('TagOrderActions.removeTag', () => {
|
||||
Analytics.trackEvent('TagOrderActions', 'removeTag');
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import * as sdk from "../../../../index";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
|
||||
export default class IgnoreRecoveryReminderDialog extends React.PureComponent {
|
||||
static propTypes = {
|
||||
onDontAskAgain: PropTypes.func.isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
onSetup: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
onDontAskAgainClick = () => {
|
||||
this.props.onFinished();
|
||||
this.props.onDontAskAgain();
|
||||
}
|
||||
|
||||
onSetupClick = () => {
|
||||
this.props.onFinished();
|
||||
this.props.onSetup();
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
|
||||
const DialogButtons = sdk.getComponent("views.elements.DialogButtons");
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_IgnoreRecoveryReminderDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Are you sure?")}
|
||||
>
|
||||
<div>
|
||||
<p>{_t(
|
||||
"Without setting up Secure Message Recovery, " +
|
||||
"you'll lose your secure message history when you " +
|
||||
"log out.",
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"If you don't want to set this up now, you can later " +
|
||||
"in Settings.",
|
||||
)}</p>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<DialogButtons
|
||||
primaryButton={_t("Set up")}
|
||||
onPrimaryButtonClick={this.onSetupClick}
|
||||
cancelButton={_t("Don't ask again")}
|
||||
onCancel={this.onDontAskAgainClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -21,7 +21,7 @@ import * as sdk from '../../../../index';
|
|||
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t, _td} from '../../../../languageHandler';
|
||||
import { accessSecretStorage } from '../../../../CrossSigningManager';
|
||||
import { accessSecretStorage } from '../../../../SecurityManager';
|
||||
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
|
||||
import {copyNode} from "../../../../utils/strings";
|
||||
import PassphraseField from "../../../../components/views/auth/PassphraseField";
|
|
@ -22,7 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
|||
import FileSaver from 'file-saver';
|
||||
import {_t, _td} from '../../../../languageHandler';
|
||||
import Modal from '../../../../Modal';
|
||||
import { promptForBackupPassphrase } from '../../../../CrossSigningManager';
|
||||
import { promptForBackupPassphrase } from '../../../../SecurityManager';
|
||||
import {copyNode} from "../../../../utils/strings";
|
||||
import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents";
|
||||
import PassphraseField from "../../../../components/views/auth/PassphraseField";
|
||||
|
@ -30,6 +30,9 @@ import StyledRadioButton from '../../../../components/views/elements/StyledRadio
|
|||
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
|
||||
import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
||||
import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
|
||||
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
|
||||
import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils';
|
||||
import SecurityCustomisations from "../../../../customisations/Security";
|
||||
|
||||
const PHASE_LOADING = 0;
|
||||
const PHASE_LOADERROR = 1;
|
||||
|
@ -55,12 +58,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
static propTypes = {
|
||||
hasCancel: PropTypes.bool,
|
||||
accountPassword: PropTypes.string,
|
||||
force: PropTypes.bool,
|
||||
forceReset: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
hasCancel: true,
|
||||
force: false,
|
||||
forceReset: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -85,13 +88,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
canUploadKeysWithPasswordOnly: null,
|
||||
accountPassword: props.accountPassword || "",
|
||||
accountPasswordCorrect: null,
|
||||
|
||||
passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY,
|
||||
canSkip: !isSecureBackupRequired(),
|
||||
};
|
||||
|
||||
const setupMethods = getSecureBackupSetupMethods();
|
||||
if (setupMethods.includes("key")) {
|
||||
this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_KEY;
|
||||
} else {
|
||||
this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_PASSPHRASE;
|
||||
}
|
||||
|
||||
this._passphraseField = createRef();
|
||||
|
||||
this._fetchBackupInfo();
|
||||
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
|
||||
|
||||
if (this.state.accountPassword) {
|
||||
// If we have an account password in memory, let's simplify and
|
||||
// assume it means password auth is also supported for device
|
||||
|
@ -102,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
this._queryKeyUploadAuth();
|
||||
}
|
||||
|
||||
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
|
||||
this._getInitialPhase();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
|
||||
}
|
||||
|
||||
_getInitialPhase() {
|
||||
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Created key via customisations, jumping to bootstrap step");
|
||||
this._recoveryKey = {
|
||||
privateKey: keyFromCustomisations,
|
||||
};
|
||||
this._bootstrapSecretStorage();
|
||||
return;
|
||||
}
|
||||
|
||||
this._fetchBackupInfo();
|
||||
}
|
||||
|
||||
async _fetchBackupInfo() {
|
||||
try {
|
||||
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||
|
@ -117,8 +141,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
|
||||
);
|
||||
|
||||
const { force } = this.props;
|
||||
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE;
|
||||
const { forceReset } = this.props;
|
||||
const phase = (backupInfo && !forceReset) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE;
|
||||
|
||||
this.setState({
|
||||
phase,
|
||||
|
@ -276,20 +300,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
const { force } = this.props;
|
||||
const { forceReset } = this.props;
|
||||
|
||||
try {
|
||||
if (force) {
|
||||
console.log("Forcing secret storage reset"); // log something so we can debug this later
|
||||
if (forceReset) {
|
||||
console.log("Forcing secret storage reset");
|
||||
await cli.bootstrapSecretStorage({
|
||||
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
|
||||
createSecretStorageKey: async () => this._recoveryKey,
|
||||
setupNewKeyBackup: true,
|
||||
setupNewSecretStorage: true,
|
||||
});
|
||||
} else {
|
||||
await cli.bootstrapSecretStorage({
|
||||
// For password authentication users after 2020-09, this cross-signing
|
||||
// step will be a no-op since it is now setup during registration or login
|
||||
// when needed. We should keep this here to cover other cases such as:
|
||||
// * Users with existing sessions prior to 2020-09 changes
|
||||
// * SSO authentication users which require interactive auth to upload
|
||||
// keys (and also happen to skip all post-authentication flows at the
|
||||
// moment via token login)
|
||||
await cli.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
|
||||
});
|
||||
await cli.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => this._recoveryKey,
|
||||
keyBackupInfo: this.state.backupInfo,
|
||||
setupNewKeyBackup: !this.state.backupInfo,
|
||||
|
@ -332,7 +364,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
// so let's stash it here, rather than prompting for it twice.
|
||||
const keyCallback = k => this._backupKey = k;
|
||||
|
||||
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
|
||||
const { finished } = Modal.createTrackedDialog(
|
||||
'Restore Backup', '', RestoreKeyBackupDialog,
|
||||
{
|
||||
|
@ -432,45 +463,61 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
_renderOptionKey() {
|
||||
return (
|
||||
<StyledRadioButton
|
||||
key={CREATE_STORAGE_OPTION_KEY}
|
||||
value={CREATE_STORAGE_OPTION_KEY}
|
||||
name="keyPassphrase"
|
||||
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
_renderOptionPassphrase() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
_renderPhaseChooseKeyPassphrase() {
|
||||
const setupMethods = getSecureBackupSetupMethods();
|
||||
const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null;
|
||||
const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null;
|
||||
|
||||
return <form onSubmit={this._onChooseKeyPassphraseFormSubmit}>
|
||||
<p className="mx_CreateSecretStorageDialog_centeredBody">{_t(
|
||||
"Safeguard against losing access to encrypted messages & data by " +
|
||||
"backing up encryption keys on your server.",
|
||||
)}</p>
|
||||
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup" onChange={this._onKeyPassphraseChange}>
|
||||
<StyledRadioButton
|
||||
key={CREATE_STORAGE_OPTION_KEY}
|
||||
value={CREATE_STORAGE_OPTION_KEY}
|
||||
name="keyPassphrase"
|
||||
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
|
||||
outlined
|
||||
>
|
||||
<div className="mx_CreateSecretStorageDialog_optionTitle">
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
|
||||
{_t("Generate a Security Key")}
|
||||
</div>
|
||||
<div>{_t("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>
|
||||
{optionKey}
|
||||
{optionPassphrase}
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Continue")}
|
||||
onPrimaryButtonClick={this._onChooseKeyPassphraseFormSubmit}
|
||||
onCancel={this._onCancelClick}
|
||||
hasCancel={true}
|
||||
hasCancel={this.state.canSkip}
|
||||
/>
|
||||
</form>;
|
||||
}
|
||||
|
@ -687,7 +734,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
<div className="mx_Dialog_buttons">
|
||||
<DialogButtons primaryButton={_t('Retry')}
|
||||
onPrimaryButtonClick={this._onLoadRetryClick}
|
||||
hasCancel={true}
|
||||
hasCancel={this.state.canSkip}
|
||||
onCancel={this._onCancel}
|
||||
/>
|
||||
</div>
|
||||
|
@ -714,7 +761,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
_titleForPhase(phase) {
|
||||
switch (phase) {
|
||||
case PHASE_CHOOSE_KEY_PASSPHRASE:
|
||||
return _t('Set up Secure backup');
|
||||
return _t('Set up Secure Backup');
|
||||
case PHASE_MIGRATE:
|
||||
return _t('Upgrade your encryption');
|
||||
case PHASE_PASSPHRASE:
|
||||
|
@ -742,7 +789,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
<div className="mx_Dialog_buttons">
|
||||
<DialogButtons primaryButton={_t('Retry')}
|
||||
onPrimaryButtonClick={this._bootstrapSecretStorage}
|
||||
hasCancel={true}
|
||||
hasCancel={this.state.canSkip}
|
||||
onCancel={this._onCancel}
|
||||
/>
|
||||
</div>
|
|
@ -17,44 +17,40 @@ limitations under the License.
|
|||
import FileSaver from 'file-saver';
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t } from '../../../../languageHandler';
|
||||
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
|
||||
import * as sdk from '../../../index';
|
||||
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
|
||||
import * as sdk from '../../../../index';
|
||||
|
||||
const PHASE_EDIT = 1;
|
||||
const PHASE_EXPORTING = 2;
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ExportE2eKeysDialog',
|
||||
|
||||
propTypes: {
|
||||
export default class ExportE2eKeysDialog extends React.Component {
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
phase: PHASE_EDIT,
|
||||
errStr: null,
|
||||
};
|
||||
},
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
|
||||
this._passphrase1 = createRef();
|
||||
this._passphrase2 = createRef();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.state = {
|
||||
phase: PHASE_EDIT,
|
||||
errStr: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
}
|
||||
|
||||
_onPassphraseFormSubmit: function(ev) {
|
||||
_onPassphraseFormSubmit = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const passphrase = this._passphrase1.current.value;
|
||||
|
@ -69,9 +65,9 @@ export default createReactClass({
|
|||
|
||||
this._startExport(passphrase);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
_startExport: function(passphrase) {
|
||||
_startExport(passphrase) {
|
||||
// extra Promise.resolve() to turn synchronous exceptions into
|
||||
// asynchronous ones.
|
||||
Promise.resolve().then(() => {
|
||||
|
@ -102,15 +98,15 @@ export default createReactClass({
|
|||
errStr: null,
|
||||
phase: PHASE_EXPORTING,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onCancelClick: function(ev) {
|
||||
_onCancelClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
this.props.onFinished(false);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
||||
const disableForm = (this.state.phase === PHASE_EXPORTING);
|
||||
|
@ -184,5 +180,5 @@ export default createReactClass({
|
|||
</form>
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -16,12 +16,11 @@ limitations under the License.
|
|||
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
|
||||
import * as sdk from '../../../../index';
|
||||
import { _t } from '../../../../languageHandler';
|
||||
|
||||
function readFileAsArrayBuffer(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -38,48 +37,45 @@ function readFileAsArrayBuffer(file) {
|
|||
const PHASE_EDIT = 1;
|
||||
const PHASE_IMPORTING = 2;
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ImportE2eKeysDialog',
|
||||
|
||||
propTypes: {
|
||||
export default class ImportE2eKeysDialog extends React.Component {
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
enableSubmit: false,
|
||||
phase: PHASE_EDIT,
|
||||
errStr: null,
|
||||
};
|
||||
},
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
|
||||
this._file = createRef();
|
||||
this._passphrase = createRef();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this.state = {
|
||||
enableSubmit: false,
|
||||
phase: PHASE_EDIT,
|
||||
errStr: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
}
|
||||
|
||||
_onFormChange: function(ev) {
|
||||
_onFormChange = (ev) => {
|
||||
const files = this._file.current.files || [];
|
||||
this.setState({
|
||||
enableSubmit: (this._passphrase.current.value !== "" && files.length > 0),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onFormSubmit: function(ev) {
|
||||
_onFormSubmit = (ev) => {
|
||||
ev.preventDefault();
|
||||
this._startImport(this._file.current.files[0], this._passphrase.current.value);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
_startImport: function(file, passphrase) {
|
||||
_startImport(file, passphrase) {
|
||||
this.setState({
|
||||
errStr: null,
|
||||
phase: PHASE_IMPORTING,
|
||||
|
@ -105,15 +101,15 @@ export default createReactClass({
|
|||
phase: PHASE_EDIT,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onCancelClick: function(ev) {
|
||||
_onCancelClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
this.props.onFinished(false);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
||||
const disableForm = (this.state.phase !== PHASE_EDIT);
|
||||
|
@ -188,5 +184,5 @@ export default createReactClass({
|
|||
</form>
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
|||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import Modal from "../../../../Modal";
|
||||
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
|
||||
import {Action} from "../../../../dispatcher/actions";
|
||||
|
||||
export default class NewRecoveryMethodDialog extends React.PureComponent {
|
||||
|
@ -41,7 +42,6 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
|
|||
}
|
||||
|
||||
onSetupClick = async () => {
|
||||
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
|
||||
Modal.createTrackedDialog(
|
||||
'Restore Backup', '', RestoreKeyBackupDialog, {
|
||||
onFinished: this.props.onFinished,
|
|
@ -47,7 +47,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
if (command[0] !== command[1]) {
|
||||
// The input looks like a command with arguments, perform exact match
|
||||
const name = command[1].substr(1); // strip leading `/`
|
||||
if (CommandMap.has(name)) {
|
||||
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) {
|
||||
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
|
||||
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
|
||||
matches = [CommandMap.get(name)];
|
||||
|
@ -63,7 +63,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
|
||||
return matches.map((result) => {
|
||||
return matches.filter(cmd => cmd.isEnabled()).map((result) => {
|
||||
let completion = result.getCommand() + ' ';
|
||||
const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]);
|
||||
// If the command (or an alias) is the same as the one they entered, we don't want to discard their arguments
|
||||
|
@ -89,7 +89,11 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}>
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
aria-label={_t("Command Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -23,7 +23,7 @@ import {MatrixClientPeg} from '../MatrixClientPeg';
|
|||
import QueryMatcher from './QueryMatcher';
|
||||
import {PillCompletion} from './Components';
|
||||
import * as sdk from '../index';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import {sortBy} from "lodash";
|
||||
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
|
||||
import {ICompletion, ISelectionRange} from "./Autocompleter";
|
||||
import FlairStore from "../stores/FlairStore";
|
||||
|
@ -81,7 +81,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString);
|
||||
completions = _sortBy(completions, [
|
||||
completions = sortBy(completions, [
|
||||
(c) => score(matchedString, c.groupId),
|
||||
(c) => c.groupId.length,
|
||||
]).map(({avatarUrl, groupId, name}) => ({
|
||||
|
@ -91,15 +91,15 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
href: makeGroupPermalink(groupId),
|
||||
component: (
|
||||
<PillCompletion title={name} description={groupId}>
|
||||
<BaseAvatar name={name || groupId}
|
||||
width={24}
|
||||
height={24}
|
||||
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} />
|
||||
<BaseAvatar
|
||||
name={name || groupId}
|
||||
width={24}
|
||||
height={24}
|
||||
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} />
|
||||
</PillCompletion>
|
||||
),
|
||||
range,
|
||||
}))
|
||||
.slice(0, 4);
|
||||
})).slice(0, 4);
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
|
|
|
@ -34,9 +34,9 @@ export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props
|
|||
const {title, subtitle, description, className, ...restProps} = props;
|
||||
return (
|
||||
<div {...restProps}
|
||||
className={classNames('mx_Autocomplete_Completion_block', className)}
|
||||
role="option"
|
||||
ref={ref}
|
||||
className={classNames('mx_Autocomplete_Completion_block', className)}
|
||||
role="option"
|
||||
ref={ref}
|
||||
>
|
||||
<span className="mx_Autocomplete_Completion_title">{ title }</span>
|
||||
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
|
||||
|
@ -53,9 +53,9 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
|
|||
const {title, subtitle, description, className, children, ...restProps} = props;
|
||||
return (
|
||||
<div {...restProps}
|
||||
className={classNames('mx_Autocomplete_Completion_pill', className)}
|
||||
role="option"
|
||||
ref={ref}
|
||||
className={classNames('mx_Autocomplete_Completion_pill', className)}
|
||||
role="option"
|
||||
ref={ref}
|
||||
>
|
||||
{ children }
|
||||
<span className="mx_Autocomplete_Completion_title">{ title }</span>
|
||||
|
|
|
@ -23,8 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider';
|
|||
import QueryMatcher from './QueryMatcher';
|
||||
import {PillCompletion} from './Components';
|
||||
import {ICompletion, ISelectionRange} from './Autocompleter';
|
||||
import _uniq from 'lodash/uniq';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import {uniq, sortBy} from 'lodash';
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { shortcodeToUnicode } from '../HtmlUtils';
|
||||
import { EMOJI, IEmoji } from '../emoji';
|
||||
|
@ -115,7 +114,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
}
|
||||
// Finally, sort by original ordering
|
||||
sorters.push((c) => c._orderBy);
|
||||
completions = _sortBy(_uniq(completions), sorters);
|
||||
completions = sortBy(uniq(completions), sorters);
|
||||
|
||||
completions = completions.map(({shortname}) => {
|
||||
const unicode = shortcodeToUnicode(shortname);
|
||||
|
@ -139,7 +138,11 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}>
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
aria-label={_t("Emoji Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,8 +16,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import _at from 'lodash/at';
|
||||
import _uniq from 'lodash/uniq';
|
||||
import {at, uniq} from 'lodash';
|
||||
import {removeHiddenChars} from "matrix-js-sdk/src/utils";
|
||||
|
||||
interface IOptions<T extends {}> {
|
||||
|
@ -73,7 +72,7 @@ export default class QueryMatcher<T extends Object> {
|
|||
// type for their values. We assume that those values who's keys have
|
||||
// been specified will be string. Also, we cannot infer all the
|
||||
// types of the keys of the objects at compile.
|
||||
const keyValues = _at<string>(<any>object, this._options.keys);
|
||||
const keyValues = at<string>(<any>object, this._options.keys);
|
||||
|
||||
if (this._options.funcs) {
|
||||
for (const f of this._options.funcs) {
|
||||
|
@ -137,7 +136,7 @@ export default class QueryMatcher<T extends Object> {
|
|||
});
|
||||
|
||||
// Now map the keys to the result objects. Also remove any duplicates.
|
||||
return _uniq(matches.map((match) => match.object));
|
||||
return uniq(matches.map((match) => match.object));
|
||||
}
|
||||
|
||||
private processQuery(query: string): string {
|
||||
|
|
|
@ -27,7 +27,7 @@ import {PillCompletion} from './Components';
|
|||
import * as sdk from '../index';
|
||||
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
|
||||
import {ICompletion, ISelectionRange} from "./Autocompleter";
|
||||
import { uniqBy, sortBy } from 'lodash';
|
||||
import {uniqBy, sortBy} from "lodash";
|
||||
|
||||
const ROOM_REGEX = /\B#\S*/g;
|
||||
|
||||
|
@ -110,9 +110,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
),
|
||||
range,
|
||||
};
|
||||
})
|
||||
.filter((completion) => !!completion.completion && completion.completion.length > 0)
|
||||
.slice(0, 4);
|
||||
}).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4);
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider';
|
|||
import {PillCompletion} from './Components';
|
||||
import * as sdk from '../index';
|
||||
import QueryMatcher from './QueryMatcher';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import {sortBy} from 'lodash';
|
||||
import {MatrixClientPeg} from '../MatrixClientPeg';
|
||||
|
||||
import MatrixEvent from "matrix-js-sdk/src/models/event";
|
||||
|
@ -71,8 +71,13 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean,
|
||||
data: IRoomTimelineData) => {
|
||||
private onRoomTimeline = (
|
||||
ev: MatrixEvent,
|
||||
room: Room,
|
||||
toStartOfTimeline: boolean,
|
||||
removed: boolean,
|
||||
data: IRoomTimelineData,
|
||||
) => {
|
||||
if (!room) return;
|
||||
if (removed) return;
|
||||
if (room.roomId !== this.room.roomId) return;
|
||||
|
@ -151,7 +156,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
const currentUserId = MatrixClientPeg.get().credentials.userId;
|
||||
this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
|
||||
|
||||
this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
|
||||
this.users = sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
|
||||
|
||||
this.matcher.setObjects(this.users);
|
||||
}
|
||||
|
@ -171,7 +176,11 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}>
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
aria-label={_t("User Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'CompatibilityPage',
|
||||
propTypes: {
|
||||
onAccept: PropTypes.func,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onAccept: function() {}, // NOP
|
||||
};
|
||||
},
|
||||
|
||||
onAccept: function() {
|
||||
this.props.onAccept();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
return (
|
||||
<div className="mx_CompatibilityPage">
|
||||
<div className="mx_CompatibilityPage_box">
|
||||
<p>{_t(
|
||||
"Sorry, your browser is <b>not</b> able to run %(brand)s.",
|
||||
{
|
||||
brand,
|
||||
},
|
||||
{
|
||||
'b': (sub) => <b>{sub}</b>,
|
||||
})
|
||||
}</p>
|
||||
<p>
|
||||
{ _t(
|
||||
"%(brand)s uses many advanced browser features, some of which are not available " +
|
||||
"or experimental in your current browser.",
|
||||
{ brand },
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
'Please install <chromeLink>Chrome</chromeLink>, <firefoxLink>Firefox</firefoxLink>, ' +
|
||||
'or <safariLink>Safari</safariLink> for the best experience.',
|
||||
{},
|
||||
{
|
||||
'chromeLink': (sub) => <a href="https://www.google.com/chrome">{sub}</a>,
|
||||
'firefoxLink': (sub) => <a href="https://firefox.com">{sub}</a>,
|
||||
'safariLink': (sub) => <a href="https://apple.com/safari">{sub}</a>,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
"With your current browser, the look and feel of the application may be " +
|
||||
"completely incorrect, and some or all features may not function. " +
|
||||
"If you want to try it anyway you can continue, but you are on your own in terms " +
|
||||
"of any issues you may encounter!",
|
||||
) }
|
||||
</p>
|
||||
<button onClick={this.onAccept}>
|
||||
{ _t("I understand the risks and wish to continue") }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {CSSProperties, useRef, useState} from "react";
|
||||
import React, {CSSProperties, RefObject, useRef, useState} from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
|
@ -233,8 +233,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
switch (ev.key) {
|
||||
case Key.TAB:
|
||||
case Key.ESCAPE:
|
||||
// close on left and right arrows too for when it is a context menu on a <Toolbar />
|
||||
case Key.ARROW_LEFT:
|
||||
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />
|
||||
case Key.ARROW_RIGHT:
|
||||
this.props.onFinished();
|
||||
break;
|
||||
|
@ -417,8 +416,8 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
|
|||
return menuOptions;
|
||||
};
|
||||
|
||||
export const useContextMenu = () => {
|
||||
const button = useRef(null);
|
||||
export const useContextMenu = (): [boolean, RefObject<HTMLElement>, () => void, () => void, (val: boolean) => void] => {
|
||||
const button = useRef<HTMLElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = () => {
|
||||
setIsOpen(true);
|
||||
|
|
|
@ -43,8 +43,8 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._dispatcherRef = null;
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {Filter} from 'matrix-js-sdk';
|
||||
|
@ -24,27 +23,28 @@ import * as sdk from '../../index';
|
|||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import EventIndexPeg from "../../indexing/EventIndexPeg";
|
||||
import { _t } from '../../languageHandler';
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
|
||||
import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice";
|
||||
|
||||
/*
|
||||
* Component which shows the filtered file using a TimelinePanel
|
||||
*/
|
||||
const FilePanel = createReactClass({
|
||||
displayName: 'FilePanel',
|
||||
class FilePanel extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// This is used to track if a decrypted event was a live event and should be
|
||||
// added to the timeline.
|
||||
decryptingEvents: new Set(),
|
||||
decryptingEvents = new Set();
|
||||
|
||||
propTypes: {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
},
|
||||
state = {
|
||||
timelineSet: null,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
timelineSet: null,
|
||||
};
|
||||
},
|
||||
|
||||
onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
|
||||
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
|
||||
if (room.roomId !== this.props.roomId) return;
|
||||
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
|
||||
|
||||
|
@ -53,9 +53,9 @@ const FilePanel = createReactClass({
|
|||
} else {
|
||||
this.addEncryptedLiveEvent(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onEventDecrypted(ev, err) {
|
||||
onEventDecrypted = (ev, err) => {
|
||||
if (ev.getRoomId() !== this.props.roomId) return;
|
||||
const eventId = ev.getId();
|
||||
|
||||
|
@ -63,7 +63,7 @@ const FilePanel = createReactClass({
|
|||
if (err) return;
|
||||
|
||||
this.addEncryptedLiveEvent(ev);
|
||||
},
|
||||
};
|
||||
|
||||
addEncryptedLiveEvent(ev, toStartOfTimeline) {
|
||||
if (!this.state.timelineSet) return;
|
||||
|
@ -77,7 +77,7 @@ const FilePanel = createReactClass({
|
|||
if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
|
||||
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -98,7 +98,7 @@ const FilePanel = createReactClass({
|
|||
client.on('Room.timeline', this.onRoomTimeline);
|
||||
client.on('Event.decrypted', this.onEventDecrypted);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -110,7 +110,7 @@ const FilePanel = createReactClass({
|
|||
client.removeListener('Room.timeline', this.onRoomTimeline);
|
||||
client.removeListener('Event.decrypted', this.onEventDecrypted);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async fetchFileEventsServer(room) {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -134,9 +134,9 @@ const FilePanel = createReactClass({
|
|||
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
|
||||
|
||||
return timelineSet;
|
||||
},
|
||||
}
|
||||
|
||||
onPaginationRequest(timelineWindow, direction, limit) {
|
||||
onPaginationRequest = (timelineWindow, direction, limit) => {
|
||||
const client = MatrixClientPeg.get();
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const roomId = this.props.roomId;
|
||||
|
@ -152,7 +152,7 @@ const FilePanel = createReactClass({
|
|||
} else {
|
||||
return timelineWindow.paginate(direction, limit);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async updateTimelineSet(roomId: string) {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -188,22 +188,30 @@ const FilePanel = createReactClass({
|
|||
} else {
|
||||
console.error("Failed to add filtered timelineSet for FilePanel as no room!");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
|
||||
return <BaseCard
|
||||
className="mx_FilePanel mx_RoomView_messageListWrapper"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<div className="mx_RoomView_empty">
|
||||
{ _t("You must <a>register</a> to use this functionality",
|
||||
{},
|
||||
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
|
||||
}
|
||||
</div>
|
||||
</div>;
|
||||
</BaseCard>;
|
||||
} else if (this.noRoom) {
|
||||
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
|
||||
return <BaseCard
|
||||
className="mx_FilePanel mx_RoomView_messageListWrapper"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<div className="mx_RoomView_empty">{ _t("You must join the room to see its files") }</div>
|
||||
</div>;
|
||||
</BaseCard>;
|
||||
}
|
||||
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
|
@ -215,12 +223,20 @@ const FilePanel = createReactClass({
|
|||
<p>{_t('Attach files from chat or just drag and drop them anywhere in a room.')}</p>
|
||||
</div>);
|
||||
|
||||
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
|
||||
|
||||
if (this.state.timelineSet) {
|
||||
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
|
||||
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
|
||||
return (
|
||||
<div className="mx_FilePanel" role="tabpanel">
|
||||
<TimelinePanel key={"filepanel_" + this.props.roomId}
|
||||
<BaseCard
|
||||
className="mx_FilePanel"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
withoutScrollContainer
|
||||
>
|
||||
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
|
@ -230,16 +246,20 @@ const FilePanel = createReactClass({
|
|||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={emptyState}
|
||||
/>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="mx_FilePanel" role="tabpanel">
|
||||
<BaseCard
|
||||
className="mx_FilePanel"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<Loader />
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default FilePanel;
|
||||
|
|
|
@ -16,8 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import TagOrderStore from '../../stores/TagOrderStore';
|
||||
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
|
||||
|
||||
import GroupActions from '../../actions/GroupActions';
|
||||
|
||||
|
@ -29,54 +28,50 @@ import { Droppable } from 'react-beautiful-dnd';
|
|||
import classNames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import UserTagTile from "../views/elements/UserTagTile";
|
||||
|
||||
const TagPanel = createReactClass({
|
||||
displayName: 'TagPanel',
|
||||
class GroupFilterPanel extends React.Component {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
statics: {
|
||||
contextType: MatrixClientContext,
|
||||
},
|
||||
state = {
|
||||
orderedTags: [],
|
||||
selectedTags: [],
|
||||
};
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
orderedTags: [],
|
||||
selectedTags: [],
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.on("sync", this._onClientSync);
|
||||
|
||||
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
|
||||
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
orderedTags: TagOrderStore.getOrderedTags() || [],
|
||||
selectedTags: TagOrderStore.getSelectedTags(),
|
||||
orderedTags: GroupFilterOrderStore.getOrderedTags() || [],
|
||||
selectedTags: GroupFilterOrderStore.getSelectedTags(),
|
||||
});
|
||||
});
|
||||
// This could be done by anything with a matrix client
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.removeListener("sync", this._onClientSync);
|
||||
if (this._tagOrderStoreToken) {
|
||||
this._tagOrderStoreToken.remove();
|
||||
if (this._groupFilterOrderStoreToken) {
|
||||
this._groupFilterOrderStoreToken.remove();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_onGroupMyMembership() {
|
||||
_onGroupMyMembership = () => {
|
||||
if (this.unmounted) return;
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
},
|
||||
};
|
||||
|
||||
_onClientSync(syncState, prevState) {
|
||||
_onClientSync = (syncState, prevState) => {
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
|
@ -84,29 +79,33 @@ const TagPanel = createReactClass({
|
|||
// Load joined groups
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onMouseDown(e) {
|
||||
onMouseDown = e => {
|
||||
// only dispatch if its not a no-op
|
||||
if (this.state.selectedTags.length > 0) {
|
||||
dis.dispatch({action: 'deselect_tags'});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onCreateGroupClick(ev) {
|
||||
ev.stopPropagation();
|
||||
dis.dispatch({action: 'view_create_group'});
|
||||
},
|
||||
|
||||
onClearFilterClick(ev) {
|
||||
onClearFilterClick = ev => {
|
||||
dis.dispatch({action: 'deselect_tags'});
|
||||
},
|
||||
};
|
||||
|
||||
renderGlobalIcon() {
|
||||
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserTagTile />
|
||||
<hr className="mx_GroupFilterPanel_divider" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
const TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
|
||||
const tags = this.state.orderedTags.map((tag, index) => {
|
||||
return <DNDTagTile
|
||||
|
@ -118,28 +117,31 @@ const TagPanel = createReactClass({
|
|||
});
|
||||
|
||||
const itemsSelected = this.state.selectedTags.length > 0;
|
||||
|
||||
let clearButton;
|
||||
if (itemsSelected) {
|
||||
clearButton = <AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
|
||||
<TintableSvg src={require("../../../res/img/icons-close.svg")} width="24" height="24"
|
||||
alt={_t("Clear filter")}
|
||||
title={_t("Clear filter")}
|
||||
/>
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const classes = classNames('mx_TagPanel', {
|
||||
mx_TagPanel_items_selected: itemsSelected,
|
||||
const classes = classNames('mx_GroupFilterPanel', {
|
||||
mx_GroupFilterPanel_items_selected: itemsSelected,
|
||||
});
|
||||
|
||||
return <div className={classes}>
|
||||
<div className="mx_TagPanel_clearButton_container">
|
||||
{ clearButton }
|
||||
</div>
|
||||
<div className="mx_TagPanel_divider" />
|
||||
let createButton = (
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Communities")}
|
||||
action="toggle_my_groups"
|
||||
className="mx_TagTile mx_TagTile_plus" />
|
||||
);
|
||||
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
createButton = (
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Create community")}
|
||||
action="view_create_group"
|
||||
className="mx_TagTile mx_TagTile_plus" />
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classes} onClick={this.onClearFilterClick}>
|
||||
<AutoHideScrollbar
|
||||
className="mx_TagPanel_scroller"
|
||||
className="mx_GroupFilterPanel_scroller"
|
||||
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
|
||||
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
|
||||
onMouseDown={this.onMouseDown}
|
||||
|
@ -150,16 +152,13 @@ const TagPanel = createReactClass({
|
|||
>
|
||||
{ (provided, snapshot) => (
|
||||
<div
|
||||
className="mx_TagPanel_tagTileContainer"
|
||||
className="mx_GroupFilterPanel_tagTileContainer"
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{ this.renderGlobalIcon() }
|
||||
{ tags }
|
||||
<div>
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Communities")}
|
||||
action="toggle_my_groups"
|
||||
className="mx_TagTile mx_TagTile_plus" />
|
||||
{createButton}
|
||||
</div>
|
||||
{ provided.placeholder }
|
||||
</div>
|
||||
|
@ -167,6 +166,6 @@ const TagPanel = createReactClass({
|
|||
</Droppable>
|
||||
</AutoHideScrollbar>
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
export default TagPanel;
|
||||
}
|
||||
}
|
||||
export default GroupFilterPanel;
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import * as sdk from '../../index';
|
||||
|
@ -70,10 +69,8 @@ const UserSummaryType = PropTypes.shape({
|
|||
}).isRequired,
|
||||
});
|
||||
|
||||
const CategoryRoomList = createReactClass({
|
||||
displayName: 'CategoryRoomList',
|
||||
|
||||
props: {
|
||||
class CategoryRoomList extends React.Component {
|
||||
static propTypes = {
|
||||
rooms: PropTypes.arrayOf(RoomSummaryType).isRequired,
|
||||
category: PropTypes.shape({
|
||||
profile: PropTypes.shape({
|
||||
|
@ -84,9 +81,9 @@ const CategoryRoomList = createReactClass({
|
|||
|
||||
// Whether the list should be editable
|
||||
editing: PropTypes.bool.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onAddRoomsToSummaryClicked: function(ev) {
|
||||
onAddRoomsToSummaryClicked = (ev) => {
|
||||
ev.preventDefault();
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, {
|
||||
|
@ -122,9 +119,9 @@ const CategoryRoomList = createReactClass({
|
|||
});
|
||||
},
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const addButton = this.props.editing ?
|
||||
(<AccessibleButton className="mx_GroupView_featuredThings_addButton"
|
||||
|
@ -155,19 +152,17 @@ const CategoryRoomList = createReactClass({
|
|||
{ roomNodes }
|
||||
{ addButton }
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const FeaturedRoom = createReactClass({
|
||||
displayName: 'FeaturedRoom',
|
||||
|
||||
props: {
|
||||
class FeaturedRoom extends React.Component {
|
||||
static propTypes = {
|
||||
summaryInfo: RoomSummaryType.isRequired,
|
||||
editing: PropTypes.bool.isRequired,
|
||||
groupId: PropTypes.string.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onClick: function(e) {
|
||||
onClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
@ -176,9 +171,9 @@ const FeaturedRoom = createReactClass({
|
|||
room_alias: this.props.summaryInfo.profile.canonical_alias,
|
||||
room_id: this.props.summaryInfo.room_id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onDeleteClicked: function(e) {
|
||||
onDeleteClicked = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
GroupStore.removeRoomFromGroupSummary(
|
||||
|
@ -201,9 +196,9 @@ const FeaturedRoom = createReactClass({
|
|||
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
|
||||
|
||||
const roomName = this.props.summaryInfo.profile.name ||
|
||||
|
@ -243,13 +238,11 @@ const FeaturedRoom = createReactClass({
|
|||
<div className="mx_GroupView_featuredThing_name">{ roomNameNode }</div>
|
||||
{ deleteButton }
|
||||
</AccessibleButton>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const RoleUserList = createReactClass({
|
||||
displayName: 'RoleUserList',
|
||||
|
||||
props: {
|
||||
class RoleUserList extends React.Component {
|
||||
static propTypes = {
|
||||
users: PropTypes.arrayOf(UserSummaryType).isRequired,
|
||||
role: PropTypes.shape({
|
||||
profile: PropTypes.shape({
|
||||
|
@ -260,9 +253,9 @@ const RoleUserList = createReactClass({
|
|||
|
||||
// Whether the list should be editable
|
||||
editing: PropTypes.bool.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onAddUsersClicked: function(ev) {
|
||||
onAddUsersClicked = (ev) => {
|
||||
ev.preventDefault();
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, {
|
||||
|
@ -298,9 +291,9 @@ const RoleUserList = createReactClass({
|
|||
});
|
||||
},
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const addButton = this.props.editing ?
|
||||
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}>
|
||||
|
@ -325,19 +318,17 @@ const RoleUserList = createReactClass({
|
|||
{ userNodes }
|
||||
{ addButton }
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const FeaturedUser = createReactClass({
|
||||
displayName: 'FeaturedUser',
|
||||
|
||||
props: {
|
||||
class FeaturedUser extends React.Component {
|
||||
static propTypes = {
|
||||
summaryInfo: UserSummaryType.isRequired,
|
||||
editing: PropTypes.bool.isRequired,
|
||||
groupId: PropTypes.string.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
onClick: function(e) {
|
||||
onClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
@ -345,9 +336,9 @@ const FeaturedUser = createReactClass({
|
|||
action: 'view_start_chat_or_reuse',
|
||||
user_id: this.props.summaryInfo.user_id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onDeleteClicked: function(e) {
|
||||
onDeleteClicked = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
GroupStore.removeUserFromGroupSummary(
|
||||
|
@ -368,9 +359,9 @@ const FeaturedUser = createReactClass({
|
|||
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id;
|
||||
|
||||
|
@ -394,41 +385,37 @@ const FeaturedUser = createReactClass({
|
|||
<div className="mx_GroupView_featuredThing_name">{ userNameNode }</div>
|
||||
{ deleteButton }
|
||||
</AccessibleButton>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_JOINPOLICY_OPEN = "open";
|
||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'GroupView',
|
||||
|
||||
propTypes: {
|
||||
export default class GroupView extends React.Component {
|
||||
static propTypes = {
|
||||
groupId: PropTypes.string.isRequired,
|
||||
// Whether this is the first time the group admin is viewing the group
|
||||
groupIsNew: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
summary: null,
|
||||
isGroupPublicised: null,
|
||||
isUserPrivileged: null,
|
||||
groupRooms: null,
|
||||
groupRoomsLoading: null,
|
||||
error: null,
|
||||
editing: false,
|
||||
saving: false,
|
||||
uploadingAvatar: false,
|
||||
avatarChanged: false,
|
||||
membershipBusy: false,
|
||||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
summary: null,
|
||||
isGroupPublicised: null,
|
||||
isUserPrivileged: null,
|
||||
groupRooms: null,
|
||||
groupRoomsLoading: null,
|
||||
error: null,
|
||||
editing: false,
|
||||
saving: false,
|
||||
uploadingAvatar: false,
|
||||
avatarChanged: false,
|
||||
membershipBusy: false,
|
||||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
this._matrixClient = MatrixClientPeg.get();
|
||||
this._matrixClient.on("Group.myMembership", this._onGroupMyMembership);
|
||||
|
@ -437,9 +424,9 @@ export default createReactClass({
|
|||
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
dis.unregister(this._dispatcherRef);
|
||||
|
@ -448,10 +435,11 @@ export default createReactClass({
|
|||
if (this._rightPanelStoreToken) {
|
||||
this._rightPanelStoreToken.remove();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(newProps) {
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (this.props.groupId !== newProps.groupId) {
|
||||
this.setState({
|
||||
summary: null,
|
||||
|
@ -460,24 +448,24 @@ export default createReactClass({
|
|||
this._initGroupStore(newProps.groupId);
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_onRightPanelStoreUpdate: function() {
|
||||
_onRightPanelStoreUpdate = () => {
|
||||
this.setState({
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onGroupMyMembership: function(group) {
|
||||
_onGroupMyMembership = (group) => {
|
||||
if (this._unmounted || group.groupId !== this.props.groupId) return;
|
||||
if (group.myMembership === 'leave') {
|
||||
// Leave settings - the user might have clicked the "Leave" button
|
||||
this._closeSettings();
|
||||
}
|
||||
this.setState({membershipBusy: false});
|
||||
},
|
||||
};
|
||||
|
||||
_initGroupStore: function(groupId, firstInit) {
|
||||
_initGroupStore(groupId, firstInit) {
|
||||
const group = this._matrixClient.getGroup(groupId);
|
||||
if (group && group.inviter && group.inviter.userId) {
|
||||
this._fetchInviterProfile(group.inviter.userId);
|
||||
|
@ -506,9 +494,9 @@ export default createReactClass({
|
|||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onGroupStoreUpdated(firstInit) {
|
||||
onGroupStoreUpdated = (firstInit) => {
|
||||
if (this._unmounted) return;
|
||||
const summary = GroupStore.getSummary(this.props.groupId);
|
||||
if (summary.profile) {
|
||||
|
@ -533,7 +521,7 @@ export default createReactClass({
|
|||
if (this.props.groupIsNew && firstInit) {
|
||||
this._onEditClick();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_fetchInviterProfile(userId) {
|
||||
this.setState({
|
||||
|
@ -555,9 +543,9 @@ export default createReactClass({
|
|||
inviterProfileBusy: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onEditClick: function() {
|
||||
_onEditClick = () => {
|
||||
this.setState({
|
||||
editing: true,
|
||||
profileForm: Object.assign({}, this.state.summary.profile),
|
||||
|
@ -568,20 +556,20 @@ export default createReactClass({
|
|||
GROUP_JOINPOLICY_INVITE,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onShareClick: function() {
|
||||
_onShareClick = () => {
|
||||
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
|
||||
Modal.createTrackedDialog('share community dialog', '', ShareDialog, {
|
||||
target: this._matrixClient.getGroup(this.props.groupId) || new Group(this.props.groupId),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onCancelClick: function() {
|
||||
_onCancelClick = () => {
|
||||
this._closeSettings();
|
||||
},
|
||||
};
|
||||
|
||||
_onAction(payload) {
|
||||
_onAction = (payload) => {
|
||||
switch (payload.action) {
|
||||
// NOTE: close_settings is an app-wide dispatch; as it is dispatched from MatrixChat
|
||||
case 'close_settings':
|
||||
|
@ -593,34 +581,34 @@ export default createReactClass({
|
|||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_closeSettings() {
|
||||
_closeSettings = () => {
|
||||
dis.dispatch({action: 'close_settings'});
|
||||
},
|
||||
};
|
||||
|
||||
_onNameChange: function(value) {
|
||||
_onNameChange = (value) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { name: value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onShortDescChange: function(value) {
|
||||
_onShortDescChange = (value) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { short_description: value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onLongDescChange: function(e) {
|
||||
_onLongDescChange = (e) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { long_description: e.target.value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onAvatarSelected: function(ev) {
|
||||
_onAvatarSelected = ev => {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
|
@ -632,7 +620,7 @@ export default createReactClass({
|
|||
profileForm: newProfileForm,
|
||||
|
||||
// Indicate that FlairStore needs to be poked to show this change
|
||||
// in TagTile (TagPanel), Flair and GroupTile (MyGroups).
|
||||
// in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups).
|
||||
avatarChanged: true,
|
||||
});
|
||||
}).catch((e) => {
|
||||
|
@ -644,15 +632,15 @@ export default createReactClass({
|
|||
description: _t('Failed to upload image'),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onJoinableChange: function(ev) {
|
||||
_onJoinableChange = ev => {
|
||||
this.setState({
|
||||
joinableForm: { policyType: ev.target.value },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onSaveClick: function() {
|
||||
_onSaveClick = () => {
|
||||
this.setState({saving: true});
|
||||
const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve();
|
||||
savePromise.then((result) => {
|
||||
|
@ -661,7 +649,6 @@ export default createReactClass({
|
|||
editing: false,
|
||||
summary: null,
|
||||
});
|
||||
dis.dispatch({action: 'panel_disable'});
|
||||
this._initGroupStore(this.props.groupId);
|
||||
|
||||
if (this.state.avatarChanged) {
|
||||
|
@ -683,16 +670,16 @@ export default createReactClass({
|
|||
avatarChanged: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_saveGroup: async function() {
|
||||
async _saveGroup() {
|
||||
await this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm);
|
||||
await this._matrixClient.setGroupJoinPolicy(this.props.groupId, {
|
||||
type: this.state.joinableForm.policyType,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onAcceptInviteClick: async function() {
|
||||
_onAcceptInviteClick = async () => {
|
||||
this.setState({membershipBusy: true});
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
|
@ -709,9 +696,9 @@ export default createReactClass({
|
|||
description: _t("Unable to accept invite"),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onRejectInviteClick: async function() {
|
||||
_onRejectInviteClick = async () => {
|
||||
this.setState({membershipBusy: true});
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
|
@ -728,9 +715,9 @@ export default createReactClass({
|
|||
description: _t("Unable to reject invite"),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onJoinClick: async function() {
|
||||
_onJoinClick = async () => {
|
||||
if (this._matrixClient.isGuest()) {
|
||||
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
|
||||
return;
|
||||
|
@ -752,9 +739,9 @@ export default createReactClass({
|
|||
description: _t("Unable to join community"),
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_leaveGroupWarnings: function() {
|
||||
_leaveGroupWarnings() {
|
||||
const warnings = [];
|
||||
|
||||
if (this.state.isUserPrivileged) {
|
||||
|
@ -768,10 +755,9 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return warnings;
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_onLeaveClick: function() {
|
||||
_onLeaveClick = () => {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const warnings = this._leaveGroupWarnings();
|
||||
|
||||
|
@ -806,13 +792,13 @@ export default createReactClass({
|
|||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onAddRoomsClick: function() {
|
||||
_onAddRoomsClick = () => {
|
||||
showGroupAddRoomDialog(this.props.groupId);
|
||||
},
|
||||
};
|
||||
|
||||
_getGroupSection: function() {
|
||||
_getGroupSection() {
|
||||
const groupSettingsSectionClasses = classnames({
|
||||
"mx_GroupView_group": this.state.editing,
|
||||
"mx_GroupView_group_disabled": this.state.editing && !this.state.isUserPrivileged,
|
||||
|
@ -856,9 +842,9 @@ export default createReactClass({
|
|||
{ this._getLongDescriptionNode() }
|
||||
{ this._getRoomsNode() }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getRoomsNode: function() {
|
||||
_getRoomsNode() {
|
||||
const RoomDetailList = sdk.getComponent('rooms.RoomDetailList');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
|
@ -883,10 +869,7 @@ export default createReactClass({
|
|||
{ _t('Add rooms to this community') }
|
||||
</div>
|
||||
</AccessibleButton>) : <div />;
|
||||
const roomDetailListClassName = classnames({
|
||||
"mx_fadable": true,
|
||||
"mx_fadable_faded": this.state.editing,
|
||||
});
|
||||
|
||||
return <div className="mx_GroupView_rooms">
|
||||
<div className="mx_GroupView_rooms_header">
|
||||
<h3>
|
||||
|
@ -897,14 +880,12 @@ export default createReactClass({
|
|||
</div>
|
||||
{ this.state.groupRoomsLoading ?
|
||||
<Spinner /> :
|
||||
<RoomDetailList
|
||||
rooms={this.state.groupRooms}
|
||||
className={roomDetailListClassName} />
|
||||
<RoomDetailList rooms={this.state.groupRooms} />
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getFeaturedRoomsNode: function() {
|
||||
_getFeaturedRoomsNode() {
|
||||
const summary = this.state.summary;
|
||||
|
||||
const defaultCategoryRooms = [];
|
||||
|
@ -943,9 +924,9 @@ export default createReactClass({
|
|||
{ defaultCategoryNode }
|
||||
{ categoryRoomNodes }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getFeaturedUsersNode: function() {
|
||||
_getFeaturedUsersNode() {
|
||||
const summary = this.state.summary;
|
||||
|
||||
const noRoleUsers = [];
|
||||
|
@ -984,9 +965,9 @@ export default createReactClass({
|
|||
{ noRoleNode }
|
||||
{ roleUserNodes }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getMembershipSection: function() {
|
||||
_getMembershipSection() {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
|
||||
|
@ -1100,9 +1081,9 @@ export default createReactClass({
|
|||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getJoinableNode: function() {
|
||||
_getJoinableNode() {
|
||||
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
|
||||
return this.state.editing ? <div>
|
||||
<h3>
|
||||
|
@ -1136,9 +1117,9 @@ export default createReactClass({
|
|||
</label>
|
||||
</div>
|
||||
</div> : null;
|
||||
},
|
||||
}
|
||||
|
||||
_getLongDescriptionNode: function() {
|
||||
_getLongDescriptionNode() {
|
||||
const summary = this.state.summary;
|
||||
let description = null;
|
||||
if (summary.profile && summary.profile.long_description) {
|
||||
|
@ -1175,9 +1156,9 @@ export default createReactClass({
|
|||
<div className="mx_GroupView_groupDesc">
|
||||
{ description }
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
|
||||
|
@ -1335,7 +1316,7 @@ export default createReactClass({
|
|||
</div>
|
||||
<GroupHeaderButtons />
|
||||
</div>
|
||||
<MainSplit panel={rightPanel}>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<AutoHideScrollbar className="mx_GroupView_body">
|
||||
{ this._getMembershipSection() }
|
||||
{ this._getGroupSection() }
|
||||
|
@ -1366,5 +1347,5 @@ export default createReactClass({
|
|||
console.error("Invalid state for GroupView");
|
||||
return <div />;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
|
||||
import {InteractiveAuth} from "matrix-js-sdk";
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
|
||||
|
@ -26,10 +25,8 @@ import * as sdk from '../../index';
|
|||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'InteractiveAuth',
|
||||
|
||||
propTypes: {
|
||||
export default class InteractiveAuthComponent extends React.Component {
|
||||
static propTypes = {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
|
||||
|
@ -86,20 +83,19 @@ export default createReactClass({
|
|||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText: PropTypes.string,
|
||||
continueKind: PropTypes.string,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this._authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
|
@ -114,6 +110,18 @@ export default createReactClass({
|
|||
requestEmailToken: this._requestEmailToken,
|
||||
});
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
this._intervalId = setInterval(() => {
|
||||
this._authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this._stageComponent = createRef();
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
this._authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this._authLogic.getEmailSid(),
|
||||
|
@ -132,26 +140,17 @@ export default createReactClass({
|
|||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
this._intervalId = setInterval(() => {
|
||||
this._authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this._stageComponent = createRef();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
|
||||
if (this._intervalId !== null) {
|
||||
clearInterval(this._intervalId);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_requestEmailToken: async function(...args) {
|
||||
_requestEmailToken = async (...args) => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
|
@ -162,15 +161,15 @@ export default createReactClass({
|
|||
busy: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
tryContinue: function() {
|
||||
tryContinue = () => {
|
||||
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
|
||||
this._stageComponent.current.tryContinue();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_authStateUpdated: function(stageType, stageState) {
|
||||
_authStateUpdated = (stageType, stageState) => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
|
@ -180,16 +179,16 @@ export default createReactClass({
|
|||
}, () => {
|
||||
if (oldStage != stageType) this._setFocus();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_requestCallback: function(auth) {
|
||||
_requestCallback = (auth) => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
},
|
||||
};
|
||||
|
||||
_onBusyChanged: function(busy) {
|
||||
_onBusyChanged = (busy) => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
|
@ -204,29 +203,29 @@ export default createReactClass({
|
|||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `_authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
},
|
||||
};
|
||||
|
||||
_setFocus: function() {
|
||||
_setFocus() {
|
||||
if (this._stageComponent.current && this._stageComponent.current.focus) {
|
||||
this._stageComponent.current.focus();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_submitAuthDict: function(authData) {
|
||||
_submitAuthDict = authData => {
|
||||
this._authLogic.submitAuthDict(authData);
|
||||
},
|
||||
};
|
||||
|
||||
_onPhaseChange: function(newPhase) {
|
||||
_onPhaseChange = newPhase => {
|
||||
if (this.props.onStagePhaseChange) {
|
||||
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onStageCancel: function() {
|
||||
_onStageCancel = () => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
},
|
||||
};
|
||||
|
||||
_renderCurrentStage: function() {
|
||||
_renderCurrentStage() {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
|
@ -260,16 +259,17 @@ export default createReactClass({
|
|||
onCancel={this._onStageCancel}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
_onAuthStageFailed: function(e) {
|
||||
_onAuthStageFailed = e => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
},
|
||||
_setEmailSid: function(sid) {
|
||||
this._authLogic.setEmailSid(sid);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
_setEmailSid = sid => {
|
||||
this._authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
|
@ -287,5 +287,5 @@ export default createReactClass({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import TagPanel from "./TagPanel";
|
||||
import GroupFilterPanel from "./GroupFilterPanel";
|
||||
import CustomRoomTagPanel from "./CustomRoomTagPanel";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
@ -46,13 +46,13 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
showBreadcrumbs: boolean;
|
||||
showTagPanel: boolean;
|
||||
showGroupFilterPanel: 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_RoomSearch_minimizedHandle", // minimized <RoomSearch />
|
||||
"mx_RoomSublist_headerText",
|
||||
"mx_RoomTile",
|
||||
"mx_RoomSublist_showNButton",
|
||||
|
@ -60,7 +60,7 @@ const cssClasses = [
|
|||
|
||||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private tagPanelWatcherRef: string;
|
||||
private groupFilterPanelWatcherRef: string;
|
||||
private bgImageWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
@ -70,7 +70,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
|
@ -78,8 +78,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
this.bgImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
|
||||
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
|
||||
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
|
||||
});
|
||||
|
||||
// We watch the middle panel because we don't actually get resized, the middle panel does.
|
||||
|
@ -88,7 +88,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
|
@ -119,8 +119,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
if (settingBgMxc) {
|
||||
avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize);
|
||||
}
|
||||
|
||||
const avatarUrlProp = `url(${avatarUrl})`;
|
||||
if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
if (!avatarUrl) {
|
||||
document.body.style.removeProperty("--avatar-url");
|
||||
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
document.body.style.setProperty("--avatar-url", avatarUrlProp);
|
||||
}
|
||||
};
|
||||
|
@ -375,9 +378,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const tagPanel = !this.state.showTagPanel ? null : (
|
||||
<div className="mx_LeftPanel_tagPanelContainer">
|
||||
<TagPanel/>
|
||||
const groupFilterPanel = !this.state.showGroupFilterPanel ? null : (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
|
@ -394,7 +397,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel": true,
|
||||
"mx_LeftPanel_hasTagPanel": !!tagPanel,
|
||||
"mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel,
|
||||
"mx_LeftPanel_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
|
@ -405,7 +408,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{tagPanel}
|
||||
{groupFilterPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
|
|
|
@ -27,7 +27,6 @@ import CallMediaHandler from '../../CallMediaHandler';
|
|||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import sessionStore from '../../stores/SessionStore';
|
||||
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
|
@ -41,13 +40,9 @@ import HomePage from "./HomePage";
|
|||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import { DefaultTagID } from "../../stores/room-list/models";
|
||||
import {
|
||||
showToast as showSetPasswordToast,
|
||||
hideToast as hideSetPasswordToast
|
||||
} from "../../toasts/SetPasswordToast";
|
||||
import {
|
||||
showToast as showServerLimitToast,
|
||||
hideToast as hideServerLimitToast
|
||||
hideToast as hideServerLimitToast,
|
||||
} from "../../toasts/ServerLimitToast";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import LeftPanel from "./LeftPanel";
|
||||
|
@ -56,6 +51,7 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay
|
|||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
import NonUrgentToastContainer from "./NonUrgentToastContainer";
|
||||
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
|
||||
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
|
||||
// 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.
|
||||
|
@ -75,16 +71,12 @@ interface IProps {
|
|||
viaServers?: string[];
|
||||
hideToSRUsers: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
middleDisabled: boolean;
|
||||
initialEventPixelOffset: number;
|
||||
leftDisabled: boolean;
|
||||
rightDisabled: boolean;
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type: string;
|
||||
autoJoin: boolean;
|
||||
thirdPartyInvite?: object;
|
||||
threepidInvite?: IThreepidInvite;
|
||||
roomOobData?: object;
|
||||
currentRoomId: string;
|
||||
ConferenceHandler?: object;
|
||||
collapseLhs: boolean;
|
||||
config: {
|
||||
piwik: {
|
||||
|
@ -98,15 +90,13 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IUsageLimit {
|
||||
// eslint-disable-next-line camelcase
|
||||
limit_type: "monthly_active_user" | string;
|
||||
// eslint-disable-next-line camelcase
|
||||
admin_contact?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
mouseDown?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
syncErrorData?: {
|
||||
error: {
|
||||
data: IUsageLimit;
|
||||
|
@ -147,8 +137,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
protected readonly _matrixClient: MatrixClient;
|
||||
protected readonly _roomView: React.RefObject<any>;
|
||||
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
|
||||
protected readonly _sessionStore: sessionStore;
|
||||
protected readonly _sessionStoreToken: { remove: () => void };
|
||||
protected readonly _compactLayoutWatcherRef: string;
|
||||
protected resizer: Resizer;
|
||||
|
||||
|
@ -156,7 +144,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
mouseDown: undefined,
|
||||
syncErrorData: undefined,
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
|
@ -169,12 +156,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
|
||||
this._sessionStore = sessionStore;
|
||||
this._sessionStoreToken = this._sessionStore.addListener(
|
||||
this._setStateFromSessionStore,
|
||||
);
|
||||
this._setStateFromSessionStore();
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
|
@ -203,9 +184,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
|
||||
if (this._sessionStoreToken) {
|
||||
this._sessionStoreToken.remove();
|
||||
}
|
||||
this.resizer.detach();
|
||||
}
|
||||
|
||||
|
@ -226,20 +204,13 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
return this._roomView.current.canResetTimeline();
|
||||
};
|
||||
|
||||
_setStateFromSessionStore = () => {
|
||||
if (this._sessionStore.getCachedPassword()) {
|
||||
showSetPasswordToast();
|
||||
} else {
|
||||
hideSetPasswordToast();
|
||||
}
|
||||
};
|
||||
|
||||
_createResizer() {
|
||||
const classNames = {
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
};
|
||||
let size;
|
||||
const collapseConfig = {
|
||||
toggleSize: 260 - 50,
|
||||
onCollapsed: (collapsed) => {
|
||||
|
@ -250,15 +221,19 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
dis.dispatch({action: "show_left_panel"}, true);
|
||||
}
|
||||
},
|
||||
onResized: (size) => {
|
||||
window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
onResized: (_size) => {
|
||||
size = _size;
|
||||
this.props.resizeNotifier.notifyLeftHandleResized();
|
||||
},
|
||||
onResizeStart: () => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
},
|
||||
onResizeStop: () => {
|
||||
window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
},
|
||||
};
|
||||
const resizer = new Resizer(
|
||||
this._resizeContainer.current,
|
||||
CollapseDistributor,
|
||||
collapseConfig);
|
||||
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
|
||||
resizer.setClassNames(classNames);
|
||||
return resizer;
|
||||
}
|
||||
|
@ -316,10 +291,10 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
_calculateServerLimitToast(syncErrorData: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
|
||||
const error = syncErrorData && syncErrorData.error && syncErrorData.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
|
||||
_calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
|
||||
const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
|
||||
if (error) {
|
||||
usageLimitEventContent = syncErrorData.error.data;
|
||||
usageLimitEventContent = syncError.error.data;
|
||||
}
|
||||
|
||||
if (usageLimitEventContent) {
|
||||
|
@ -534,8 +509,8 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
// Could be "GroupTile +groupId:domain"
|
||||
const draggableId = result.draggableId.split(' ').pop();
|
||||
|
||||
// Dispatch synchronously so that the TagPanel receives an
|
||||
// optimistic update from TagOrderStore before the previous
|
||||
// Dispatch synchronously so that the GroupFilterPanel receives an
|
||||
// optimistic update from GroupFilterOrderStore before the previous
|
||||
// state is shown.
|
||||
dis.dispatch(TagOrderActions.moveTag(
|
||||
this._matrixClient,
|
||||
|
@ -566,48 +541,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
), true);
|
||||
};
|
||||
|
||||
_onMouseDown = (ev) => {
|
||||
// When the panels are disabled, clicking on them results in a mouse event
|
||||
// which bubbles to certain elements in the tree. When this happens, close
|
||||
// any settings page that is currently open (user/room/group).
|
||||
if (this.props.leftDisabled && this.props.rightDisabled) {
|
||||
const targetClasses = new Set(ev.target.className.split(' '));
|
||||
if (
|
||||
targetClasses.has('mx_MatrixChat') ||
|
||||
targetClasses.has('mx_MatrixChat_middlePanel') ||
|
||||
targetClasses.has('mx_RoomView')
|
||||
) {
|
||||
this.setState({
|
||||
mouseDown: {
|
||||
x: ev.pageX,
|
||||
y: ev.pageY,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onMouseUp = (ev) => {
|
||||
if (!this.state.mouseDown) return;
|
||||
|
||||
const deltaX = ev.pageX - this.state.mouseDown.x;
|
||||
const deltaY = ev.pageY - this.state.mouseDown.y;
|
||||
const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY));
|
||||
const maxRadius = 5; // People shouldn't be straying too far, hopefully
|
||||
|
||||
// Note: we track how far the user moved their mouse to help
|
||||
// combat against https://github.com/vector-im/element-web/issues/7158
|
||||
|
||||
if (distance < maxRadius) {
|
||||
// This is probably a real click, and not a drag
|
||||
dis.dispatch({ action: 'close_settings' });
|
||||
}
|
||||
|
||||
// Always clear the mouseDown state to ensure we don't accidentally
|
||||
// use stale values due to the mouseDown checks.
|
||||
this.setState({mouseDown: null});
|
||||
};
|
||||
|
||||
render() {
|
||||
const RoomView = sdk.getComponent('structures.RoomView');
|
||||
const UserView = sdk.getComponent('structures.UserView');
|
||||
|
@ -620,18 +553,15 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
switch (this.props.page_type) {
|
||||
case PageTypes.RoomView:
|
||||
pageElement = <RoomView
|
||||
ref={this._roomView}
|
||||
autoJoin={this.props.autoJoin}
|
||||
onRegistered={this.props.onRegistered}
|
||||
thirdPartyInvite={this.props.thirdPartyInvite}
|
||||
oobData={this.props.roomOobData}
|
||||
viaServers={this.props.viaServers}
|
||||
eventPixelOffset={this.props.initialEventPixelOffset}
|
||||
key={this.props.currentRoomId || 'roomview'}
|
||||
disabled={this.props.middleDisabled}
|
||||
ConferenceHandler={this.props.ConferenceHandler}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
ref={this._roomView}
|
||||
autoJoin={this.props.autoJoin}
|
||||
onRegistered={this.props.onRegistered}
|
||||
threepidInvite={this.props.threepidInvite}
|
||||
oobData={this.props.roomOobData}
|
||||
viaServers={this.props.viaServers}
|
||||
key={this.props.currentRoomId || 'roomview'}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
break;
|
||||
|
||||
case PageTypes.MyGroups:
|
||||
|
@ -647,12 +577,13 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
break;
|
||||
|
||||
case PageTypes.UserView:
|
||||
pageElement = <UserView userId={this.props.currentUserId} />;
|
||||
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
break;
|
||||
case PageTypes.GroupView:
|
||||
pageElement = <GroupView
|
||||
groupId={this.props.currentGroupId}
|
||||
isNew={this.props.currentGroupIsNew}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
break;
|
||||
}
|
||||
|
@ -676,8 +607,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
onKeyDown={this._onReactKeyDown}
|
||||
className='mx_MatrixChat_wrapper'
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
onMouseDown={this._onMouseDown}
|
||||
onMouseUp={this._onMouseUp}
|
||||
>
|
||||
<ToastContainer />
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
|
|
|
@ -19,9 +19,18 @@ import React from 'react';
|
|||
import { Resizable } from 're-resizable';
|
||||
|
||||
export default class MainSplit extends React.Component {
|
||||
_onResized = (event, direction, refToElement, delta) => {
|
||||
_onResizeStart = () => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
};
|
||||
|
||||
_onResize = () => {
|
||||
this.props.resizeNotifier.notifyRightHandleResized();
|
||||
};
|
||||
|
||||
_onResizeStop = (event, direction, refToElement, delta) => {
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width);
|
||||
}
|
||||
};
|
||||
|
||||
_loadSidePanelSize() {
|
||||
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
|
||||
|
@ -58,7 +67,9 @@ export default class MainSplit extends React.Component {
|
|||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
onResizeStop={this._onResized}
|
||||
onResizeStart={this._onResizeStart}
|
||||
onResize={this._onResize}
|
||||
onResizeStop={this._onResizeStop}
|
||||
className="mx_RightPanel_ResizeWrapper"
|
||||
handleClasses={{left: "mx_RightPanel_ResizeHandle"}}
|
||||
>
|
||||
|
|
|
@ -30,7 +30,7 @@ import 'what-input';
|
|||
|
||||
import Analytics from "../../Analytics";
|
||||
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as RoomListSorter from "../../RoomListSorter";
|
||||
|
@ -69,7 +69,7 @@ import { ViewUserPayload } from "../../dispatcher/payloads/ViewUserPayload";
|
|||
import { Action } from "../../dispatcher/actions";
|
||||
import {
|
||||
showToast as showAnalyticsToast,
|
||||
hideToast as hideAnalyticsToast
|
||||
hideToast as hideAnalyticsToast,
|
||||
} from "../../toasts/AnalyticsToast";
|
||||
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
|
@ -77,6 +77,10 @@ import ErrorDialog from "../views/dialogs/ErrorDialog";
|
|||
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import { leaveRoomBehaviour } from "../../utils/membership";
|
||||
import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog";
|
||||
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
|
||||
import {UIFeature} from "../../settings/UIFeature";
|
||||
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -128,6 +132,7 @@ interface IScreen {
|
|||
params?: object;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IRoomInfo {
|
||||
room_id?: string;
|
||||
room_alias?: string;
|
||||
|
@ -135,16 +140,16 @@ interface IRoomInfo {
|
|||
|
||||
auto_join?: boolean;
|
||||
highlighted?: boolean;
|
||||
third_party_invite?: object;
|
||||
oob_data?: object;
|
||||
via_servers?: string[];
|
||||
threepid_invite?: IThreepidInvite;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface IProps { // TODO type things better
|
||||
config: Record<string, any>;
|
||||
serverConfig?: ValidatedServerConfig;
|
||||
ConferenceHandler?: any;
|
||||
onNewScreen: (string) => void;
|
||||
onNewScreen: (screen: string, replaceLast: boolean) => void;
|
||||
enableGuest?: boolean;
|
||||
// the queryParams extracted from the [real] query-string of the URI
|
||||
realQueryParams?: Record<string, string>;
|
||||
|
@ -164,6 +169,7 @@ interface IState {
|
|||
// the master view we are showing.
|
||||
view: Views;
|
||||
// What the LoggedInView would be showing if visible
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type?: PageTypes;
|
||||
// The ID of the room we're viewing. This is either populated directly
|
||||
// in the case where we view a room by ID or by RoomView when it resolves
|
||||
|
@ -175,12 +181,12 @@ interface IState {
|
|||
currentUserId?: string;
|
||||
// this is persisted as mx_lhs_size, loaded in LoggedInView
|
||||
collapseLhs: boolean;
|
||||
leftDisabled: boolean;
|
||||
middleDisabled: boolean;
|
||||
// the right panel's disabled state is tracked in its store.
|
||||
// Parameters used in the registration dance with the IS
|
||||
// eslint-disable-next-line camelcase
|
||||
register_client_secret?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
register_session_id?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
register_id_sid?: string;
|
||||
// When showing Modal dialogs we need to set aria-hidden on the root app element
|
||||
// and disable it when there are no dialogs
|
||||
|
@ -189,7 +195,7 @@ interface IState {
|
|||
resizeNotifier: ResizeNotifier;
|
||||
serverConfig?: ValidatedServerConfig;
|
||||
ready: boolean;
|
||||
thirdPartyInvite?: object;
|
||||
threepidInvite?: IThreepidInvite,
|
||||
roomOobData?: object;
|
||||
viaServers?: string[];
|
||||
pendingInitialSync?: boolean;
|
||||
|
@ -227,8 +233,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.state = {
|
||||
view: Views.LOADING,
|
||||
collapseLhs: false,
|
||||
leftDisabled: false,
|
||||
middleDisabled: false,
|
||||
|
||||
hideToSRUsers: false,
|
||||
|
||||
|
@ -253,6 +257,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// outside this.state because updating it should never trigger a
|
||||
// rerender.
|
||||
this.screenAfterLogin = this.props.initialScreenAfterLogin;
|
||||
if (this.screenAfterLogin) {
|
||||
const params = this.screenAfterLogin.params || {};
|
||||
if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) {
|
||||
// probably a threepid invite - try to store it
|
||||
const roomId = this.screenAfterLogin.screen.substring("room/".length);
|
||||
ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat);
|
||||
}
|
||||
}
|
||||
|
||||
this.windowWidth = 10000;
|
||||
this.handleResize();
|
||||
|
@ -273,7 +285,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// When the session loads it'll be detected as soft logged out and a dispatch
|
||||
// will be sent out to say that, triggering this MatrixChat to show the soft
|
||||
// logout page.
|
||||
Lifecycle.loadSession({});
|
||||
Lifecycle.loadSession();
|
||||
}
|
||||
|
||||
this.accountPassword = null;
|
||||
|
@ -340,6 +352,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillUpdate(props, state) {
|
||||
if (this.shouldTrackPageChange(this.state, state)) {
|
||||
this.startPageChangeTimer();
|
||||
|
@ -396,8 +409,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}).then((loadedSession) => {
|
||||
if (!loadedSession) {
|
||||
// fall back to showing the welcome screen
|
||||
dis.dispatch({action: "view_welcome_page"});
|
||||
// fall back to showing the welcome screen... unless we have a 3pid invite pending
|
||||
if (ThreepidInviteStore.instance.pickBestInvite()) {
|
||||
dis.dispatch({action: 'start_registration'});
|
||||
} else {
|
||||
dis.dispatch({action: "view_welcome_page"});
|
||||
}
|
||||
}
|
||||
});
|
||||
// Note we don't catch errors from this: we catch everything within
|
||||
|
@ -609,8 +626,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
|
||||
{initialTabId: tabPayload.initialTabId},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true
|
||||
);
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this.viewSomethingBehindModal();
|
||||
|
@ -620,7 +636,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.createRoom(payload.public);
|
||||
break;
|
||||
case 'view_create_group': {
|
||||
const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog");
|
||||
let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog")
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
CreateGroupDialog = CreateCommunityPrototypeDialog;
|
||||
}
|
||||
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
||||
break;
|
||||
}
|
||||
|
@ -646,9 +665,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
case 'view_home_page':
|
||||
this.viewHome();
|
||||
break;
|
||||
case 'view_set_mxid':
|
||||
this.setMxId(payload);
|
||||
break;
|
||||
case 'view_start_chat_or_reuse':
|
||||
this.chatCreateOrReuse(payload.user_id);
|
||||
break;
|
||||
|
@ -689,14 +705,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.state.resizeNotifier.notifyLeftHandleResized();
|
||||
});
|
||||
break;
|
||||
case 'panel_disable': {
|
||||
this.setState({
|
||||
leftDisabled: payload.leftDisabled || payload.sideDisabled || false,
|
||||
middleDisabled: payload.middleDisabled || false,
|
||||
// We don't track the right panel being disabled here - it's tracked in the store.
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'on_logged_in':
|
||||
if (
|
||||
!Lifecycle.isSoftLogout() &&
|
||||
|
@ -825,10 +833,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// context of that particular event.
|
||||
// @param {boolean=} roomInfo.highlighted If true, add event_id to the hash of the URL
|
||||
// and alter the EventTile to appear highlighted.
|
||||
// @param {Object=} roomInfo.third_party_invite Object containing data about the third party
|
||||
// we received to join the room, if any.
|
||||
// @param {string=} roomInfo.third_party_invite.inviteSignUrl 3pid invite sign URL
|
||||
// @param {string=} roomInfo.third_party_invite.invitedEmail The email address the invite was sent to
|
||||
// @param {Object=} roomInfo.threepid_invite Object containing data about the third party
|
||||
// we received to join the room, if any.
|
||||
// @param {Object=} roomInfo.oob_data Object of additional data about the room
|
||||
// that has been passed out-of-band (eg.
|
||||
// room name and avatar from an invite email)
|
||||
|
@ -876,6 +882,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
// If we are redirecting to a Room Alias and it is for the room we already showing then replace history item
|
||||
const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId;
|
||||
|
||||
if (roomInfo.event_id && roomInfo.highlighted) {
|
||||
presentedId += "/" + roomInfo.event_id;
|
||||
}
|
||||
|
@ -883,12 +892,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
view: Views.LOGGED_IN,
|
||||
currentRoomId: roomInfo.room_id || null,
|
||||
page_type: PageTypes.RoomView,
|
||||
thirdPartyInvite: roomInfo.third_party_invite,
|
||||
threepidInvite: roomInfo.threepid_invite,
|
||||
roomOobData: roomInfo.oob_data,
|
||||
viaServers: roomInfo.via_servers,
|
||||
ready: true,
|
||||
}, () => {
|
||||
this.notifyNewScreen('room/' + presentedId);
|
||||
this.notifyNewScreen('room/' + presentedId, replaceLast);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -960,37 +969,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private setMxId(payload) {
|
||||
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
|
||||
const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
|
||||
homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(),
|
||||
onFinished: (submitted, credentials) => {
|
||||
if (!submitted) {
|
||||
dis.dispatch({
|
||||
action: 'cancel_after_sync_prepared',
|
||||
});
|
||||
if (payload.go_home_on_cancel) {
|
||||
dis.dispatch({
|
||||
action: 'view_home_page',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.setJustRegisteredUserId(credentials.user_id);
|
||||
this.onRegistered(credentials);
|
||||
},
|
||||
onDifferentServerClicked: (ev) => {
|
||||
dis.dispatch({action: 'start_registration'});
|
||||
close();
|
||||
},
|
||||
onLoginClick: (ev) => {
|
||||
dis.dispatch({action: 'start_login'});
|
||||
close();
|
||||
},
|
||||
}).close;
|
||||
}
|
||||
|
||||
private async createRoom(defaultPublic = false) {
|
||||
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||
if (communityId) {
|
||||
// double check the user will have permission to associate this room with the community
|
||||
if (!CommunityPrototypeStore.instance.isAdminOf(communityId)) {
|
||||
Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, {
|
||||
title: _t("Cannot create rooms in this community"),
|
||||
description: _t("You do not have permission to create rooms in this community."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
|
||||
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic });
|
||||
|
||||
|
@ -1076,7 +1067,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
title: _t("Leave room"),
|
||||
description: (
|
||||
<span>
|
||||
{ _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
|
||||
{ _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
|
||||
{ warnings }
|
||||
</span>
|
||||
),
|
||||
|
@ -1190,6 +1181,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// the homepage.
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
}
|
||||
} else if (ThreepidInviteStore.instance.pickBestInvite()) {
|
||||
// The user has a 3pid invite pending - show them that
|
||||
const threepidInvite = ThreepidInviteStore.instance.pickBestInvite();
|
||||
|
||||
// HACK: This is a pretty brutal way of threading the invite back through
|
||||
// our systems, but it's the safest we have for now.
|
||||
const params = ThreepidInviteStore.instance.translateToWireFormat(threepidInvite);
|
||||
this.showScreen(`room/${threepidInvite.roomId}`, params)
|
||||
} else {
|
||||
// The user has just logged in after registering,
|
||||
// so show the homepage.
|
||||
|
@ -1201,8 +1200,8 @@ 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);
|
||||
if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) {
|
||||
showAnalyticsToast(this.props.config.piwik?.policyUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1331,7 +1330,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.firstSyncComplete = true;
|
||||
this.firstSyncPromise.resolve();
|
||||
|
||||
if (Notifier.shouldShowToolbar()) {
|
||||
if (Notifier.shouldShowPrompt()) {
|
||||
showNotificationsToast();
|
||||
}
|
||||
|
||||
|
@ -1340,15 +1339,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
ready: true,
|
||||
});
|
||||
});
|
||||
cli.on('Call.incoming', function(call) {
|
||||
// we dispatch this synchronously to make sure that the event
|
||||
// handlers on the call are set up immediately (so that if
|
||||
// we get an immediate hangup, we don't get a stuck call)
|
||||
dis.dispatch({
|
||||
action: 'incoming_call',
|
||||
call: call,
|
||||
}, true);
|
||||
});
|
||||
|
||||
if (SettingsStore.getValue(UIFeature.Voip)) {
|
||||
cli.on('Call.incoming', function(call) {
|
||||
// we dispatch this synchronously to make sure that the event
|
||||
// handlers on the call are set up immediately (so that if
|
||||
// we get an immediate hangup, we don't get a stuck call)
|
||||
dis.dispatch({
|
||||
action: 'incoming_call',
|
||||
call: call,
|
||||
}, true);
|
||||
});
|
||||
}
|
||||
|
||||
cli.on('Session.logged_out', function(errObj) {
|
||||
if (Lifecycle.isLoggingOut()) return;
|
||||
|
||||
|
@ -1429,7 +1432,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
cli.on("crypto.warning", (type) => {
|
||||
switch (type) {
|
||||
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
|
||||
const brand = SdkConfig.get().brand;
|
||||
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
|
||||
title: _t('Old cryptography data detected'),
|
||||
description: _t(
|
||||
|
@ -1440,7 +1442,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
"in this version. This may also cause messages exchanged with this " +
|
||||
"version to fail. If you experience problems, log out and back in " +
|
||||
"again. To retain message history, export and re-import your keys.",
|
||||
{ brand },
|
||||
{ brand: SdkConfig.get().brand },
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
@ -1465,12 +1467,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
if (haveNewVersion) {
|
||||
Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method',
|
||||
import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'),
|
||||
import('../../async-components/views/dialogs/security/NewRecoveryMethodDialog'),
|
||||
{ newVersionInfo },
|
||||
);
|
||||
} else {
|
||||
Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed',
|
||||
import('../../async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog'),
|
||||
import('../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1627,16 +1629,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149
|
||||
|
||||
// FIXME: sort_out caseConsistency
|
||||
const thirdPartyInvite = {
|
||||
inviteSignUrl: params.signurl,
|
||||
invitedEmail: params.email,
|
||||
};
|
||||
const oobData = {
|
||||
name: params.room_name,
|
||||
avatarUrl: params.room_avatar_url,
|
||||
inviterName: params.inviter_name,
|
||||
};
|
||||
let threepidInvite: IThreepidInvite;
|
||||
if (params.signurl && params.email) {
|
||||
threepidInvite = ThreepidInviteStore.instance
|
||||
.storeInvite(roomString, params as IThreepidInviteWireFormat);
|
||||
}
|
||||
|
||||
// on our URLs there might be a ?via=matrix.org or similar to help
|
||||
// joins to the room succeed. We'll pass these through as an array
|
||||
|
@ -1657,8 +1654,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// it as highlighted, which will propagate to RoomView and highlight the
|
||||
// associated EventTile.
|
||||
highlighted: Boolean(eventId),
|
||||
third_party_invite: thirdPartyInvite,
|
||||
oob_data: oobData,
|
||||
threepid_invite: threepidInvite,
|
||||
// TODO: Replace oob_data with the threepidInvite (which has the same info).
|
||||
// This isn't done yet because it's threaded through so many more places.
|
||||
// See https://github.com/vector-im/element-web/issues/15157
|
||||
oob_data: {
|
||||
name: threepidInvite?.roomName,
|
||||
avatarUrl: threepidInvite?.roomAvatarUrl,
|
||||
inviterName: threepidInvite?.inviterName,
|
||||
},
|
||||
room_alias: undefined,
|
||||
room_id: undefined,
|
||||
};
|
||||
|
@ -1690,9 +1694,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
notifyNewScreen(screen: string) {
|
||||
notifyNewScreen(screen: string, replaceLast = false) {
|
||||
if (this.props.onNewScreen) {
|
||||
this.props.onNewScreen(screen);
|
||||
this.props.onNewScreen(screen, replaceLast);
|
||||
}
|
||||
this.setPageSubtitle();
|
||||
}
|
||||
|
@ -1764,12 +1768,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.showScreen("forgot_password");
|
||||
};
|
||||
|
||||
onRegisterFlowComplete = (credentials: object, password: string) => {
|
||||
onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string) => {
|
||||
return this.onUserCompletedLoginFlow(credentials, password);
|
||||
};
|
||||
|
||||
// returns a promise which resolves to the new MatrixClient
|
||||
onRegistered(credentials: object) {
|
||||
onRegistered(credentials: IMatrixClientCreds) {
|
||||
return Lifecycle.setLoggedIn(credentials);
|
||||
}
|
||||
|
||||
|
@ -1805,7 +1809,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else {
|
||||
subtitle = `${this.subTitleStatus} ${subtitle}`;
|
||||
}
|
||||
document.title = `${SdkConfig.get().brand} ${subtitle}`;
|
||||
|
||||
const title = `${SdkConfig.get().brand} ${subtitle}`;
|
||||
|
||||
if (document.title !== title) {
|
||||
document.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusIndicator(state: string, prevState: string) {
|
||||
|
@ -1843,7 +1852,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
return this.props.makeRegistrationUrl(params);
|
||||
};
|
||||
|
||||
onUserCompletedLoginFlow = async (credentials: object, password: string) => {
|
||||
/**
|
||||
* After registration or login, we run various post-auth steps before entering the app
|
||||
* proper, such setting up cross-signing or verifying the new session.
|
||||
*
|
||||
* Note: SSO users (and any others using token login) currently do not pass through
|
||||
* this, as they instead jump straight into the app after `attemptTokenLogin`.
|
||||
*/
|
||||
onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string) => {
|
||||
this.accountPassword = password;
|
||||
// self-destruct the password after 5mins
|
||||
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
|
||||
|
@ -1909,7 +1925,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
render() {
|
||||
const fragmentAfterLogin = this.getFragmentAfterLogin();
|
||||
let view;
|
||||
let view = null;
|
||||
|
||||
if (this.state.view === Views.LOADING) {
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
|
@ -1988,14 +2004,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else if (this.state.view === Views.WELCOME) {
|
||||
const Welcome = sdk.getComponent('auth.Welcome');
|
||||
view = <Welcome />;
|
||||
} else if (this.state.view === Views.REGISTER) {
|
||||
} else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) {
|
||||
const Registration = sdk.getComponent('structures.auth.Registration');
|
||||
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
|
||||
view = (
|
||||
<Registration
|
||||
clientSecret={this.state.register_client_secret}
|
||||
sessionId={this.state.register_session_id}
|
||||
idSid={this.state.register_id_sid}
|
||||
email={this.props.startingFragmentQueryParams.email}
|
||||
email={email}
|
||||
brand={this.props.config.brand}
|
||||
makeRegistrationUrl={this.makeRegistrationUrl}
|
||||
onLoggedIn={this.onRegisterFlowComplete}
|
||||
|
@ -2005,7 +2022,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
{...this.getServerProperties()}
|
||||
/>
|
||||
);
|
||||
} else if (this.state.view === Views.FORGOT_PASSWORD) {
|
||||
} else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) {
|
||||
const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword');
|
||||
view = (
|
||||
<ForgotPassword
|
||||
|
@ -2016,6 +2033,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
/>
|
||||
);
|
||||
} else if (this.state.view === Views.LOGIN) {
|
||||
const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset);
|
||||
const Login = sdk.getComponent('structures.auth.Login');
|
||||
view = (
|
||||
<Login
|
||||
|
@ -2024,7 +2042,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
onRegisterClick={this.onRegisterClick}
|
||||
fallbackHsUrl={this.getFallbackHsUrl()}
|
||||
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
|
||||
onForgotPasswordClick={this.onForgotPasswordClick}
|
||||
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
fragmentAfterLogin={fragmentAfterLogin}
|
||||
{...this.getServerProperties()}
|
||||
|
@ -2049,3 +2067,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
</ErrorBoundary>;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
// JRS: Maybe we should move the step that writes this to the window out of
|
||||
// `element-web` and into this file? Better yet, we should probably create a
|
||||
// store to hold this state.
|
||||
// See also https://github.com/vector-im/element-web/issues/15034.
|
||||
const app = window.matrixChat;
|
||||
return app && (app as MatrixChat).state.view === Views.LOGGED_IN;
|
||||
}
|
||||
|
|
|
@ -135,6 +135,9 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
// whether to use the irc layout
|
||||
useIRCLayout: PropTypes.bool,
|
||||
|
||||
// whether or not to show flair at all
|
||||
enableFlair: PropTypes.bool,
|
||||
};
|
||||
|
||||
// Force props to be loaded for useIRCLayout
|
||||
|
@ -515,10 +518,13 @@ export default class MessagePanel extends React.Component {
|
|||
if (!grouper) {
|
||||
const wantTile = this._shouldShowEvent(mxEv);
|
||||
if (wantTile) {
|
||||
const nextEvent = i < this.props.events.length - 1
|
||||
? this.props.events[i + 1]
|
||||
: null;
|
||||
// make sure we unpack the array returned by _getTilesForEvent,
|
||||
// otherwise react will auto-generate keys and we will end up
|
||||
// replacing all of the DOM elements every time we paginate.
|
||||
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last));
|
||||
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent));
|
||||
prevEvent = mxEv;
|
||||
}
|
||||
|
||||
|
@ -534,7 +540,7 @@ export default class MessagePanel extends React.Component {
|
|||
return ret;
|
||||
}
|
||||
|
||||
_getTilesForEvent(prevEvent, mxEv, last) {
|
||||
_getTilesForEvent(prevEvent, mxEv, last, nextEvent) {
|
||||
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
|
@ -559,6 +565,11 @@ export default class MessagePanel extends React.Component {
|
|||
ret.push(dateSeparator);
|
||||
}
|
||||
|
||||
let willWantDateSeparator = false;
|
||||
if (nextEvent) {
|
||||
willWantDateSeparator = this._wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
|
||||
}
|
||||
|
||||
// is this a continuation of the previous message?
|
||||
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv);
|
||||
|
||||
|
@ -579,7 +590,8 @@ export default class MessagePanel extends React.Component {
|
|||
data-scroll-tokens={scrollToken}
|
||||
>
|
||||
<TileErrorBoundary mxEvent={mxEv}>
|
||||
<EventTile mxEvent={mxEv}
|
||||
<EventTile
|
||||
mxEvent={mxEv}
|
||||
continuation={continuation}
|
||||
isRedacted={mxEv.isRedacted()}
|
||||
replacingEventId={mxEv.replacingEventId()}
|
||||
|
@ -594,10 +606,12 @@ export default class MessagePanel extends React.Component {
|
|||
isTwelveHour={this.props.isTwelveHour}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
last={last}
|
||||
lastInSection={willWantDateSeparator}
|
||||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
enableFlair={this.props.enableFlair}
|
||||
/>
|
||||
</TileErrorBoundary>
|
||||
</li>,
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
|
@ -26,29 +25,23 @@ import AccessibleButton from '../views/elements/AccessibleButton';
|
|||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MyGroups',
|
||||
export default class MyGroups extends React.Component {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
groups: null,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
groups: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
statics: {
|
||||
contextType: MatrixClientContext,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this._fetch();
|
||||
},
|
||||
}
|
||||
|
||||
_onCreateGroupClick: function() {
|
||||
_onCreateGroupClick = () => {
|
||||
dis.dispatch({action: 'view_create_group'});
|
||||
},
|
||||
};
|
||||
|
||||
_fetch: function() {
|
||||
_fetch() {
|
||||
this.context.getJoinedGroups().then((result) => {
|
||||
this.setState({groups: result.groups, error: null});
|
||||
}, (err) => {
|
||||
|
@ -59,9 +52,9 @@ export default createReactClass({
|
|||
}
|
||||
this.setState({groups: null, error: err});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
|
@ -149,5 +142,5 @@ export default createReactClass({
|
|||
{ content }
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,21 +17,22 @@ 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 * as sdk from "../../index";
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
|
||||
/*
|
||||
* Component which shows the global notification list using a TimelinePanel
|
||||
*/
|
||||
const NotificationPanel = createReactClass({
|
||||
displayName: 'NotificationPanel',
|
||||
class NotificationPanel extends React.Component {
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
propTypes: {
|
||||
},
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
@ -41,29 +42,28 @@ const NotificationPanel = createReactClass({
|
|||
<p>{_t('You have no visible notifications in this room.')}</p>
|
||||
</div>);
|
||||
|
||||
let content;
|
||||
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
|
||||
if (timelineSet) {
|
||||
return (
|
||||
<div className="mx_NotificationPanel" role="tabpanel">
|
||||
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview={false}
|
||||
tileShape="notif"
|
||||
empty={emptyState}
|
||||
/>
|
||||
</div>
|
||||
content = (
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview={false}
|
||||
tileShape="notif"
|
||||
empty={emptyState}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
console.error("No notifTimelineSet available!");
|
||||
return (
|
||||
<div className="mx_NotificationPanel" role="tabpanel">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
content = <Loader />;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
|
||||
{ content }
|
||||
</BaseCard>;
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationPanel;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015 - 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -20,7 +17,8 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import RateLimitedFunc from '../../ratelimitedfunc';
|
||||
|
@ -30,11 +28,14 @@ import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPa
|
|||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
|
||||
import WidgetCard from "../views/right_panel/WidgetCard";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
|
||||
export default class RightPanel extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
roomId: PropTypes.string, // if showing panels for a given room, this is set
|
||||
room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set
|
||||
groupId: PropTypes.string, // if showing panels for a given group, this is set
|
||||
user: PropTypes.object, // used if we know the user ahead of opening the panel
|
||||
};
|
||||
|
@ -42,13 +43,13 @@ export default class RightPanel extends React.Component {
|
|||
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
...RightPanelStore.getSharedInstance().roomPanelPhaseParams,
|
||||
phase: this._getPhaseFromProps(),
|
||||
isUserPrivilegedInGroup: null,
|
||||
member: this._getUserForPanel(),
|
||||
verificationRequest: RightPanelStore.getSharedInstance().roomPanelPhaseParams.verificationRequest,
|
||||
};
|
||||
this.onAction = this.onAction.bind(this);
|
||||
this.onRoomStateMember = this.onRoomStateMember.bind(this);
|
||||
|
@ -100,10 +101,6 @@ export default class RightPanel extends React.Component {
|
|||
}
|
||||
return RightPanelPhases.RoomMemberInfo;
|
||||
} else {
|
||||
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) {
|
||||
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList});
|
||||
return RightPanelPhases.RoomMemberList;
|
||||
}
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
}
|
||||
|
@ -161,13 +158,13 @@ export default class RightPanel extends React.Component {
|
|||
}
|
||||
|
||||
onRoomStateMember(ev, state, member) {
|
||||
if (member.roomId !== this.props.roomId) {
|
||||
if (!this.props.room || member.roomId !== this.props.room.roomId) {
|
||||
return;
|
||||
}
|
||||
// redraw the badge on the membership list
|
||||
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.roomId) {
|
||||
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) {
|
||||
this._delayedUpdate();
|
||||
} else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.roomId &&
|
||||
} else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId &&
|
||||
member.userId === this.state.member.userId) {
|
||||
// refresh the member info (e.g. new power level)
|
||||
this._delayedUpdate();
|
||||
|
@ -184,6 +181,7 @@ export default class RightPanel extends React.Component {
|
|||
event: payload.event,
|
||||
verificationRequest: payload.verificationRequest,
|
||||
verificationRequestPromise: payload.verificationRequestPromise,
|
||||
widgetId: payload.widgetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -200,17 +198,31 @@ export default class RightPanel extends React.Component {
|
|||
dis.dispatch({
|
||||
action: "view_home_page",
|
||||
});
|
||||
} else if (this.state.phase === RightPanelPhases.EncryptionPanel &&
|
||||
this.state.verificationRequest && this.state.verificationRequest.pending
|
||||
) {
|
||||
// When the user clicks close on the encryption panel cancel the pending request first if any
|
||||
this.state.verificationRequest.cancel();
|
||||
} 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.
|
||||
const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null,
|
||||
member: isEncryptionPhase ? this.state.member : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
// the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ToggleRightPanel,
|
||||
type: this.props.groupId ? "group" : "room",
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const MemberList = sdk.getComponent('rooms.MemberList');
|
||||
const UserInfo = sdk.getComponent('right_panel.UserInfo');
|
||||
|
@ -223,36 +235,42 @@ export default class RightPanel extends React.Component {
|
|||
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
|
||||
|
||||
let panel = <div />;
|
||||
const roomId = this.props.room ? this.props.room.roomId : undefined;
|
||||
|
||||
switch (this.state.phase) {
|
||||
case RightPanelPhases.RoomMemberList:
|
||||
if (this.props.roomId) {
|
||||
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
|
||||
if (roomId) {
|
||||
panel = <MemberList roomId={roomId} key={roomId} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupMemberList:
|
||||
if (this.props.groupId) {
|
||||
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupRoomList:
|
||||
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomMemberInfo:
|
||||
case RightPanelPhases.EncryptionPanel:
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
roomId={this.props.roomId}
|
||||
key={this.props.roomId || this.state.member.userId}
|
||||
room={this.props.room}
|
||||
key={roomId || this.state.member.userId}
|
||||
onClose={this.onCloseUserInfo}
|
||||
phase={this.state.phase}
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
verificationRequestPromise={this.state.verificationRequestPromise}
|
||||
/>;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Room3pidMemberInfo:
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={roomId} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupMemberInfo:
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
|
@ -260,28 +278,33 @@ export default class RightPanel extends React.Component {
|
|||
key={this.state.member.userId}
|
||||
onClose={this.onCloseUserInfo} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupRoomInfo:
|
||||
panel = <GroupRoomInfo
|
||||
groupRoomId={this.state.groupRoomId}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.groupRoomId} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.NotificationPanel:
|
||||
panel = <NotificationPanel />;
|
||||
panel = <NotificationPanel onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.FilePanel:
|
||||
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomSummary:
|
||||
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Widget:
|
||||
panel = <WidgetCard room={this.props.room} widgetId={this.state.widgetId} onClose={this.onClose} />;
|
||||
break;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RightPanel", "mx_fadable", {
|
||||
"collapsed": this.props.collapsed,
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
"dark-panel": true,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className={classes} id="mx_RightPanel">
|
||||
<aside className="mx_RightPanel dark-panel" id="mx_RightPanel">
|
||||
{ panel }
|
||||
</aside>
|
||||
);
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
@ -30,23 +29,28 @@ import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/Di
|
|||
import Analytics from '../../Analytics';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import GroupStore from "../../stores/GroupStore";
|
||||
import FlairStore from "../../stores/FlairStore";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 160;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
||||
function track(action) {
|
||||
Analytics.trackEvent('RoomDirectory', action);
|
||||
}
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomDirectory',
|
||||
|
||||
propTypes: {
|
||||
export default class RoomDirectory extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
protocolsLoading: true,
|
||||
|
@ -54,66 +58,108 @@ export default createReactClass({
|
|||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
filterString: null,
|
||||
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
|
||||
? selectedCommunityId
|
||||
: null,
|
||||
communityName: null,
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this.nextBatch = null;
|
||||
this.filterTimeout = null;
|
||||
this.scrollPanel = null;
|
||||
this.protocols = null;
|
||||
|
||||
this.setState({protocolsLoading: true});
|
||||
this.state.protocolsLoading = true;
|
||||
if (!MatrixClientPeg.get()) {
|
||||
// We may not have a client yet when invoked from welcome page
|
||||
this.setState({protocolsLoading: false});
|
||||
this.state.protocolsLoading = false;
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
}, (err) => {
|
||||
console.warn(`error loading third party protocols: ${err}`);
|
||||
this.setState({protocolsLoading: false});
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// Guests currently aren't allowed to use this API, so
|
||||
// ignore this as otherwise this error is literally the
|
||||
// thing you see when loading the client!
|
||||
return;
|
||||
}
|
||||
track('Failed to get protocol list from homeserver');
|
||||
const brand = SdkConfig.get().brand;
|
||||
this.setState({
|
||||
error: _t(
|
||||
'%(brand)s failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
{ brand },
|
||||
),
|
||||
|
||||
if (!this.state.selectedCommunityId) {
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
}, (err) => {
|
||||
console.warn(`error loading third party protocols: ${err}`);
|
||||
this.setState({protocolsLoading: false});
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// Guests currently aren't allowed to use this API, so
|
||||
// ignore this as otherwise this error is literally the
|
||||
// thing you see when loading the client!
|
||||
return;
|
||||
}
|
||||
track('Failed to get protocol list from homeserver');
|
||||
const brand = SdkConfig.get().brand;
|
||||
this.setState({
|
||||
error: _t(
|
||||
'%(brand)s failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
{brand},
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// We don't use the protocols in the communities v2 prototype experience
|
||||
this.state.protocolsLoading = false;
|
||||
|
||||
// Grab the profile info async
|
||||
FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => {
|
||||
this.setState({communityName: profile.name});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.refreshRoomList();
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this._unmounted = true;
|
||||
},
|
||||
}
|
||||
|
||||
refreshRoomList = () => {
|
||||
if (this.state.selectedCommunityId) {
|
||||
this.setState({
|
||||
publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => {
|
||||
return {
|
||||
// Translate all the group properties to the directory format
|
||||
room_id: r.roomId,
|
||||
name: r.name,
|
||||
topic: r.topic,
|
||||
canonical_alias: r.canonicalAlias,
|
||||
num_joined_members: r.numJoinedMembers,
|
||||
avatarUrl: r.avatarUrl,
|
||||
world_readable: r.worldReadable,
|
||||
guest_can_join: r.guestsCanJoin,
|
||||
};
|
||||
}).filter(r => {
|
||||
const filterString = this.state.filterString;
|
||||
if (filterString) {
|
||||
const containedIn = (s: string) => (s || "").toLowerCase().includes(filterString.toLowerCase());
|
||||
return containedIn(r.name) || containedIn(r.topic) || containedIn(r.canonical_alias);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
refreshRoomList: function() {
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
});
|
||||
this.getMoreRooms();
|
||||
},
|
||||
};
|
||||
|
||||
getMoreRooms: function() {
|
||||
getMoreRooms() {
|
||||
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
|
||||
if (!MatrixClientPeg.get()) return Promise.resolve();
|
||||
|
||||
this.setState({
|
||||
|
@ -185,7 +231,7 @@ export default createReactClass({
|
|||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A limited interface for removing rooms from the directory.
|
||||
|
@ -194,7 +240,7 @@ export default createReactClass({
|
|||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
removeFromDirectory: function(room) {
|
||||
removeFromDirectory(room) {
|
||||
const alias = get_display_alias_for_room(room);
|
||||
const name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
|
@ -236,18 +282,18 @@ export default createReactClass({
|
|||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onRoomClicked: function(room, ev) {
|
||||
if (ev.shiftKey) {
|
||||
onRoomClicked = (room, ev) => {
|
||||
if (ev.shiftKey && !this.state.selectedCommunityId) {
|
||||
ev.preventDefault();
|
||||
this.removeFromDirectory(room);
|
||||
} else {
|
||||
this.showRoom(room);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onOptionChange: function(server, instanceId) {
|
||||
onOptionChange = (server, instanceId) => {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
|
@ -265,15 +311,15 @@ export default createReactClass({
|
|||
// find the five gitter ones, at which point we do not want
|
||||
// to render all those rooms when switching back to 'all networks'.
|
||||
// Easiest to just blow away the state & re-fetch.
|
||||
},
|
||||
};
|
||||
|
||||
onFillRequest: function(backwards) {
|
||||
onFillRequest = (backwards) => {
|
||||
if (backwards || !this.nextBatch) return Promise.resolve(false);
|
||||
|
||||
return this.getMoreRooms();
|
||||
},
|
||||
};
|
||||
|
||||
onFilterChange: function(alias) {
|
||||
onFilterChange = (alias) => {
|
||||
this.setState({
|
||||
filterString: alias || null,
|
||||
});
|
||||
|
@ -289,9 +335,9 @@ export default createReactClass({
|
|||
this.filterTimeout = null;
|
||||
this.refreshRoomList();
|
||||
}, 700);
|
||||
},
|
||||
};
|
||||
|
||||
onFilterClear: function() {
|
||||
onFilterClear = () => {
|
||||
// update immediately
|
||||
this.setState({
|
||||
filterString: null,
|
||||
|
@ -300,9 +346,9 @@ export default createReactClass({
|
|||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onJoinFromSearchClick: function(alias) {
|
||||
onJoinFromSearchClick = (alias) => {
|
||||
// If we don't have a particular instance id selected, just show that rooms alias
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
// If the user specified an alias without a domain, add on whichever server is selected
|
||||
|
@ -343,50 +389,41 @@ export default createReactClass({
|
|||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onPreviewClick: function(ev, room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.room_id,
|
||||
should_peek: true,
|
||||
});
|
||||
onPreviewClick = (ev, room) => {
|
||||
this.showRoom(room, null, false, true);
|
||||
ev.stopPropagation();
|
||||
},
|
||||
};
|
||||
|
||||
onViewClick: function(ev, room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.room_id,
|
||||
should_peek: false,
|
||||
});
|
||||
onViewClick = (ev, room) => {
|
||||
this.showRoom(room);
|
||||
ev.stopPropagation();
|
||||
},
|
||||
};
|
||||
|
||||
onJoinClick: function(ev, room) {
|
||||
onJoinClick = (ev, room) => {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
},
|
||||
};
|
||||
|
||||
onCreateRoomClick: function(room) {
|
||||
onCreateRoomClick = room => {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
showRoomAlias: function(alias, autoJoin=false) {
|
||||
showRoomAlias(alias, autoJoin=false) {
|
||||
this.showRoom(null, alias, autoJoin);
|
||||
},
|
||||
}
|
||||
|
||||
showRoom: function(room, room_alias, autoJoin=false) {
|
||||
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
|
||||
this.props.onFinished();
|
||||
const payload = {
|
||||
action: 'view_room',
|
||||
auto_join: autoJoin,
|
||||
should_peek: shouldPeek,
|
||||
};
|
||||
if (room) {
|
||||
// Don't let the user view a room they won't be able to either
|
||||
|
@ -411,6 +448,7 @@ export default createReactClass({
|
|||
};
|
||||
|
||||
if (this.state.roomServer) {
|
||||
payload.via_servers = [this.state.roomServer];
|
||||
payload.opts = {
|
||||
viaServers: [this.state.roomServer],
|
||||
};
|
||||
|
@ -426,7 +464,7 @@ export default createReactClass({
|
|||
payload.room_id = room.room_id;
|
||||
}
|
||||
dis.dispatch(payload);
|
||||
},
|
||||
}
|
||||
|
||||
getRow(room) {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -459,6 +497,9 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
let topic = room.topic || '';
|
||||
// Additional truncation based on line numbers is done via CSS,
|
||||
// but to ensure that the DOM is not polluted with a huge string
|
||||
// we give it a hard limit before rendering.
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
|
@ -492,22 +533,22 @@ export default createReactClass({
|
|||
<td className="mx_RoomDirectory_join">{joinOrViewButton}</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
collectScrollPanel: function(element) {
|
||||
collectScrollPanel = (element) => {
|
||||
this.scrollPanel = element;
|
||||
},
|
||||
};
|
||||
|
||||
_stringLooksLikeId: function(s, field_type) {
|
||||
_stringLooksLikeId(s, field_type) {
|
||||
let pat = /^#[^\s]+:[^\s]/;
|
||||
if (field_type && field_type.regexp) {
|
||||
pat = new RegExp(field_type.regexp);
|
||||
}
|
||||
|
||||
return pat.test(s);
|
||||
},
|
||||
}
|
||||
|
||||
_getFieldsForThirdPartyLocation: function(userInput, protocol, instance) {
|
||||
_getFieldsForThirdPartyLocation(userInput, protocol, instance) {
|
||||
// make an object with the fields specified by that protocol. We
|
||||
// require that the values of all but the last field come from the
|
||||
// instance. The last is the user input.
|
||||
|
@ -521,20 +562,20 @@ export default createReactClass({
|
|||
}
|
||||
fields[requiredFields[requiredFields.length - 1]] = userInput;
|
||||
return fields;
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* called by the parent component when PageUp/Down/etc is pressed.
|
||||
*
|
||||
* We pass it down to the scroll panel.
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
handleScrollKey = ev => {
|
||||
if (this.scrollPanel) {
|
||||
this.scrollPanel.handleScrollKey(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
@ -610,6 +651,18 @@ export default createReactClass({
|
|||
}
|
||||
}
|
||||
|
||||
let dropdown = (
|
||||
<NetworkDropdown
|
||||
protocols={this.protocols}
|
||||
onOptionChange={this.onOptionChange}
|
||||
selectedServerName={this.state.roomServer}
|
||||
selectedInstanceId={this.state.instanceId}
|
||||
/>
|
||||
);
|
||||
if (this.state.selectedCommunityId) {
|
||||
dropdown = null;
|
||||
}
|
||||
|
||||
listHeader = <div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
|
@ -619,12 +672,7 @@ export default createReactClass({
|
|||
placeholder={placeholder}
|
||||
showJoinButton={showJoinButton}
|
||||
/>
|
||||
<NetworkDropdown
|
||||
protocols={this.protocols}
|
||||
onOptionChange={this.onOptionChange}
|
||||
selectedServerName={this.state.roomServer}
|
||||
selectedInstanceId={this.state.instanceId}
|
||||
/>
|
||||
{dropdown}
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
|
@ -637,12 +685,16 @@ export default createReactClass({
|
|||
}},
|
||||
);
|
||||
|
||||
const title = this.state.selectedCommunityId
|
||||
? _t("Explore rooms in %(communityName)s", {
|
||||
communityName: this.state.communityName || this.state.selectedCommunityId,
|
||||
}) : _t("Explore rooms");
|
||||
return (
|
||||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Explore rooms")}
|
||||
title={title}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
{explanation}
|
||||
|
@ -653,8 +705,8 @@ export default createReactClass({
|
|||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
|
|
|
@ -20,7 +20,6 @@ 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";
|
||||
|
@ -137,7 +136,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
|
||||
let icon = (
|
||||
<div className='mx_RoomSearch_icon'/>
|
||||
<div className='mx_RoomSearch_icon' />
|
||||
);
|
||||
let input = (
|
||||
<input
|
||||
|
@ -166,7 +165,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
icon = (
|
||||
<AccessibleButton
|
||||
title={_t("Search rooms")}
|
||||
className="mx_RoomSearch_icon"
|
||||
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
|
||||
onClick={this.openSearch}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,7 +15,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
|
@ -27,6 +24,7 @@ import Resend from '../../Resend';
|
|||
import dis from '../../dispatcher/dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
|
@ -39,20 +37,20 @@ function getUnsentMessages(room) {
|
|||
});
|
||||
}
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'RoomStatusBar',
|
||||
|
||||
propTypes: {
|
||||
export default class RoomStatusBar extends React.Component {
|
||||
static propTypes = {
|
||||
// the room this statusbar is representing.
|
||||
room: PropTypes.object.isRequired,
|
||||
// This is true when the user is alone in the room, but has also sent a message.
|
||||
// Used to suggest to the user to invite someone
|
||||
sentMessageAndIsAlone: PropTypes.bool,
|
||||
|
||||
// true if there is an active call in this room (means we show
|
||||
// the 'Active Call' text in the status bar if there is nothing
|
||||
// more interesting)
|
||||
hasActiveCall: PropTypes.bool,
|
||||
// The active call in the room, if any (means we show the call bar
|
||||
// along with the status of the call)
|
||||
callState: PropTypes.string,
|
||||
|
||||
// The type of the call in progress, or null if no call is in progress
|
||||
callType: PropTypes.string,
|
||||
|
||||
// true if the room is being peeked at. This affects components that shouldn't
|
||||
// logically be shown when peeking, such as a prompt to invite people to a room.
|
||||
|
@ -86,37 +84,35 @@ export default createReactClass({
|
|||
// callback for when the status bar is displaying something and should
|
||||
// be visible
|
||||
onVisible: PropTypes.func,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
syncStateData: MatrixClientPeg.get().getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
};
|
||||
},
|
||||
state = {
|
||||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
syncStateData: MatrixClientPeg.get().getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
|
||||
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
|
||||
|
||||
this._checkSize();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidUpdate: function() {
|
||||
componentDidUpdate() {
|
||||
this._checkSize();
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) {
|
||||
client.removeListener("sync", this.onSyncStateChange);
|
||||
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onSyncStateChange: function(state, prevState, data) {
|
||||
onSyncStateChange = (state, prevState, data) => {
|
||||
if (state === "SYNCING" && prevState === "SYNCING") {
|
||||
return;
|
||||
}
|
||||
|
@ -124,41 +120,47 @@ export default createReactClass({
|
|||
syncState: state,
|
||||
syncStateData: data,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onResendAllClick: function() {
|
||||
_showCallBar() {
|
||||
return (this.props.callState &&
|
||||
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
|
||||
);
|
||||
}
|
||||
|
||||
_onResendAllClick = () => {
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
};
|
||||
|
||||
_onCancelAllClick: function() {
|
||||
_onCancelAllClick = () => {
|
||||
Resend.cancelUnsentEvents(this.props.room);
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
};
|
||||
|
||||
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
|
||||
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
|
||||
if (room.roomId !== this.props.room.roomId) return;
|
||||
|
||||
this.setState({
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Check whether current size is greater than 0, if yes call props.onVisible
|
||||
_checkSize: function() {
|
||||
_checkSize() {
|
||||
if (this._getSize()) {
|
||||
if (this.props.onVisible) this.props.onVisible();
|
||||
} else {
|
||||
if (this.props.onHidden) this.props.onHidden();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// We don't need the actual height - just whether it is likely to have
|
||||
// changed - so we use '0' to indicate normal size, and other values to
|
||||
// indicate other sizes.
|
||||
_getSize: function() {
|
||||
_getSize() {
|
||||
if (this._shouldShowConnectionError() ||
|
||||
this.props.hasActiveCall ||
|
||||
this._showCallBar() ||
|
||||
this.props.sentMessageAndIsAlone
|
||||
) {
|
||||
return STATUS_BAR_EXPANDED;
|
||||
|
@ -166,11 +168,11 @@ export default createReactClass({
|
|||
return STATUS_BAR_EXPANDED_LARGE;
|
||||
}
|
||||
return STATUS_BAR_HIDDEN;
|
||||
},
|
||||
}
|
||||
|
||||
// return suitable content for the image on the left of the status bar.
|
||||
_getIndicator: function() {
|
||||
if (this.props.hasActiveCall) {
|
||||
_getIndicator() {
|
||||
if (this._showCallBar()) {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
return (
|
||||
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
|
||||
|
@ -182,9 +184,9 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}
|
||||
|
||||
_shouldShowConnectionError: function() {
|
||||
_shouldShowConnectionError() {
|
||||
// no conn bar trumps the "some not sent" msg since you can't resend without
|
||||
// a connection!
|
||||
// There's one situation in which we don't show this 'no connection' bar, and that's
|
||||
|
@ -195,9 +197,9 @@ export default createReactClass({
|
|||
this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED',
|
||||
);
|
||||
return this.state.syncState === "ERROR" && !errorIsMauError;
|
||||
},
|
||||
}
|
||||
|
||||
_getUnsentMessageContent: function() {
|
||||
_getUnsentMessageContent() {
|
||||
const unsentMessages = this.state.unsentMessages;
|
||||
if (!unsentMessages.length) return null;
|
||||
|
||||
|
@ -272,10 +274,29 @@ export default createReactClass({
|
|||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
_getCallStatusText() {
|
||||
switch (this.props.callState) {
|
||||
case CallState.CreateOffer:
|
||||
case CallState.InviteSent:
|
||||
return _t('Calling...');
|
||||
case CallState.Connecting:
|
||||
case CallState.CreateAnswer:
|
||||
return _t('Call connecting...');
|
||||
case CallState.Connected:
|
||||
return _t('Active call');
|
||||
case CallState.WaitLocalMedia:
|
||||
if (this.props.callType === CallType.Video) {
|
||||
return _t('Starting camera...');
|
||||
} else {
|
||||
return _t('Starting microphone...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return suitable content for the main (text) part of the status bar.
|
||||
_getContent: function() {
|
||||
_getContent() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
|
@ -296,10 +317,10 @@ export default createReactClass({
|
|||
return this._getUnsentMessageContent();
|
||||
}
|
||||
|
||||
if (this.props.hasActiveCall) {
|
||||
if (this._showCallBar()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_callBar">
|
||||
<b>{ _t('Active call') }</b>
|
||||
<b>{ this._getCallStatusText() }</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -323,9 +344,9 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const content = this._getContent();
|
||||
const indicator = this._getIndicator();
|
||||
|
||||
|
@ -339,5 +360,5 @@ export default createReactClass({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -15,7 +15,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, {createRef} from "react";
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Key } from '../../Keyboard';
|
||||
import Timer from '../../utils/Timer';
|
||||
|
@ -84,10 +83,8 @@ if (DEBUG_SCROLL) {
|
|||
* offset as normal.
|
||||
*/
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ScrollPanel',
|
||||
|
||||
propTypes: {
|
||||
export default class ScrollPanel extends React.Component {
|
||||
static propTypes = {
|
||||
/* stickyBottom: if set to true, then once the user hits the bottom of
|
||||
* the list, any new children added to the list will cause the list to
|
||||
* scroll down to show the new element, rather than preserving the
|
||||
|
@ -97,7 +94,7 @@ export default createReactClass({
|
|||
|
||||
/* startAtBottom: if set to true, the view is assumed to start
|
||||
* scrolled to the bottom.
|
||||
* XXX: It's likley this is unecessary and can be derived from
|
||||
* XXX: It's likely this is unnecessary and can be derived from
|
||||
* stickyBottom, but I'm adding an extra parameter to ensure
|
||||
* behaviour stays the same for other uses of ScrollPanel.
|
||||
* If so, let's remove this parameter down the line.
|
||||
|
@ -141,6 +138,7 @@ export default createReactClass({
|
|||
/* style: styles to add to the top-level div
|
||||
*/
|
||||
style: PropTypes.object,
|
||||
|
||||
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
|
||||
*/
|
||||
resizeNotifier: PropTypes.object,
|
||||
|
@ -149,36 +147,35 @@ export default createReactClass({
|
|||
* of the wrapper
|
||||
*/
|
||||
fixedChildren: PropTypes.node,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
stickyBottom: true,
|
||||
startAtBottom: true,
|
||||
onFillRequest: function(backwards) { return Promise.resolve(false); },
|
||||
onUnfillRequest: function(backwards, scrollToken) {},
|
||||
onScroll: function() {},
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
stickyBottom: true,
|
||||
startAtBottom: true,
|
||||
onFillRequest: function(backwards) { return Promise.resolve(false); },
|
||||
onUnfillRequest: function(backwards, scrollToken) {},
|
||||
onScroll: function() {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._pendingFillRequests = {b: null, f: null};
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
|
||||
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
|
||||
this.resetScrollState();
|
||||
|
||||
this._itemlist = createRef();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.checkScroll();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidUpdate: function() {
|
||||
componentDidUpdate() {
|
||||
// after adding event tiles, we may need to tweak the scroll (either to
|
||||
// keep at the bottom of the timeline, or to maintain the view after
|
||||
// adding events to the top).
|
||||
|
@ -186,9 +183,9 @@ export default createReactClass({
|
|||
// This will also re-check the fill state, in case the paginate was inadequate
|
||||
this.checkScroll();
|
||||
this.updatePreventShrinking();
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
// set a boolean to say we've been unmounted, which any pending
|
||||
// promises can use to throw away their results.
|
||||
//
|
||||
|
@ -196,51 +193,53 @@ export default createReactClass({
|
|||
this.unmounted = true;
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
|
||||
this.props.resizeNotifier.removeListener("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onScroll: function(ev) {
|
||||
onScroll = ev => {
|
||||
// skip scroll events caused by resizing
|
||||
if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
|
||||
debuglog("onScroll", this._getScrollNode().scrollTop);
|
||||
this._scrollTimeout.restart();
|
||||
this._saveScrollState();
|
||||
this.updatePreventShrinking();
|
||||
this.props.onScroll(ev);
|
||||
this.checkFillState();
|
||||
},
|
||||
};
|
||||
|
||||
onResize: function() {
|
||||
onResize = () => {
|
||||
debuglog("onResize");
|
||||
this.checkScroll();
|
||||
// update preventShrinkingState if present
|
||||
if (this.preventShrinkingState) {
|
||||
this.preventShrinking();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
// where it ought to be, and set off pagination requests if necessary.
|
||||
checkScroll: function() {
|
||||
checkScroll = () => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this._restoreSavedScrollState();
|
||||
this.checkFillState();
|
||||
},
|
||||
};
|
||||
|
||||
// return true if the content is fully scrolled down right now; else false.
|
||||
//
|
||||
// note that this is independent of the 'stuckAtBottom' state - it is simply
|
||||
// about whether the content is scrolled down right now, irrespective of
|
||||
// whether it will stay that way when the children update.
|
||||
isAtBottom: function() {
|
||||
isAtBottom = () => {
|
||||
const sn = this._getScrollNode();
|
||||
// fractional values (both too big and too small)
|
||||
// for scrollTop happen on certain browsers/platforms
|
||||
// when scrolled all the way down. E.g. Chrome 72 on debian.
|
||||
// so check difference <= 1;
|
||||
return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1;
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
// returns the vertical height in the given direction that can be removed from
|
||||
// the content box (which has a height of scrollHeight, see checkFillState) without
|
||||
|
@ -273,7 +272,7 @@ export default createReactClass({
|
|||
// |#########| - |
|
||||
// |#########| |
|
||||
// `---------' -
|
||||
_getExcessHeight: function(backwards) {
|
||||
_getExcessHeight(backwards) {
|
||||
const sn = this._getScrollNode();
|
||||
const contentHeight = this._getMessagesHeight();
|
||||
const listHeight = this._getListHeight();
|
||||
|
@ -285,10 +284,10 @@ export default createReactClass({
|
|||
} else {
|
||||
return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
checkFillState: async function(depth=0) {
|
||||
checkFillState = async (depth=0) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
@ -368,10 +367,10 @@ export default createReactClass({
|
|||
this._fillRequestWhileRunning = false;
|
||||
this.checkFillState();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// check if unfilling is possible and send an unfill request if necessary
|
||||
_checkUnfillState: function(backwards) {
|
||||
_checkUnfillState(backwards) {
|
||||
let excessHeight = this._getExcessHeight(backwards);
|
||||
if (excessHeight <= 0) {
|
||||
return;
|
||||
|
@ -417,10 +416,10 @@ export default createReactClass({
|
|||
this.props.onUnfillRequest(backwards, markerScrollToken);
|
||||
}, UNFILL_REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// check if there is already a pending fill request. If not, set one off.
|
||||
_maybeFill: function(depth, backwards) {
|
||||
_maybeFill(depth, backwards) {
|
||||
const dir = backwards ? 'b' : 'f';
|
||||
if (this._pendingFillRequests[dir]) {
|
||||
debuglog("Already a "+dir+" fill in progress - not starting another");
|
||||
|
@ -456,7 +455,7 @@ export default createReactClass({
|
|||
return this.checkFillState(depth + 1);
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
/* get the current scroll state. This returns an object with the following
|
||||
* properties:
|
||||
|
@ -472,9 +471,7 @@ export default createReactClass({
|
|||
* the number of pixels the bottom of the tracked child is above the
|
||||
* bottom of the scroll panel.
|
||||
*/
|
||||
getScrollState: function() {
|
||||
return this.scrollState;
|
||||
},
|
||||
getScrollState = () => this.scrollState;
|
||||
|
||||
/* reset the saved scroll state.
|
||||
*
|
||||
|
@ -488,7 +485,7 @@ export default createReactClass({
|
|||
* no use if no children exist yet, or if you are about to replace the
|
||||
* child list.)
|
||||
*/
|
||||
resetScrollState: function() {
|
||||
resetScrollState = () => {
|
||||
this.scrollState = {
|
||||
stuckAtBottom: this.props.startAtBottom,
|
||||
};
|
||||
|
@ -496,20 +493,20 @@ export default createReactClass({
|
|||
this._pages = 0;
|
||||
this._scrollTimeout = new Timer(100);
|
||||
this._heightUpdateInProgress = false;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* jump to the top of the content.
|
||||
*/
|
||||
scrollToTop: function() {
|
||||
scrollToTop = () => {
|
||||
this._getScrollNode().scrollTop = 0;
|
||||
this._saveScrollState();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* jump to the bottom of the content.
|
||||
*/
|
||||
scrollToBottom: function() {
|
||||
scrollToBottom = () => {
|
||||
// the easiest way to make sure that the scroll state is correctly
|
||||
// saved is to do the scroll, then save the updated state. (Calculating
|
||||
// it ourselves is hard, and we can't rely on an onScroll callback
|
||||
|
@ -517,25 +514,25 @@ export default createReactClass({
|
|||
const sn = this._getScrollNode();
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
this._saveScrollState();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Page up/down.
|
||||
*
|
||||
* @param {number} mult: -1 to page up, +1 to page down
|
||||
*/
|
||||
scrollRelative: function(mult) {
|
||||
scrollRelative = mult => {
|
||||
const scrollNode = this._getScrollNode();
|
||||
const delta = mult * scrollNode.clientHeight * 0.5;
|
||||
scrollNode.scrollBy(0, delta);
|
||||
this._saveScrollState();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll up/down in response to a scroll key
|
||||
* @param {object} ev the keyboard event
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
handleScrollKey = ev => {
|
||||
switch (ev.key) {
|
||||
case Key.PAGE_UP:
|
||||
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
|
@ -561,7 +558,7 @@ export default createReactClass({
|
|||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/* Scroll the panel to bring the DOM node with the scroll token
|
||||
* `scrollToken` into view.
|
||||
|
@ -574,7 +571,7 @@ export default createReactClass({
|
|||
* node (specifically, the bottom of it) will be positioned. If omitted, it
|
||||
* defaults to 0.
|
||||
*/
|
||||
scrollToToken: function(scrollToken, pixelOffset, offsetBase) {
|
||||
scrollToToken = (scrollToken, pixelOffset, offsetBase) => {
|
||||
pixelOffset = pixelOffset || 0;
|
||||
offsetBase = offsetBase || 0;
|
||||
|
||||
|
@ -596,9 +593,9 @@ export default createReactClass({
|
|||
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
|
||||
this._saveScrollState();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_saveScrollState: function() {
|
||||
_saveScrollState() {
|
||||
if (this.props.stickyBottom && this.isAtBottom()) {
|
||||
this.scrollState = { stuckAtBottom: true };
|
||||
debuglog("saved stuckAtBottom state");
|
||||
|
@ -641,9 +638,9 @@ export default createReactClass({
|
|||
bottomOffset: bottomOffset,
|
||||
pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
_restoreSavedScrollState: async function() {
|
||||
async _restoreSavedScrollState() {
|
||||
const scrollState = this.scrollState;
|
||||
|
||||
if (scrollState.stuckAtBottom) {
|
||||
|
@ -676,7 +673,8 @@ export default createReactClass({
|
|||
} else {
|
||||
debuglog("not updating height because request already in progress");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
|
||||
async _updateHeight() {
|
||||
// wait until user has stopped scrolling
|
||||
|
@ -731,7 +729,7 @@ export default createReactClass({
|
|||
debuglog("updateHeight to", {newHeight, topDiff});
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_getTrackedNode() {
|
||||
const scrollState = this.scrollState;
|
||||
|
@ -764,11 +762,11 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return scrollState.trackedNode;
|
||||
},
|
||||
}
|
||||
|
||||
_getListHeight() {
|
||||
return this._bottomGrowth + (this._pages * PAGE_SIZE);
|
||||
},
|
||||
}
|
||||
|
||||
_getMessagesHeight() {
|
||||
const itemlist = this._itemlist.current;
|
||||
|
@ -777,17 +775,17 @@ export default createReactClass({
|
|||
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
|
||||
// 18 is itemlist padding
|
||||
return lastNodeBottom - firstNodeTop + (18 * 2);
|
||||
},
|
||||
}
|
||||
|
||||
_topFromBottom(node) {
|
||||
// current capped height - distance from top = distance from bottom of container to top of tracked element
|
||||
return this._itemlist.current.clientHeight - node.offsetTop;
|
||||
},
|
||||
}
|
||||
|
||||
/* get the DOM node which has the scrollTop property we care about for our
|
||||
* message panel.
|
||||
*/
|
||||
_getScrollNode: function() {
|
||||
_getScrollNode() {
|
||||
if (this.unmounted) {
|
||||
// this shouldn't happen, but when it does, turn the NPE into
|
||||
// something more meaningful.
|
||||
|
@ -801,18 +799,18 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return this._divScroll;
|
||||
},
|
||||
}
|
||||
|
||||
_collectScroll: function(divScroll) {
|
||||
_collectScroll = divScroll => {
|
||||
this._divScroll = divScroll;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
Mark the bottom offset of the last tile so we can balance it out when
|
||||
anything below it changes, by calling updatePreventShrinking, to keep
|
||||
the same minimum bottom offset, effectively preventing the timeline to shrink.
|
||||
*/
|
||||
preventShrinking: function() {
|
||||
preventShrinking = () => {
|
||||
const messageList = this._itemlist.current;
|
||||
const tiles = messageList && messageList.children;
|
||||
if (!messageList) {
|
||||
|
@ -836,16 +834,16 @@ export default createReactClass({
|
|||
offsetNode: lastTileNode,
|
||||
};
|
||||
debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom");
|
||||
},
|
||||
};
|
||||
|
||||
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
|
||||
clearPreventShrinking: function() {
|
||||
clearPreventShrinking = () => {
|
||||
const messageList = this._itemlist.current;
|
||||
const balanceElement = messageList && messageList.parentElement;
|
||||
if (balanceElement) balanceElement.style.paddingBottom = null;
|
||||
this.preventShrinkingState = null;
|
||||
debuglog("prevent shrinking cleared");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
update the container padding to balance
|
||||
|
@ -855,7 +853,7 @@ export default createReactClass({
|
|||
from the bottom of the marked tile grows larger than
|
||||
what it was when marking.
|
||||
*/
|
||||
updatePreventShrinking: function() {
|
||||
updatePreventShrinking = () => {
|
||||
if (this.preventShrinkingState) {
|
||||
const sn = this._getScrollNode();
|
||||
const scrollState = this.scrollState;
|
||||
|
@ -885,9 +883,9 @@ export default createReactClass({
|
|||
this.clearPreventShrinking();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
// TODO: the classnames on the div and ol could do with being updated to
|
||||
// reflect the fact that we don't necessarily contain a list of messages.
|
||||
// it's not obvious why we have a separate div and ol anyway.
|
||||
|
@ -905,5 +903,5 @@ export default createReactClass({
|
|||
</div>
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,18 +16,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Key } from '../../Keyboard';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { throttle } from 'lodash';
|
||||
import {throttle} from 'lodash';
|
||||
import AccessibleButton from '../../components/views/elements/AccessibleButton';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'SearchBox',
|
||||
|
||||
propTypes: {
|
||||
export default class SearchBox extends React.Component {
|
||||
static propTypes = {
|
||||
onSearch: PropTypes.func,
|
||||
onCleared: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
|
@ -38,35 +35,32 @@ export default createReactClass({
|
|||
// on room search focus action (it would be nicer to take
|
||||
// this functionality out, but not obvious how that would work)
|
||||
enableRoomSearchFocus: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
enableRoomSearchFocus: false,
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
enableRoomSearchFocus: false,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._search = createRef();
|
||||
|
||||
this.state = {
|
||||
searchTerm: "",
|
||||
blurred: true,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._search = createRef();
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
}
|
||||
|
||||
onAction: function(payload) {
|
||||
onAction = payload => {
|
||||
if (!this.props.enableRoomSearchFocus) return;
|
||||
|
||||
switch (payload.action) {
|
||||
|
@ -81,51 +75,51 @@ export default createReactClass({
|
|||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onChange: function() {
|
||||
onChange = () => {
|
||||
if (!this._search.current) return;
|
||||
this.setState({ searchTerm: this._search.current.value });
|
||||
this.onSearch();
|
||||
},
|
||||
};
|
||||
|
||||
onSearch: throttle(function() {
|
||||
onSearch = throttle(() => {
|
||||
this.props.onSearch(this._search.current.value);
|
||||
}, 200, {trailing: true, leading: true}),
|
||||
}, 200, {trailing: true, leading: true});
|
||||
|
||||
_onKeyDown: function(ev) {
|
||||
_onKeyDown = ev => {
|
||||
switch (ev.key) {
|
||||
case Key.ESCAPE:
|
||||
this._clearSearch("keyboard");
|
||||
break;
|
||||
}
|
||||
if (this.props.onKeyDown) this.props.onKeyDown(ev);
|
||||
},
|
||||
};
|
||||
|
||||
_onFocus: function(ev) {
|
||||
_onFocus = ev => {
|
||||
this.setState({blurred: false});
|
||||
ev.target.select();
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onBlur: function(ev) {
|
||||
_onBlur = ev => {
|
||||
this.setState({blurred: true});
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_clearSearch: function(source) {
|
||||
_clearSearch(source) {
|
||||
this._search.current.value = "";
|
||||
this.onChange();
|
||||
if (this.props.onCleared) {
|
||||
this.props.onCleared(source);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
// check for collapsed here and
|
||||
// not at parent so we keep
|
||||
// searchTerm in our state
|
||||
|
@ -166,5 +160,5 @@ export default createReactClass({
|
|||
{ clearButton }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ limitations under the License.
|
|||
|
||||
import * as React from "react";
|
||||
import {_t} from '../../languageHandler';
|
||||
import * as PropTypes from "prop-types";
|
||||
import * as sdk from "../../index";
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { ReactNode } from "react";
|
||||
|
|
|
@ -19,7 +19,6 @@ limitations under the License.
|
|||
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import ReactDOM from "react-dom";
|
||||
import PropTypes from 'prop-types';
|
||||
import {EventTimeline} from "matrix-js-sdk";
|
||||
|
@ -36,6 +35,7 @@ import Timer from '../../utils/Timer';
|
|||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
||||
import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import {UIFeature} from "../../settings/UIFeature";
|
||||
|
||||
const PAGINATE_SIZE = 20;
|
||||
const INITIAL_SIZE = 20;
|
||||
|
@ -54,10 +54,8 @@ if (DEBUG) {
|
|||
*
|
||||
* Also responsible for handling and sending read receipts.
|
||||
*/
|
||||
const TimelinePanel = createReactClass({
|
||||
displayName: 'TimelinePanel',
|
||||
|
||||
propTypes: {
|
||||
class TimelinePanel extends React.Component {
|
||||
static propTypes = {
|
||||
// The js-sdk EventTimelineSet object for the timeline sequence we are
|
||||
// representing. This may or may not have a room, depending on what it's
|
||||
// a timeline representing. If it has a room, we maintain RRs etc for
|
||||
|
@ -107,31 +105,36 @@ const TimelinePanel = createReactClass({
|
|||
// shape property to be passed to EventTiles
|
||||
tileShape: PropTypes.string,
|
||||
|
||||
// placeholder text to use if the timeline is empty
|
||||
empty: PropTypes.string,
|
||||
// placeholder to use if the timeline is empty
|
||||
empty: PropTypes.node,
|
||||
|
||||
// whether to show reactions for an event
|
||||
showReactions: PropTypes.bool,
|
||||
|
||||
// whether to use the irc layout
|
||||
useIRCLayout: PropTypes.bool,
|
||||
},
|
||||
}
|
||||
|
||||
statics: {
|
||||
// a map from room id to read marker event timestamp
|
||||
roomReadMarkerTsMap: {},
|
||||
},
|
||||
// a map from room id to read marker event timestamp
|
||||
static roomReadMarkerTsMap = {};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
// By default, disable the timelineCap in favour of unpaginating based on
|
||||
// event tile heights. (See _unpaginateEvents)
|
||||
timelineCap: Number.MAX_VALUE,
|
||||
className: 'mx_RoomView_messagePanel',
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
// By default, disable the timelineCap in favour of unpaginating based on
|
||||
// event tile heights. (See _unpaginateEvents)
|
||||
timelineCap: Number.MAX_VALUE,
|
||||
className: 'mx_RoomView_messagePanel',
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
debuglog("TimelinePanel: mounting");
|
||||
|
||||
this.lastRRSentEventId = undefined;
|
||||
this.lastRMSentEventId = undefined;
|
||||
|
||||
this._messagePanel = createRef();
|
||||
|
||||
getInitialState: function() {
|
||||
// XXX: we could track RM per TimelineSet rather than per Room.
|
||||
// but for now we just do it per room for simplicity.
|
||||
let initialReadMarker = null;
|
||||
|
@ -144,7 +147,7 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
this.state = {
|
||||
events: [],
|
||||
liveEvents: [],
|
||||
timelineLoading: true, // track whether our room timeline is loading
|
||||
|
@ -203,24 +206,6 @@ const TimelinePanel = createReactClass({
|
|||
// how long to show the RM for when it's scrolled off-screen
|
||||
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
debuglog("TimelinePanel: mounting");
|
||||
|
||||
this.lastRRSentEventId = undefined;
|
||||
this.lastRMSentEventId = undefined;
|
||||
|
||||
this._messagePanel = createRef();
|
||||
|
||||
if (this.props.manageReadReceipts) {
|
||||
this.updateReadReceiptOnUserActivity();
|
||||
}
|
||||
if (this.props.manageReadMarkers) {
|
||||
this.updateReadMarkerOnUserActivity();
|
||||
}
|
||||
|
||||
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
|
||||
|
@ -234,12 +219,24 @@ const TimelinePanel = createReactClass({
|
|||
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
|
||||
MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced);
|
||||
MatrixClientPeg.get().on("sync", this.onSync);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Move into constructor
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillMount() {
|
||||
if (this.props.manageReadReceipts) {
|
||||
this.updateReadReceiptOnUserActivity();
|
||||
}
|
||||
if (this.props.manageReadMarkers) {
|
||||
this.updateReadMarkerOnUserActivity();
|
||||
}
|
||||
|
||||
this._initTimeline(this.props);
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(newProps) {
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.timelineSet !== this.props.timelineSet) {
|
||||
// throw new Error("changing timelineSet on a TimelinePanel is not supported");
|
||||
|
||||
|
@ -260,9 +257,9 @@ const TimelinePanel = createReactClass({
|
|||
" (was " + this.props.eventId + ")");
|
||||
return this._initTimeline(newProps);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
shouldComponentUpdate: function(nextProps, nextState) {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
if (!ObjectUtils.shallowEqual(this.props, nextProps)) {
|
||||
if (DEBUG) {
|
||||
console.group("Timeline.shouldComponentUpdate: props change");
|
||||
|
@ -284,9 +281,9 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
// set a boolean to say we've been unmounted, which any pending
|
||||
// promises can use to throw away their results.
|
||||
//
|
||||
|
@ -316,9 +313,9 @@ const TimelinePanel = createReactClass({
|
|||
client.removeListener("Event.replaced", this.onEventReplaced);
|
||||
client.removeListener("sync", this.onSync);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onMessageListUnfillRequest: function(backwards, scrollToken) {
|
||||
onMessageListUnfillRequest = (backwards, scrollToken) => {
|
||||
// If backwards, unpaginate from the back (i.e. the start of the timeline)
|
||||
const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
|
||||
debuglog("TimelinePanel: unpaginating events in direction", dir);
|
||||
|
@ -349,18 +346,18 @@ const TimelinePanel = createReactClass({
|
|||
firstVisibleEventIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onPaginationRequest(timelineWindow, direction, size) {
|
||||
onPaginationRequest = (timelineWindow, direction, size) => {
|
||||
if (this.props.onPaginationRequest) {
|
||||
return this.props.onPaginationRequest(timelineWindow, direction, size);
|
||||
} else {
|
||||
return timelineWindow.paginate(direction, size);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// set off a pagination request.
|
||||
onMessageListFillRequest: function(backwards) {
|
||||
onMessageListFillRequest = backwards => {
|
||||
if (!this._shouldPaginate()) return Promise.resolve(false);
|
||||
|
||||
const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
|
||||
|
@ -425,9 +422,9 @@ const TimelinePanel = createReactClass({
|
|||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onMessageListScroll: function(e) {
|
||||
onMessageListScroll = e => {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(e);
|
||||
}
|
||||
|
@ -447,9 +444,9 @@ const TimelinePanel = createReactClass({
|
|||
// NO-OP when timeout already has set to the given value
|
||||
this._readMarkerActivityTimer.changeTimeout(timeout);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onAction: function(payload) {
|
||||
onAction = payload => {
|
||||
if (payload.action === 'ignore_state_changed') {
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
@ -463,9 +460,9 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
|
||||
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
|
||||
// ignore events for other timeline sets
|
||||
if (data.timeline.getTimelineSet() !== this.props.timelineSet) return;
|
||||
|
||||
|
@ -537,21 +534,19 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onRoomTimelineReset: function(room, timelineSet) {
|
||||
onRoomTimelineReset = (room, timelineSet) => {
|
||||
if (timelineSet !== this.props.timelineSet) return;
|
||||
|
||||
if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) {
|
||||
this._loadTimeline();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
canResetTimeline: function() {
|
||||
return this._messagePanel.current && this._messagePanel.current.isAtBottom();
|
||||
},
|
||||
canResetTimeline = () => this._messagePanel.current && this._messagePanel.current.isAtBottom();
|
||||
|
||||
onRoomRedaction: function(ev, room) {
|
||||
onRoomRedaction = (ev, room) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
|
@ -560,9 +555,9 @@ const TimelinePanel = createReactClass({
|
|||
// we could skip an update if the event isn't in our timeline,
|
||||
// but that's probably an early optimisation.
|
||||
this.forceUpdate();
|
||||
},
|
||||
};
|
||||
|
||||
onEventReplaced: function(replacedEvent, room) {
|
||||
onEventReplaced = (replacedEvent, room) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
|
@ -571,27 +566,27 @@ const TimelinePanel = createReactClass({
|
|||
// we could skip an update if the event isn't in our timeline,
|
||||
// but that's probably an early optimisation.
|
||||
this.forceUpdate();
|
||||
},
|
||||
};
|
||||
|
||||
onRoomReceipt: function(ev, room) {
|
||||
onRoomReceipt = (ev, room) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
if (room !== this.props.timelineSet.room) return;
|
||||
|
||||
this.forceUpdate();
|
||||
},
|
||||
};
|
||||
|
||||
onLocalEchoUpdated: function(ev, room, oldEventId) {
|
||||
onLocalEchoUpdated = (ev, room, oldEventId) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
if (room !== this.props.timelineSet.room) return;
|
||||
|
||||
this._reloadEvents();
|
||||
},
|
||||
};
|
||||
|
||||
onAccountData: function(ev, room) {
|
||||
onAccountData = (ev, room) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
|
@ -605,9 +600,9 @@ const TimelinePanel = createReactClass({
|
|||
this.setState({
|
||||
readMarkerEventId: ev.getContent().event_id,
|
||||
}, this.props.onReadMarkerUpdated);
|
||||
},
|
||||
};
|
||||
|
||||
onEventDecrypted: function(ev) {
|
||||
onEventDecrypted = ev => {
|
||||
// Can be null for the notification timeline, etc.
|
||||
if (!this.props.timelineSet.room) return;
|
||||
|
||||
|
@ -620,19 +615,19 @@ const TimelinePanel = createReactClass({
|
|||
if (ev.getRoomId() === this.props.timelineSet.room.roomId) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onSync: function(state, prevState, data) {
|
||||
onSync = (state, prevState, data) => {
|
||||
this.setState({clientSyncState: state});
|
||||
},
|
||||
};
|
||||
|
||||
_readMarkerTimeout(readMarkerPosition) {
|
||||
return readMarkerPosition === 0 ?
|
||||
this.state.readMarkerInViewThresholdMs :
|
||||
this.state.readMarkerOutOfViewThresholdMs;
|
||||
},
|
||||
}
|
||||
|
||||
updateReadMarkerOnUserActivity: async function() {
|
||||
async updateReadMarkerOnUserActivity() {
|
||||
const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition());
|
||||
this._readMarkerActivityTimer = new Timer(initialTimeout);
|
||||
|
||||
|
@ -644,9 +639,9 @@ const TimelinePanel = createReactClass({
|
|||
// outside of try/catch to not swallow errors
|
||||
this.updateReadMarker();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
updateReadReceiptOnUserActivity: async function() {
|
||||
async updateReadReceiptOnUserActivity() {
|
||||
this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
|
||||
while (this._readReceiptActivityTimer) { //unset on unmount
|
||||
UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer);
|
||||
|
@ -656,9 +651,9 @@ const TimelinePanel = createReactClass({
|
|||
// outside of try/catch to not swallow errors
|
||||
this.sendReadReceipt();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
sendReadReceipt: function() {
|
||||
sendReadReceipt = () => {
|
||||
if (SettingsStore.getValue("lowBandwidth")) return;
|
||||
|
||||
if (!this._messagePanel.current) return;
|
||||
|
@ -766,11 +761,11 @@ const TimelinePanel = createReactClass({
|
|||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// if the read marker is on the screen, we can now assume we've caught up to the end
|
||||
// of the screen, so move the marker down to the bottom of the screen.
|
||||
updateReadMarker: function() {
|
||||
updateReadMarker = () => {
|
||||
if (!this.props.manageReadMarkers) return;
|
||||
if (this.getReadMarkerPosition() === 1) {
|
||||
// the read marker is at an event below the viewport,
|
||||
|
@ -801,11 +796,11 @@ const TimelinePanel = createReactClass({
|
|||
|
||||
// Send the updated read marker (along with read receipt) to the server
|
||||
this.sendReadReceipt();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// advance the read marker past any events we sent ourselves.
|
||||
_advanceReadMarkerPastMyEvents: function() {
|
||||
_advanceReadMarkerPastMyEvents() {
|
||||
if (!this.props.manageReadMarkers) return;
|
||||
|
||||
// we call `_timelineWindow.getEvents()` rather than using
|
||||
|
@ -837,11 +832,11 @@ const TimelinePanel = createReactClass({
|
|||
|
||||
const ev = events[i];
|
||||
this._setReadMarker(ev.getId(), ev.getTs());
|
||||
},
|
||||
}
|
||||
|
||||
/* jump down to the bottom of this room, where new events are arriving
|
||||
*/
|
||||
jumpToLiveTimeline: function() {
|
||||
jumpToLiveTimeline = () => {
|
||||
// if we can't forward-paginate the existing timeline, then there
|
||||
// is no point reloading it - just jump straight to the bottom.
|
||||
//
|
||||
|
@ -854,12 +849,12 @@ const TimelinePanel = createReactClass({
|
|||
this._messagePanel.current.scrollToBottom();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/* scroll to show the read-up-to marker. We put it 1/3 of the way down
|
||||
* the container.
|
||||
*/
|
||||
jumpToReadMarker: function() {
|
||||
jumpToReadMarker = () => {
|
||||
if (!this.props.manageReadMarkers) return;
|
||||
if (!this._messagePanel.current) return;
|
||||
if (!this.state.readMarkerEventId) return;
|
||||
|
@ -883,11 +878,11 @@ const TimelinePanel = createReactClass({
|
|||
// As with jumpToLiveTimeline, we want to reload the timeline around the
|
||||
// read-marker.
|
||||
this._loadTimeline(this.state.readMarkerEventId, 0, 1/3);
|
||||
},
|
||||
};
|
||||
|
||||
/* update the read-up-to marker to match the read receipt
|
||||
*/
|
||||
forgetReadMarker: function() {
|
||||
forgetReadMarker = () => {
|
||||
if (!this.props.manageReadMarkers) return;
|
||||
|
||||
const rmId = this._getCurrentReadReceipt();
|
||||
|
@ -903,17 +898,17 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
|
||||
this._setReadMarker(rmId, rmTs);
|
||||
},
|
||||
};
|
||||
|
||||
/* return true if the content is fully scrolled down and we are
|
||||
* at the end of the live timeline.
|
||||
*/
|
||||
isAtEndOfLiveTimeline: function() {
|
||||
isAtEndOfLiveTimeline = () => {
|
||||
return this._messagePanel.current
|
||||
&& this._messagePanel.current.isAtBottom()
|
||||
&& this._timelineWindow
|
||||
&& !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
/* get the current scroll state. See ScrollPanel.getScrollState for
|
||||
|
@ -921,10 +916,10 @@ const TimelinePanel = createReactClass({
|
|||
*
|
||||
* returns null if we are not mounted.
|
||||
*/
|
||||
getScrollState: function() {
|
||||
getScrollState = () => {
|
||||
if (!this._messagePanel.current) { return null; }
|
||||
return this._messagePanel.current.getScrollState();
|
||||
},
|
||||
};
|
||||
|
||||
// returns one of:
|
||||
//
|
||||
|
@ -932,7 +927,7 @@ const TimelinePanel = createReactClass({
|
|||
// -1: read marker is above the window
|
||||
// 0: read marker is visible
|
||||
// +1: read marker is below the window
|
||||
getReadMarkerPosition: function() {
|
||||
getReadMarkerPosition = () => {
|
||||
if (!this.props.manageReadMarkers) return null;
|
||||
if (!this._messagePanel.current) return null;
|
||||
|
||||
|
@ -953,9 +948,9 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
canJumpToReadMarker: function() {
|
||||
canJumpToReadMarker = () => {
|
||||
// 1. Do not show jump bar if neither the RM nor the RR are set.
|
||||
// 3. We want to show the bar if the read-marker is off the top of the screen.
|
||||
// 4. Also, if pos === null, the event might not be paginated - show the unread bar
|
||||
|
@ -963,14 +958,14 @@ const TimelinePanel = createReactClass({
|
|||
const ret = this.state.readMarkerEventId !== null && // 1.
|
||||
(pos < 0 || pos === null); // 3., 4.
|
||||
return ret;
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* called by the parent component when PageUp/Down/etc is pressed.
|
||||
*
|
||||
* We pass it down to the scroll panel.
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
handleScrollKey = ev => {
|
||||
if (!this._messagePanel.current) { return; }
|
||||
|
||||
// jump to the live timeline on ctrl-end, rather than the end of the
|
||||
|
@ -980,9 +975,9 @@ const TimelinePanel = createReactClass({
|
|||
} else {
|
||||
this._messagePanel.current.handleScrollKey(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_initTimeline: function(props) {
|
||||
_initTimeline(props) {
|
||||
const initialEvent = props.eventId;
|
||||
const pixelOffset = props.eventPixelOffset;
|
||||
|
||||
|
@ -994,7 +989,7 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
|
||||
return this._loadTimeline(initialEvent, pixelOffset, offsetBase);
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* (re)-load the event timeline, and initialise the scroll state, centered
|
||||
|
@ -1012,7 +1007,7 @@ const TimelinePanel = createReactClass({
|
|||
*
|
||||
* returns a promise which will resolve when the load completes.
|
||||
*/
|
||||
_loadTimeline: function(eventId, pixelOffset, offsetBase) {
|
||||
_loadTimeline(eventId, pixelOffset, offsetBase) {
|
||||
this._timelineWindow = new Matrix.TimelineWindow(
|
||||
MatrixClientPeg.get(), this.props.timelineSet,
|
||||
{windowLimit: this.props.timelineCap});
|
||||
|
@ -1122,21 +1117,21 @@ const TimelinePanel = createReactClass({
|
|||
});
|
||||
prom.then(onLoaded, onError);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// handle the completion of a timeline load or localEchoUpdate, by
|
||||
// reloading the events from the timelinewindow and pending event list into
|
||||
// the state.
|
||||
_reloadEvents: function() {
|
||||
_reloadEvents() {
|
||||
// we might have switched rooms since the load started - just bin
|
||||
// the results if so.
|
||||
if (this.unmounted) return;
|
||||
|
||||
this.setState(this._getEvents());
|
||||
},
|
||||
}
|
||||
|
||||
// get the list of events from the timeline window and the pending event list
|
||||
_getEvents: function() {
|
||||
_getEvents() {
|
||||
const events = this._timelineWindow.getEvents();
|
||||
const firstVisibleEventIndex = this._checkForPreJoinUISI(events);
|
||||
|
||||
|
@ -1154,7 +1149,7 @@ const TimelinePanel = createReactClass({
|
|||
liveEvents,
|
||||
firstVisibleEventIndex,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for undecryptable messages that were sent while the user was not in
|
||||
|
@ -1166,7 +1161,7 @@ const TimelinePanel = createReactClass({
|
|||
* undecryptable event that was sent while the user was not in the room. If no
|
||||
* such events were found, then it returns 0.
|
||||
*/
|
||||
_checkForPreJoinUISI: function(events) {
|
||||
_checkForPreJoinUISI(events) {
|
||||
const room = this.props.timelineSet.room;
|
||||
|
||||
if (events.length === 0 || !room ||
|
||||
|
@ -1228,18 +1223,18 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
}
|
||||
|
||||
_indexForEventId: function(evId) {
|
||||
_indexForEventId(evId) {
|
||||
for (let i = 0; i < this.state.events.length; ++i) {
|
||||
if (evId == this.state.events[i].getId()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}
|
||||
|
||||
_getLastDisplayedEventIndex: function(opts) {
|
||||
_getLastDisplayedEventIndex(opts) {
|
||||
opts = opts || {};
|
||||
const ignoreOwn = opts.ignoreOwn || false;
|
||||
const allowPartial = opts.allowPartial || false;
|
||||
|
@ -1313,7 +1308,7 @@ const TimelinePanel = createReactClass({
|
|||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the id of the event corresponding to our user's latest read-receipt.
|
||||
|
@ -1324,7 +1319,7 @@ const TimelinePanel = createReactClass({
|
|||
* SDK.
|
||||
* @return {String} the event ID
|
||||
*/
|
||||
_getCurrentReadReceipt: function(ignoreSynthesized) {
|
||||
_getCurrentReadReceipt(ignoreSynthesized) {
|
||||
const client = MatrixClientPeg.get();
|
||||
// the client can be null on logout
|
||||
if (client == null) {
|
||||
|
@ -1333,9 +1328,9 @@ const TimelinePanel = createReactClass({
|
|||
|
||||
const myUserId = client.credentials.userId;
|
||||
return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized);
|
||||
},
|
||||
}
|
||||
|
||||
_setReadMarker: function(eventId, eventTs, inhibitSetState) {
|
||||
_setReadMarker(eventId, eventTs, inhibitSetState) {
|
||||
const roomId = this.props.timelineSet.room.roomId;
|
||||
|
||||
// don't update the state (and cause a re-render) if there is
|
||||
|
@ -1358,9 +1353,9 @@ const TimelinePanel = createReactClass({
|
|||
this.setState({
|
||||
readMarkerEventId: eventId,
|
||||
}, this.props.onReadMarkerUpdated);
|
||||
},
|
||||
}
|
||||
|
||||
_shouldPaginate: function() {
|
||||
_shouldPaginate() {
|
||||
// don't try to paginate while events in the timeline are
|
||||
// still being decrypted. We don't render events while they're
|
||||
// being decrypted, so they don't take up space in the timeline.
|
||||
|
@ -1369,13 +1364,11 @@ const TimelinePanel = createReactClass({
|
|||
return !this.state.events.some((e) => {
|
||||
return e.isBeingDecrypted();
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
getRelationsForEvent(...args) {
|
||||
return this.props.timelineSet.getRelationsForEvent(...args);
|
||||
},
|
||||
getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args);
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const MessagePanel = sdk.getComponent("structures.MessagePanel");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
||||
|
@ -1454,9 +1447,10 @@ const TimelinePanel = createReactClass({
|
|||
editState={this.state.editState}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default TimelinePanel;
|
||||
|
|
|
@ -16,30 +16,28 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import filesize from "filesize";
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'UploadBar',
|
||||
propTypes: {
|
||||
export default class UploadBar extends React.Component {
|
||||
static propTypes = {
|
||||
room: PropTypes.object,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.mounted = true;
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
}
|
||||
|
||||
onAction: function(payload) {
|
||||
onAction = payload => {
|
||||
switch (payload.action) {
|
||||
case 'upload_progress':
|
||||
case 'upload_finished':
|
||||
|
@ -48,9 +46,9 @@ export default createReactClass({
|
|||
if (this.mounted) this.forceUpdate();
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
|
||||
|
||||
// for testing UI... - also fix up the ContentMessages.getCurrentUploads().length
|
||||
|
@ -105,5 +103,5 @@ export default createReactClass({
|
|||
<div className="mx_UploadBar_uploadFilename">{ uploadText }</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,8 +40,17 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
|||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
||||
import * as fbEmitter from "fbemitter";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import { showCommunityInviteDialog } from "../../RoomInvite";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog";
|
||||
import {UIFeature} from "../../settings/UIFeature";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
|
@ -58,6 +67,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
private tagStoreRef: fbEmitter.EventSubscription;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -77,14 +87,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
public componentDidMount() {
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
|
||||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
this.tagStoreRef.remove();
|
||||
}
|
||||
|
||||
private onTagStoreUpdate = () => {
|
||||
this.forceUpdate(); // we don't have anything useful in state to update
|
||||
};
|
||||
|
||||
private isUserOnDarkTheme(): boolean {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
|
@ -189,9 +205,54 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
defaultDispatcher.dispatch({action: 'view_home_page'});
|
||||
};
|
||||
|
||||
private onCommunitySettingsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, {
|
||||
communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(),
|
||||
});
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onCommunityMembersClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// We'd ideally just pop open a right panel with the member list, but the current
|
||||
// way the right panel is structured makes this exceedingly difficult. Instead, we'll
|
||||
// switch to the general room and open the member list there as it should be in sync
|
||||
// anyways.
|
||||
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
|
||||
if (chat) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: chat.roomId,
|
||||
}, true);
|
||||
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList});
|
||||
} else {
|
||||
// "This should never happen" clauses go here for the prototype.
|
||||
Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, {
|
||||
title: _t('Failed to find the general chat for this community'),
|
||||
description: _t("Failed to find the general chat for this community"),
|
||||
});
|
||||
}
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onCommunityInviteClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private renderContextMenu = (): React.ReactNode => {
|
||||
if (!this.state.contextMenuPosition) return null;
|
||||
|
||||
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
|
||||
let hostingLink;
|
||||
const signupLink = getHostingLink("user-context-menu");
|
||||
if (signupLink) {
|
||||
|
@ -225,22 +286,151 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
let feedbackButton;
|
||||
if (SettingsStore.getValue(UIFeature.Feedback)) {
|
||||
feedbackButton = <IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("Feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>;
|
||||
}
|
||||
|
||||
let primaryHeader = (
|
||||
<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>
|
||||
);
|
||||
let primaryOptionList = (
|
||||
<React.Fragment>
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("Security & privacy")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{/* <IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/> */}
|
||||
{ feedbackButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOptionList red>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</React.Fragment>
|
||||
);
|
||||
let secondarySection = null;
|
||||
|
||||
if (prototypeCommunityName) {
|
||||
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||
primaryHeader = (
|
||||
<div className="mx_UserMenu_contextMenu_name">
|
||||
<span className="mx_UserMenu_contextMenu_displayName">
|
||||
{prototypeCommunityName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
let settingsOption;
|
||||
let inviteOption;
|
||||
if (CommunityPrototypeStore.instance.canInviteTo(communityId)) {
|
||||
inviteOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconInvite"
|
||||
label={_t("Invite")}
|
||||
onClick={this.onCommunityInviteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
|
||||
settingsOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("Settings")}
|
||||
aria-label={_t("Community settings")}
|
||||
onClick={this.onCommunitySettingsClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
primaryOptionList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{settingsOption}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconMembers"
|
||||
label={_t("Members")}
|
||||
onClick={this.onCommunityMembersClick}
|
||||
/>
|
||||
{inviteOption}
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
secondarySection = (
|
||||
<React.Fragment>
|
||||
<hr />
|
||||
<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>
|
||||
</div>
|
||||
<IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("Settings")}
|
||||
aria-label={_t("User settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{ feedbackButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOptionList red>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
"mx_UserMenu_contextMenu": true,
|
||||
"mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName,
|
||||
});
|
||||
|
||||
return <IconizedContextMenu
|
||||
// -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}
|
||||
// numerical adjustments to overlap the context menu by just over the width of the
|
||||
// menu icon and make it look connected
|
||||
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 10}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height + 8}
|
||||
onFinished={this.onCloseMenu}
|
||||
className="mx_UserMenu_contextMenu"
|
||||
className={classes}
|
||||
>
|
||||
<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>
|
||||
{primaryHeader}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
|
@ -254,53 +444,45 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{hostingLink}
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("Security & privacy")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{/* <IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/> */}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("Feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOptionList red>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
{primaryOptionList}
|
||||
{secondarySection}
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
public render() {
|
||||
const avatarSize = 32; // should match border-radius of the avatar
|
||||
|
||||
let name = <span className="mx_UserMenu_userName">{OwnProfileStore.instance.displayName}</span>;
|
||||
const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId();
|
||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
|
||||
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
|
||||
let isPrototype = false;
|
||||
let menuName = _t("User menu");
|
||||
let name = <span className="mx_UserMenu_userName">{displayName}</span>;
|
||||
let buttons = (
|
||||
<span className="mx_UserMenu_headerButtons">
|
||||
{/* masked image in CSS */}
|
||||
</span>
|
||||
);
|
||||
if (prototypeCommunityName) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{prototypeCommunityName}</span>
|
||||
<span className="mx_UserMenu_subUserName">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
menuName = _t("Community and user menu");
|
||||
isPrototype = true;
|
||||
} else if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{_t("Home")}</span>
|
||||
<span className="mx_UserMenu_subUserName">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
isPrototype = true;
|
||||
}
|
||||
if (this.props.isMinimized) {
|
||||
name = null;
|
||||
buttons = null;
|
||||
|
@ -309,6 +491,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
const classes = classNames({
|
||||
'mx_UserMenu': true,
|
||||
'mx_UserMenu_minimized': this.props.isMinimized,
|
||||
'mx_UserMenu_prototype': isPrototype,
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -317,16 +500,16 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
className={classes}
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.buttonRef}
|
||||
label={_t("User menu")}
|
||||
label={menuName}
|
||||
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)}
|
||||
idName={displayName}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
resizeMethod="crop"
|
||||
|
|
|
@ -80,7 +80,9 @@ export default class UserView extends React.Component {
|
|||
const RightPanel = sdk.getComponent('structures.RightPanel');
|
||||
const MainSplit = sdk.getComponent('structures.MainSplit');
|
||||
const panel = <RightPanel user={this.state.member} />;
|
||||
return (<MainSplit panel={panel}><HomePage /></MainSplit>);
|
||||
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<HomePage />
|
||||
</MainSplit>);
|
||||
} else {
|
||||
return (<div />);
|
||||
}
|
||||
|
|
|
@ -17,24 +17,21 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import SyntaxHighlight from '../views/elements/SyntaxHighlight';
|
||||
import {_t} from "../../languageHandler";
|
||||
import * as sdk from "../../index";
|
||||
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ViewSource',
|
||||
|
||||
propTypes: {
|
||||
export default class ViewSource extends React.Component {
|
||||
static propTypes = {
|
||||
content: PropTypes.object.isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
roomId: PropTypes.string.isRequired,
|
||||
eventId: PropTypes.string.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
return (
|
||||
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t('View Source')}>
|
||||
|
@ -49,5 +46,5 @@ export default createReactClass({
|
|||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,9 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AsyncWrapper from '../../../AsyncWrapper';
|
||||
import * as sdk from '../../../index';
|
||||
import AuthPage from '../../views/auth/AuthPage';
|
||||
import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody';
|
||||
import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog';
|
||||
|
||||
export default class E2eSetup extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -25,21 +26,11 @@ export default class E2eSetup extends React.Component {
|
|||
accountPassword: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// awkwardly indented because https://github.com/eslint/eslint/issues/11310
|
||||
this._createStorageDialogPromise =
|
||||
import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog");
|
||||
}
|
||||
|
||||
render() {
|
||||
const AuthPage = sdk.getComponent("auth.AuthPage");
|
||||
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
|
||||
return (
|
||||
<AuthPage>
|
||||
<CompleteSecurityBody>
|
||||
<AsyncWrapper prom={this._createStorageDialogPromise}
|
||||
hasCancel={false}
|
||||
<CreateCrossSigningDialog
|
||||
onFinished={this.props.onFinished}
|
||||
accountPassword={this.props.accountPassword}
|
||||
/>
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
|
@ -40,50 +39,47 @@ const PHASE_EMAIL_SENT = 3;
|
|||
// User has clicked the link in email and completed reset
|
||||
const PHASE_DONE = 4;
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ForgotPassword',
|
||||
|
||||
propTypes: {
|
||||
export default class ForgotPassword extends React.Component {
|
||||
static propTypes = {
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
onLoginClick: PropTypes.func,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
phase: PHASE_FORGOT,
|
||||
email: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
errorText: null,
|
||||
state = {
|
||||
phase: PHASE_FORGOT,
|
||||
email: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
errorText: null,
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
serverRequiresIdServer: null,
|
||||
};
|
||||
},
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
serverRequiresIdServer: null,
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.reset = null;
|
||||
this._checkServerLiveliness(this.props.serverConfig);
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps: function(newProps) {
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Do a liveliness check on the new URLs
|
||||
this._checkServerLiveliness(newProps.serverConfig);
|
||||
},
|
||||
}
|
||||
|
||||
_checkServerLiveliness: async function(serverConfig) {
|
||||
async _checkServerLiveliness(serverConfig) {
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
serverConfig.hsUrl,
|
||||
|
@ -100,9 +96,9 @@ export default createReactClass({
|
|||
} catch (e) {
|
||||
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
submitPasswordReset: function(email, password) {
|
||||
submitPasswordReset(email, password) {
|
||||
this.setState({
|
||||
phase: PHASE_SENDING_EMAIL,
|
||||
});
|
||||
|
@ -117,9 +113,9 @@ export default createReactClass({
|
|||
phase: PHASE_FORGOT,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onVerify: async function(ev) {
|
||||
onVerify = async ev => {
|
||||
ev.preventDefault();
|
||||
if (!this.reset) {
|
||||
console.error("onVerify called before submitPasswordReset!");
|
||||
|
@ -131,9 +127,9 @@ export default createReactClass({
|
|||
} catch (err) {
|
||||
this.showErrorDialog(err.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onSubmitForm: async function(ev) {
|
||||
onSubmitForm = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
// refresh the server errors, just in case the server came back online
|
||||
|
@ -166,41 +162,41 @@ export default createReactClass({
|
|||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onInputChanged: function(stateKey, ev) {
|
||||
onInputChanged = (stateKey, ev) => {
|
||||
this.setState({
|
||||
[stateKey]: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
onServerDetailsNextPhaseClick = async () => {
|
||||
this.setState({
|
||||
phase: PHASE_FORGOT,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onEditServerDetailsClick(ev) {
|
||||
onEditServerDetailsClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onLoginClick: function(ev) {
|
||||
onLoginClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onLoginClick();
|
||||
},
|
||||
};
|
||||
|
||||
showErrorDialog: function(body, title) {
|
||||
showErrorDialog(body, title) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
|
||||
title: title,
|
||||
description: body,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
renderServerDetails() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
|
@ -218,7 +214,7 @@ export default createReactClass({
|
|||
submitText={_t("Next")}
|
||||
submitClass="mx_Login_submit"
|
||||
/>;
|
||||
},
|
||||
}
|
||||
|
||||
renderForgot() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
@ -335,12 +331,12 @@ export default createReactClass({
|
|||
{_t('Sign in instead')}
|
||||
</a>
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
renderSendingEmail() {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
},
|
||||
}
|
||||
|
||||
renderEmailSent() {
|
||||
return <div>
|
||||
|
@ -350,7 +346,7 @@ export default createReactClass({
|
|||
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
|
||||
value={_t('I have verified my email address')} />
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
renderDone() {
|
||||
return <div>
|
||||
|
@ -363,9 +359,9 @@ export default createReactClass({
|
|||
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
|
||||
value={_t('Return to login screen')} />
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
|
||||
|
@ -397,5 +393,5 @@ export default createReactClass({
|
|||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t, _td} from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
|
@ -29,6 +28,8 @@ import classNames from "classnames";
|
|||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import SSOButton from "../../views/elements/SSOButton";
|
||||
import PlatformPeg from '../../../PlatformPeg';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
@ -53,13 +54,11 @@ _td("Invalid base_url for m.identity_server");
|
|||
_td("Identity server URL does not appear to be a valid identity server");
|
||||
_td("General failure");
|
||||
|
||||
/**
|
||||
/*
|
||||
* A wire component which glues together login UI components and Login logic
|
||||
*/
|
||||
export default createReactClass({
|
||||
displayName: 'Login',
|
||||
|
||||
propTypes: {
|
||||
export default class LoginComponent extends React.Component {
|
||||
static propTypes = {
|
||||
// Called when the user has logged in. Params:
|
||||
// - The object returned by the login API
|
||||
// - The user's password, if applicable, (may be cached in memory for a
|
||||
|
@ -85,10 +84,14 @@ export default createReactClass({
|
|||
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
isSyncing: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._unmounted = false;
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
busyLoggingIn: null,
|
||||
errorText: null,
|
||||
|
@ -113,11 +116,6 @@ export default createReactClass({
|
|||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
|
||||
// map from login step type to a function which will render a control
|
||||
// letting you do that login type
|
||||
|
@ -128,35 +126,38 @@ export default createReactClass({
|
|||
'm.login.cas': () => this._renderSsoStep("cas"),
|
||||
'm.login.sso': () => this._renderSsoStep("sso"),
|
||||
};
|
||||
|
||||
this._initLoginLogic();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillMount() {
|
||||
this._initLoginLogic();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Ensure that we end up actually logging in to the right place
|
||||
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
|
||||
},
|
||||
}
|
||||
|
||||
onPasswordLoginError: function(errorText) {
|
||||
onPasswordLoginError = errorText => {
|
||||
this.setState({
|
||||
errorText,
|
||||
loginIncorrect: Boolean(errorText),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
isBusy: function() {
|
||||
return this.state.busy || this.props.busy;
|
||||
},
|
||||
isBusy = () => this.state.busy || this.props.busy;
|
||||
|
||||
onPasswordLogin: async function(username, phoneCountry, phoneNumber, password) {
|
||||
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
|
||||
if (!this.state.serverIsAlive) {
|
||||
this.setState({busy: true});
|
||||
// Do a quick liveliness check on the URLs
|
||||
|
@ -263,13 +264,13 @@ export default createReactClass({
|
|||
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onUsernameChanged: function(username) {
|
||||
onUsernameChanged = username => {
|
||||
this.setState({ username: username });
|
||||
},
|
||||
};
|
||||
|
||||
onUsernameBlur: async function(username) {
|
||||
onUsernameBlur = async username => {
|
||||
const doWellknownLookup = username[0] === "@";
|
||||
this.setState({
|
||||
username: username,
|
||||
|
@ -314,19 +315,19 @@ export default createReactClass({
|
|||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onPhoneCountryChanged: function(phoneCountry) {
|
||||
onPhoneCountryChanged = phoneCountry => {
|
||||
this.setState({ phoneCountry: phoneCountry });
|
||||
},
|
||||
};
|
||||
|
||||
onPhoneNumberChanged: function(phoneNumber) {
|
||||
onPhoneNumberChanged = phoneNumber => {
|
||||
this.setState({
|
||||
phoneNumber: phoneNumber,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onPhoneNumberBlur: function(phoneNumber) {
|
||||
onPhoneNumberBlur = phoneNumber => {
|
||||
// Validate the phone number entered
|
||||
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
|
||||
this.setState({
|
||||
|
@ -339,15 +340,15 @@ export default createReactClass({
|
|||
canTryLogin: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onRegisterClick: function(ev) {
|
||||
onRegisterClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onRegisterClick();
|
||||
},
|
||||
};
|
||||
|
||||
onTryRegisterClick: function(ev) {
|
||||
onTryRegisterClick = ev => {
|
||||
const step = this._getCurrentFlowStep();
|
||||
if (step === 'm.login.sso' || step === 'm.login.cas') {
|
||||
// If we're showing SSO it means that registration is also probably disabled,
|
||||
|
@ -361,23 +362,23 @@ export default createReactClass({
|
|||
// Don't intercept - just go through to the register page
|
||||
this.onRegisterClick(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
onServerDetailsNextPhaseClick = () => {
|
||||
this.setState({
|
||||
phase: PHASE_LOGIN,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onEditServerDetailsClick(ev) {
|
||||
onEditServerDetailsClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_initLoginLogic: async function(hsUrl, isUrl) {
|
||||
async _initLoginLogic(hsUrl, isUrl) {
|
||||
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
|
||||
isUrl = isUrl || this.props.serverConfig.isUrl;
|
||||
|
||||
|
@ -465,9 +466,9 @@ export default createReactClass({
|
|||
busy: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_isSupportedFlow: function(flow) {
|
||||
_isSupportedFlow(flow) {
|
||||
// technically the flow can have multiple steps, but no one does this
|
||||
// for login and loginLogic doesn't support it so we can ignore it.
|
||||
if (!this._stepRendererMap[flow.type]) {
|
||||
|
@ -475,11 +476,11 @@ export default createReactClass({
|
|||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
|
||||
_getCurrentFlowStep: function() {
|
||||
_getCurrentFlowStep() {
|
||||
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
|
||||
},
|
||||
}
|
||||
|
||||
_errorTextFromError(err) {
|
||||
let errCode = err.errcode;
|
||||
|
@ -526,7 +527,7 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return errorText;
|
||||
},
|
||||
}
|
||||
|
||||
renderServerComponent() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
|
@ -552,7 +553,7 @@ export default createReactClass({
|
|||
delayTimeMs={250}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
},
|
||||
}
|
||||
|
||||
renderLoginComponentForStep() {
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) {
|
||||
|
@ -572,9 +573,9 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}
|
||||
|
||||
_renderPasswordStep: function() {
|
||||
_renderPasswordStep = () => {
|
||||
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
|
||||
|
||||
let onEditServerDetailsClick = null;
|
||||
|
@ -603,9 +604,9 @@ export default createReactClass({
|
|||
busy={this.props.isSyncing || this.state.busyLoggingIn}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
_renderSsoStep: function(loginType) {
|
||||
_renderSsoStep = loginType => {
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
|
||||
let onEditServerDetailsClick = null;
|
||||
|
@ -634,9 +635,9 @@ export default createReactClass({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const InlineSpinner = sdk.getComponent("elements.InlineSpinner");
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
|
@ -680,7 +681,7 @@ export default createReactClass({
|
|||
{_t("If you've joined lots of rooms, this might take a while")}
|
||||
</div> }
|
||||
</div>;
|
||||
} else {
|
||||
} else if (SettingsStore.getValue(UIFeature.Registration)) {
|
||||
footer = (
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onTryRegisterClick} href="#">
|
||||
{ _t('Create account') }
|
||||
|
@ -704,5 +705,5 @@ export default createReactClass({
|
|||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,29 +15,24 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'PostRegistration',
|
||||
|
||||
propTypes: {
|
||||
export default class PostRegistration extends React.Component {
|
||||
static propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
avatarUrl: null,
|
||||
errorString: null,
|
||||
busy: false,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
avatarUrl: null,
|
||||
errorString: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
|
||||
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
|
||||
// the URL to be passed to you (because it's also used for room avatars).
|
||||
|
@ -55,9 +50,9 @@ export default createReactClass({
|
|||
busy: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
|
||||
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
|
||||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
|
@ -78,5 +73,5 @@ export default createReactClass({
|
|||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,6 @@ limitations under the License.
|
|||
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
|
@ -43,10 +42,8 @@ const PHASE_REGISTRATION = 1;
|
|||
// Enable phases for registration
|
||||
const PHASES_ENABLED = true;
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'Registration',
|
||||
|
||||
propTypes: {
|
||||
export default class Registration extends React.Component {
|
||||
static propTypes = {
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
|
@ -65,12 +62,13 @@ export default createReactClass({
|
|||
onLoginClick: PropTypes.func.isRequired,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
defaultDeviceDisplayName: PropTypes.string,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
getInitialState: function() {
|
||||
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
|
||||
|
||||
return {
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
// We remember the values entered by the user because
|
||||
|
@ -118,14 +116,15 @@ export default createReactClass({
|
|||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId: null,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
this._replaceClient();
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
@ -142,7 +141,7 @@ export default createReactClass({
|
|||
phase: this.getDefaultPhaseForServerType(serverType),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
getDefaultPhaseForServerType(type) {
|
||||
switch (type) {
|
||||
|
@ -155,9 +154,9 @@ export default createReactClass({
|
|||
case ServerType.ADVANCED:
|
||||
return PHASE_SERVER_DETAILS;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onServerTypeChange(type) {
|
||||
onServerTypeChange = type => {
|
||||
this.setState({
|
||||
serverType: type,
|
||||
});
|
||||
|
@ -184,9 +183,9 @@ export default createReactClass({
|
|||
this.setState({
|
||||
phase: this.getDefaultPhaseForServerType(type),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_replaceClient: async function(serverConfig) {
|
||||
async _replaceClient(serverConfig) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
serverDeadError: null,
|
||||
|
@ -286,18 +285,18 @@ export default createReactClass({
|
|||
showGenericError(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onFormSubmit: function(formVals) {
|
||||
onFormSubmit = formVals => {
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
formVals: formVals,
|
||||
doingUIAuth: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) {
|
||||
_requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
|
||||
return this.state.matrixClient.requestRegisterEmailToken(
|
||||
emailAddress,
|
||||
clientSecret,
|
||||
|
@ -309,9 +308,9 @@ export default createReactClass({
|
|||
session_id: sessionId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
_onUIAuthFinished: async function(success, response, extra) {
|
||||
_onUIAuthFinished = async (success, response, extra) => {
|
||||
if (!success) {
|
||||
let msg = response.message || response.toString();
|
||||
// can we give a better error message?
|
||||
|
@ -395,9 +394,9 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
this.setState(newState);
|
||||
},
|
||||
};
|
||||
|
||||
_setupPushers: function() {
|
||||
_setupPushers() {
|
||||
if (!this.props.brand) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
@ -418,15 +417,15 @@ export default createReactClass({
|
|||
}, (error) => {
|
||||
console.error("Couldn't get pushers: " + error);
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onLoginClick: function(ev) {
|
||||
onLoginClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onLoginClick();
|
||||
},
|
||||
};
|
||||
|
||||
onGoToFormClicked(ev) {
|
||||
onGoToFormClicked = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._replaceClient();
|
||||
|
@ -435,23 +434,23 @@ export default createReactClass({
|
|||
doingUIAuth: false,
|
||||
phase: PHASE_REGISTRATION,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
onServerDetailsNextPhaseClick = async () => {
|
||||
this.setState({
|
||||
phase: PHASE_REGISTRATION,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onEditServerDetailsClick(ev) {
|
||||
onEditServerDetailsClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_makeRegisterRequest: function(auth) {
|
||||
_makeRegisterRequest = auth => {
|
||||
// We inhibit login if we're trying to register with an email address: this
|
||||
// avoids a lot of complex race conditions that can occur if we try to log
|
||||
// the user in one one or both of the tabs they might end up with after
|
||||
|
@ -471,20 +470,20 @@ export default createReactClass({
|
|||
if (auth) registerParams.auth = auth;
|
||||
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
|
||||
return this.state.matrixClient.registerRequest(registerParams);
|
||||
},
|
||||
};
|
||||
|
||||
_getUIAuthInputs: function() {
|
||||
_getUIAuthInputs() {
|
||||
return {
|
||||
emailAddress: this.state.formVals.email,
|
||||
phoneCountry: this.state.formVals.phoneCountry,
|
||||
phoneNumber: this.state.formVals.phoneNumber,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
// Links to the login page shown after registration is completed are routed through this
|
||||
// which checks the user hasn't already logged in somewhere else (perhaps we should do
|
||||
// this more generally?)
|
||||
_onLoginClickWithCheck: async function(ev) {
|
||||
_onLoginClickWithCheck = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
|
||||
|
@ -492,7 +491,7 @@ export default createReactClass({
|
|||
// ok fine, there's still no session: really go to the login page
|
||||
this.props.onLoginClick();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
renderServerComponent() {
|
||||
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
|
||||
|
@ -553,7 +552,7 @@ export default createReactClass({
|
|||
/>
|
||||
{serverDetails}
|
||||
</div>;
|
||||
},
|
||||
}
|
||||
|
||||
renderRegisterComponent() {
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) {
|
||||
|
@ -608,9 +607,9 @@ export default createReactClass({
|
|||
serverRequiresIdServer={this.state.serverRequiresIdServer}
|
||||
/>;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
@ -706,5 +705,5 @@ export default createReactClass({
|
|||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,16 +18,13 @@ limitations under the License.
|
|||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'AuthFooter',
|
||||
|
||||
render: function() {
|
||||
export default class AuthFooter extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="mx_AuthFooter">
|
||||
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,17 +17,14 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import * as sdk from '../../../index';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'AuthHeader',
|
||||
|
||||
propTypes: {
|
||||
export default class AuthHeader extends React.Component {
|
||||
static propTypes = {
|
||||
disableLanguageSelector: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo');
|
||||
const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector');
|
||||
|
||||
|
@ -37,5 +34,5 @@ export default createReactClass({
|
|||
<LanguageSelector disabled={this.props.disableLanguageSelector} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
|
@ -24,36 +23,31 @@ const DIV_ID = 'mx_recaptcha';
|
|||
/**
|
||||
* A pure UI component which displays a captcha form.
|
||||
*/
|
||||
export default createReactClass({
|
||||
displayName: 'CaptchaForm',
|
||||
|
||||
propTypes: {
|
||||
export default class CaptchaForm extends React.Component {
|
||||
static propTypes = {
|
||||
sitePublicKey: PropTypes.string,
|
||||
|
||||
// called with the captcha response
|
||||
onCaptchaResponse: PropTypes.func,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onCaptchaResponse: () => {},
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
onCaptchaResponse: () => {},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
errorText: null,
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
this._captchaWidgetId = null;
|
||||
|
||||
this._recaptchaContainer = createRef();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
|
||||
// so we do this instead.
|
||||
if (global.grecaptcha) {
|
||||
|
@ -68,13 +62,13 @@ export default createReactClass({
|
|||
);
|
||||
this._recaptchaContainer.current.appendChild(scriptTag);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
this._resetRecaptcha();
|
||||
},
|
||||
}
|
||||
|
||||
_renderRecaptcha: function(divId) {
|
||||
_renderRecaptcha(divId) {
|
||||
if (!global.grecaptcha) {
|
||||
console.error("grecaptcha not loaded!");
|
||||
throw new Error("Recaptcha did not load successfully");
|
||||
|
@ -93,15 +87,15 @@ export default createReactClass({
|
|||
sitekey: publicKey,
|
||||
callback: this.props.onCaptchaResponse,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_resetRecaptcha: function() {
|
||||
_resetRecaptcha() {
|
||||
if (this._captchaWidgetId !== null) {
|
||||
global.grecaptcha.reset(this._captchaWidgetId);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_onCaptchaLoaded: function() {
|
||||
_onCaptchaLoaded() {
|
||||
console.log("Loaded recaptcha script.");
|
||||
try {
|
||||
this._renderRecaptcha(DIV_ID);
|
||||
|
@ -110,9 +104,9 @@ export default createReactClass({
|
|||
errorText: e.toString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
|
@ -131,5 +125,5 @@ export default createReactClass({
|
|||
{ error }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,14 +16,11 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'CustomServerDialog',
|
||||
|
||||
render: function() {
|
||||
export default class CustomServerDialog extends React.Component {
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
return (
|
||||
<div className="mx_ErrorDialog">
|
||||
|
@ -46,5 +43,5 @@ export default createReactClass({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import url from 'url';
|
||||
import classnames from 'classnames';
|
||||
|
@ -26,6 +25,7 @@ import * as sdk from '../../../index';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Spinner from "../elements/Spinner";
|
||||
|
||||
/* This file contains a collection of components which are used by the
|
||||
* InteractiveAuth to prompt the user to enter the information needed
|
||||
|
@ -75,14 +75,10 @@ import AccessibleButton from "../elements/AccessibleButton";
|
|||
|
||||
export const DEFAULT_PHASE = 0;
|
||||
|
||||
export const PasswordAuthEntry = createReactClass({
|
||||
displayName: 'PasswordAuthEntry',
|
||||
export class PasswordAuthEntry extends React.Component {
|
||||
static LOGIN_TYPE = "m.login.password";
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.password",
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
|
@ -90,19 +86,17 @@ export const PasswordAuthEntry = createReactClass({
|
|||
// happen?
|
||||
busy: PropTypes.bool,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
}
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
password: "",
|
||||
};
|
||||
},
|
||||
state = {
|
||||
password: "",
|
||||
};
|
||||
|
||||
_onSubmit: function(e) {
|
||||
_onSubmit = e => {
|
||||
e.preventDefault();
|
||||
if (this.props.busy) return;
|
||||
|
||||
|
@ -117,16 +111,16 @@ export const PasswordAuthEntry = createReactClass({
|
|||
},
|
||||
password: this.state.password,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onPasswordFieldChange: function(ev) {
|
||||
_onPasswordFieldChange = ev => {
|
||||
// enable the submit button iff the password is non-empty
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const passwordBoxClass = classnames({
|
||||
"error": this.props.errorText,
|
||||
});
|
||||
|
@ -176,36 +170,32 @@ export const PasswordAuthEntry = createReactClass({
|
|||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const RecaptchaAuthEntry = createReactClass({
|
||||
displayName: 'RecaptchaAuthEntry',
|
||||
export class RecaptchaAuthEntry extends React.Component {
|
||||
static LOGIN_TYPE = "m.login.recaptcha";
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.recaptcha",
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
static propTypes = {
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
stageParams: PropTypes.object.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
busy: PropTypes.bool,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
}
|
||||
|
||||
_onCaptchaResponse: function(response) {
|
||||
_onCaptchaResponse = response => {
|
||||
this.props.submitAuthDict({
|
||||
type: RecaptchaAuthEntry.LOGIN_TYPE,
|
||||
response: response,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (this.props.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
|
@ -241,31 +231,24 @@ export const RecaptchaAuthEntry = createReactClass({
|
|||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const TermsAuthEntry = createReactClass({
|
||||
displayName: 'TermsAuthEntry',
|
||||
export class TermsAuthEntry extends React.Component {
|
||||
static LOGIN_TYPE = "m.login.terms";
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.terms",
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
static propTypes = {
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
stageParams: PropTypes.object.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
busy: PropTypes.bool,
|
||||
showContinue: PropTypes.bool,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Move this to constructor
|
||||
componentWillMount: function() {
|
||||
// example stageParams:
|
||||
//
|
||||
// {
|
||||
|
@ -310,17 +293,22 @@ export const TermsAuthEntry = createReactClass({
|
|||
pickedPolicies.push(langPolicy);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
"toggledPolicies": initToggles,
|
||||
"policies": pickedPolicies,
|
||||
});
|
||||
},
|
||||
this.state = {
|
||||
toggledPolicies: initToggles,
|
||||
policies: pickedPolicies,
|
||||
};
|
||||
}
|
||||
|
||||
tryContinue: function() {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
}
|
||||
|
||||
tryContinue = () => {
|
||||
this._trySubmit();
|
||||
},
|
||||
};
|
||||
|
||||
_togglePolicy: function(policyId) {
|
||||
_togglePolicy(policyId) {
|
||||
const newToggles = {};
|
||||
for (const policy of this.state.policies) {
|
||||
let checked = this.state.toggledPolicies[policy.id];
|
||||
|
@ -329,9 +317,9 @@ export const TermsAuthEntry = createReactClass({
|
|||
newToggles[policy.id] = checked;
|
||||
}
|
||||
this.setState({"toggledPolicies": newToggles});
|
||||
},
|
||||
}
|
||||
|
||||
_trySubmit: function() {
|
||||
_trySubmit = () => {
|
||||
let allChecked = true;
|
||||
for (const policy of this.state.policies) {
|
||||
const checked = this.state.toggledPolicies[policy.id];
|
||||
|
@ -340,9 +328,9 @@ export const TermsAuthEntry = createReactClass({
|
|||
|
||||
if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
|
||||
else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (this.props.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
|
@ -387,17 +375,13 @@ export const TermsAuthEntry = createReactClass({
|
|||
{ submitButton }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const EmailIdentityAuthEntry = createReactClass({
|
||||
displayName: 'EmailIdentityAuthEntry',
|
||||
export class EmailIdentityAuthEntry extends React.Component {
|
||||
static LOGIN_TYPE = "m.login.email.identity";
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.email.identity",
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
authSessionId: PropTypes.string.isRequired,
|
||||
|
@ -407,13 +391,13 @@ export const EmailIdentityAuthEntry = createReactClass({
|
|||
fail: PropTypes.func.isRequired,
|
||||
setEmailSid: PropTypes.func.isRequired,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
// This component is now only displayed once the token has been requested,
|
||||
// so we know the email has been sent. It can also get loaded after the user
|
||||
// has clicked the validation link if the server takes a while to propagate
|
||||
|
@ -421,8 +405,12 @@ export const EmailIdentityAuthEntry = createReactClass({
|
|||
// the validation link, we won't know the email address, so if we don't have it,
|
||||
// assume that the link has been clicked and the server will realise when we poll.
|
||||
if (this.props.inputs.emailAddress === undefined) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
return <Spinner />;
|
||||
} else if (this.props.stageState?.emailSid) {
|
||||
// we only have a session ID if the user has clicked the link in their email,
|
||||
// so show a loading state instead of "an email has been sent to..." because
|
||||
// that's confusing when you've already read that email.
|
||||
return <Spinner />;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
|
@ -434,17 +422,13 @@ export const EmailIdentityAuthEntry = createReactClass({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const MsisdnAuthEntry = createReactClass({
|
||||
displayName: 'MsisdnAuthEntry',
|
||||
export class MsisdnAuthEntry extends React.Component {
|
||||
static LOGIN_TYPE = "m.login.msisdn";
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.msisdn",
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
static propTypes = {
|
||||
inputs: PropTypes.shape({
|
||||
phoneCountry: PropTypes.string,
|
||||
phoneNumber: PropTypes.string,
|
||||
|
@ -454,16 +438,14 @@ export const MsisdnAuthEntry = createReactClass({
|
|||
submitAuthDict: PropTypes.func.isRequired,
|
||||
matrixClient: PropTypes.object,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
token: '',
|
||||
requestingToken: false,
|
||||
};
|
||||
},
|
||||
state = {
|
||||
token: '',
|
||||
requestingToken: false,
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
|
||||
this._submitUrl = null;
|
||||
|
@ -477,12 +459,12 @@ export const MsisdnAuthEntry = createReactClass({
|
|||
}).finally(() => {
|
||||
this.setState({requestingToken: false});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
/*
|
||||
* Requests a verification token by SMS.
|
||||
*/
|
||||
_requestMsisdnToken: function() {
|
||||
_requestMsisdnToken() {
|
||||
return this.props.matrixClient.requestRegisterMsisdnToken(
|
||||
this.props.inputs.phoneCountry,
|
||||
this.props.inputs.phoneNumber,
|
||||
|
@ -493,15 +475,15 @@ export const MsisdnAuthEntry = createReactClass({
|
|||
this._sid = result.sid;
|
||||
this._msisdn = result.msisdn;
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_onTokenChange: function(e) {
|
||||
_onTokenChange = e => {
|
||||
this.setState({
|
||||
token: e.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
_onFormSubmit: async function(e) {
|
||||
_onFormSubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (this.state.token == '') return;
|
||||
|
||||
|
@ -552,9 +534,9 @@ export const MsisdnAuthEntry = createReactClass({
|
|||
this.props.fail(e);
|
||||
console.log("Failed to submit msisdn token");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
if (this.state.requestingToken) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
|
@ -598,8 +580,8 @@ export const MsisdnAuthEntry = createReactClass({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SSOAuthEntry extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -686,46 +668,46 @@ export class SSOAuthEntry extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
export const FallbackAuthEntry = createReactClass({
|
||||
displayName: 'FallbackAuthEntry',
|
||||
|
||||
propTypes: {
|
||||
export class FallbackAuthEntry extends React.Component {
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
authSessionId: PropTypes.string.isRequired,
|
||||
loginType: PropTypes.string.isRequired,
|
||||
submitAuthDict: PropTypes.func.isRequired,
|
||||
errorText: PropTypes.string,
|
||||
onPhaseChange: PropTypes.func.isRequired,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount: function() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
},
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount: function() {
|
||||
// we have to make the user click a button, as browsers will block
|
||||
// the popup if we open it immediately.
|
||||
this._popupWindow = null;
|
||||
window.addEventListener("message", this._onReceiveMessage);
|
||||
|
||||
this._fallbackButton = createRef();
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.onPhaseChange(DEFAULT_PHASE);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("message", this._onReceiveMessage);
|
||||
if (this._popupWindow) {
|
||||
this._popupWindow.close();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
focus: function() {
|
||||
focus = () => {
|
||||
if (this._fallbackButton.current) {
|
||||
this._fallbackButton.current.focus();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onShowFallbackClick: function(e) {
|
||||
_onShowFallbackClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
@ -735,18 +717,18 @@ export const FallbackAuthEntry = createReactClass({
|
|||
);
|
||||
this._popupWindow = window.open(url);
|
||||
this._popupWindow.opener = null;
|
||||
},
|
||||
};
|
||||
|
||||
_onReceiveMessage: function(event) {
|
||||
_onReceiveMessage = event => {
|
||||
if (
|
||||
event.data === "authDone" &&
|
||||
event.origin === this.props.matrixClient.getHomeserverUrl()
|
||||
) {
|
||||
this.props.submitAuthDict({});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
errorSection = (
|
||||
|
@ -761,8 +743,8 @@ export const FallbackAuthEntry = createReactClass({
|
|||
{errorSection}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const AuthEntryComponents = [
|
||||
PasswordAuthEntry,
|
||||
|
|
|
@ -40,11 +40,7 @@ interface IProps {
|
|||
onValidate(result: IValidationResult);
|
||||
}
|
||||
|
||||
interface IState {
|
||||
complexity: zxcvbn.ZXCVBNResult;
|
||||
}
|
||||
|
||||
class PassphraseField extends PureComponent<IProps, IState> {
|
||||
class PassphraseField extends PureComponent<IProps> {
|
||||
static defaultProps = {
|
||||
label: _td("Password"),
|
||||
labelEnterPassword: _td("Enter password"),
|
||||
|
@ -52,14 +48,16 @@ class PassphraseField extends PureComponent<IProps, IState> {
|
|||
labelAllowedButUnsafe: _td("Password is allowed, but unsafe"),
|
||||
};
|
||||
|
||||
state = { complexity: null };
|
||||
|
||||
public readonly validate = withValidation<this>({
|
||||
description: function() {
|
||||
const complexity = this.state.complexity;
|
||||
public readonly validate = withValidation<this, zxcvbn.ZXCVBNResult>({
|
||||
description: function(complexity) {
|
||||
const score = complexity ? complexity.score : 0;
|
||||
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
|
||||
},
|
||||
deriveData: async ({ value }) => {
|
||||
if (!value) return null;
|
||||
const { scorePassword } = await import('../../../utils/PasswordScorer');
|
||||
return scorePassword(value);
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -68,28 +66,24 @@ class PassphraseField extends PureComponent<IProps, IState> {
|
|||
},
|
||||
{
|
||||
key: "complexity",
|
||||
test: async function({ value }) {
|
||||
test: async function({ value }, complexity) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const { scorePassword } = await import('../../../utils/PasswordScorer');
|
||||
const complexity = scorePassword(value);
|
||||
this.setState({ complexity });
|
||||
const safe = complexity.score >= this.props.minScore;
|
||||
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
|
||||
return allowUnsafe || safe;
|
||||
},
|
||||
valid: function() {
|
||||
valid: function(complexity) {
|
||||
// Unsafe passwords that are valid are only possible through a
|
||||
// configuration flag. We'll print some helper text to signal
|
||||
// to the user that their password is allowed, but unsafe.
|
||||
if (this.state.complexity.score >= this.props.minScore) {
|
||||
if (complexity.score >= this.props.minScore) {
|
||||
return _t(this.props.labelStrongPassword);
|
||||
}
|
||||
return _t(this.props.labelAllowedButUnsafe);
|
||||
},
|
||||
invalid: function() {
|
||||
const complexity = this.state.complexity;
|
||||
invalid: function(complexity) {
|
||||
if (!complexity) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import * as Email from '../../../email';
|
||||
|
@ -39,13 +38,11 @@ const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
|
|||
|
||||
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
|
||||
|
||||
/**
|
||||
/*
|
||||
* A pure UI component which displays a registration form.
|
||||
*/
|
||||
export default createReactClass({
|
||||
displayName: 'RegistrationForm',
|
||||
|
||||
propTypes: {
|
||||
export default class RegistrationForm extends React.Component {
|
||||
static propTypes = {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail: PropTypes.string,
|
||||
defaultPhoneCountry: PropTypes.string,
|
||||
|
@ -58,17 +55,17 @@ export default createReactClass({
|
|||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
canSubmit: PropTypes.bool,
|
||||
serverRequiresIdServer: PropTypes.bool,
|
||||
},
|
||||
};
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onValidationChange: console.error,
|
||||
canSubmit: true,
|
||||
};
|
||||
},
|
||||
static defaultProps = {
|
||||
onValidationChange: console.error,
|
||||
canSubmit: true,
|
||||
};
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
|
@ -80,9 +77,9 @@ export default createReactClass({
|
|||
passwordConfirm: this.props.defaultPassword || "",
|
||||
passwordComplexity: null,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
onSubmit: async function(ev) {
|
||||
onSubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!this.props.canSubmit) return;
|
||||
|
@ -118,7 +115,7 @@ export default createReactClass({
|
|||
title: _t("Warning!"),
|
||||
description: desc,
|
||||
button: _t("Continue"),
|
||||
onFinished: function(confirmed) {
|
||||
onFinished(confirmed) {
|
||||
if (confirmed) {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
|
@ -127,9 +124,9 @@ export default createReactClass({
|
|||
} else {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_doSubmit: function(ev) {
|
||||
_doSubmit(ev) {
|
||||
const email = this.state.email.trim();
|
||||
const promise = this.props.onRegisterClick({
|
||||
username: this.state.username.trim(),
|
||||
|
@ -145,7 +142,7 @@ export default createReactClass({
|
|||
ev.target.disabled = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
|
@ -196,12 +193,12 @@ export default createReactClass({
|
|||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if all fields were valid last time they were validated.
|
||||
*/
|
||||
allFieldsValid: function() {
|
||||
allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
|
@ -209,7 +206,7 @@ export default createReactClass({
|
|||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
|
||||
findFirstInvalidField(fieldIDs) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
|
@ -218,34 +215,34 @@ export default createReactClass({
|
|||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}
|
||||
|
||||
markFieldValid: function(fieldID, valid) {
|
||||
markFieldValid(fieldID, valid) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldValid,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
onEmailChange(ev) {
|
||||
onEmailChange = ev => {
|
||||
this.setState({
|
||||
email: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async onEmailValidate(fieldState) {
|
||||
onEmailValidate = async fieldState => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(FIELD_EMAIL, result.valid);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
validateEmailRules: withValidation({
|
||||
validateEmailRules = withValidation({
|
||||
description: () => _t("Use an email address to recover your account"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: function({ value, allowEmpty }) {
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address (required on this homeserver)"),
|
||||
|
@ -256,31 +253,31 @@ export default createReactClass({
|
|||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
onPasswordChange(ev) {
|
||||
onPasswordChange = ev => {
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onPasswordValidate(result) {
|
||||
onPasswordValidate = result => {
|
||||
this.markFieldValid(FIELD_PASSWORD, result.valid);
|
||||
},
|
||||
};
|
||||
|
||||
onPasswordConfirmChange(ev) {
|
||||
onPasswordConfirmChange = ev => {
|
||||
this.setState({
|
||||
passwordConfirm: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async onPasswordConfirmValidate(fieldState) {
|
||||
onPasswordConfirmValidate = async fieldState => {
|
||||
const result = await this.validatePasswordConfirmRules(fieldState);
|
||||
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
validatePasswordConfirmRules: withValidation({
|
||||
validatePasswordConfirmRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -289,39 +286,39 @@ export default createReactClass({
|
|||
},
|
||||
{
|
||||
key: "match",
|
||||
test: function({ value }) {
|
||||
test({ value }) {
|
||||
return !value || value === this.state.password;
|
||||
},
|
||||
invalid: () => _t("Passwords don't match"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
onPhoneCountryChange(newVal) {
|
||||
onPhoneCountryChange = newVal => {
|
||||
this.setState({
|
||||
phoneCountry: newVal.iso2,
|
||||
phonePrefix: newVal.prefix,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
onPhoneNumberChange(ev) {
|
||||
onPhoneNumberChange = ev => {
|
||||
this.setState({
|
||||
phoneNumber: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async onPhoneNumberValidate(fieldState) {
|
||||
onPhoneNumberValidate = async fieldState => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
validatePhoneNumberRules: withValidation({
|
||||
validatePhoneNumberRules = withValidation({
|
||||
description: () => _t("Other users can invite you to rooms using your contact details"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: function({ value, allowEmpty }) {
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number (required on this homeserver)"),
|
||||
|
@ -332,21 +329,21 @@ export default createReactClass({
|
|||
invalid: () => _t("Doesn't look like a valid phone number"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
onUsernameChange(ev) {
|
||||
onUsernameChange = ev => {
|
||||
this.setState({
|
||||
username: ev.target.value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async onUsernameValidate(fieldState) {
|
||||
onUsernameValidate = async fieldState => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(FIELD_USERNAME, result.valid);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
validateUsernameRules: withValidation({
|
||||
validateUsernameRules = withValidation({
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
rules: [
|
||||
{
|
||||
|
@ -360,7 +357,7 @@ export default createReactClass({
|
|||
invalid: () => _t("Some characters not allowed"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A step is required if all flows include that step.
|
||||
|
@ -372,7 +369,7 @@ export default createReactClass({
|
|||
return this.props.flows.every((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A step is used if any flows include that step.
|
||||
|
@ -384,7 +381,7 @@ export default createReactClass({
|
|||
return this.props.flows.some((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_showEmail() {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
|
@ -395,7 +392,7 @@ export default createReactClass({
|
|||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
|
||||
_showPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
|
@ -408,7 +405,7 @@ export default createReactClass({
|
|||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
|
||||
renderEmail() {
|
||||
if (!this._showEmail()) {
|
||||
|
@ -426,7 +423,7 @@ export default createReactClass({
|
|||
onChange={this.onEmailChange}
|
||||
onValidate={this.onEmailValidate}
|
||||
/>;
|
||||
},
|
||||
}
|
||||
|
||||
renderPassword() {
|
||||
return <PassphraseField
|
||||
|
@ -437,7 +434,7 @@ export default createReactClass({
|
|||
onChange={this.onPasswordChange}
|
||||
onValidate={this.onPasswordValidate}
|
||||
/>;
|
||||
},
|
||||
}
|
||||
|
||||
renderPasswordConfirm() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
@ -451,7 +448,7 @@ export default createReactClass({
|
|||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
/>;
|
||||
},
|
||||
}
|
||||
|
||||
renderPhoneNumber() {
|
||||
if (!this._showPhoneNumber()) {
|
||||
|
@ -477,7 +474,7 @@ export default createReactClass({
|
|||
onChange={this.onPhoneNumberChange}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
/>;
|
||||
},
|
||||
}
|
||||
|
||||
renderUsername() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
@ -491,9 +488,9 @@ export default createReactClass({
|
|||
onChange={this.onUsernameChange}
|
||||
onValidate={this.onUsernameValidate}
|
||||
/>;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
|
@ -578,5 +575,5 @@ export default createReactClass({
|
|||
</form>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,10 +15,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from "classnames";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import AuthPage from "./AuthPage";
|
||||
import {_td} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
|
||||
// translatable strings for Welcome pages
|
||||
_td("Sign in with SSO");
|
||||
|
@ -39,7 +43,9 @@ export default class Welcome extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<AuthPage>
|
||||
<div className="mx_Welcome">
|
||||
<div className={classNames("mx_Welcome", {
|
||||
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
|
||||
})}>
|
||||
<EmbeddedPage
|
||||
className="mx_WelcomePage"
|
||||
url={pageUrl}
|
||||
|
|
|
@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
||||
import React, {useCallback, useContext, useEffect, useState} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as AvatarLogic from '../../../Avatar';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -42,34 +42,35 @@ interface IProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const calculateUrls = (url, urls) => {
|
||||
// work out the full set of urls to try to load. This is formed like so:
|
||||
// imageUrls: [ props.url, ...props.urls ]
|
||||
|
||||
let _urls = [];
|
||||
if (!SettingsStore.getValue("lowBandwidth")) {
|
||||
_urls = urls || [];
|
||||
|
||||
if (url) {
|
||||
_urls.unshift(url); // put in urls[0]
|
||||
}
|
||||
}
|
||||
|
||||
// deduplicate URLs
|
||||
return Array.from(new Set(_urls));
|
||||
};
|
||||
|
||||
const useImageUrl = ({url, urls}): [string, () => void] => {
|
||||
const [imageUrls, setUrls] = useState<string[]>([]);
|
||||
const [urlsIndex, setIndex] = useState<number>();
|
||||
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls));
|
||||
const [urlsIndex, setIndex] = useState<number>(0);
|
||||
|
||||
const onError = useCallback(() => {
|
||||
setIndex(i => i + 1); // try the next one
|
||||
}, []);
|
||||
const memoizedUrls = useMemo(() => urls, [JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
// work out the full set of urls to try to load. This is formed like so:
|
||||
// imageUrls: [ props.url, ...props.urls ]
|
||||
|
||||
let _urls = [];
|
||||
if (!SettingsStore.getValue("lowBandwidth")) {
|
||||
_urls = memoizedUrls || [];
|
||||
|
||||
if (url) {
|
||||
_urls.unshift(url); // put in urls[0]
|
||||
}
|
||||
}
|
||||
|
||||
// deduplicate URLs
|
||||
_urls = Array.from(new Set(_urls));
|
||||
|
||||
setUrls(calculateUrls(url, urls));
|
||||
setIndex(0);
|
||||
setUrls(_urls);
|
||||
}, [url, memoizedUrls]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const onClientSync = useCallback((syncState, prevState) => {
|
||||
|
@ -95,7 +96,7 @@ const BaseAvatar = (props: IProps) => {
|
|||
urls,
|
||||
width = 40,
|
||||
height = 40,
|
||||
resizeMethod = "crop", // eslint-disable-line no-unused-vars
|
||||
resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
defaultToInitialLetter = true,
|
||||
onClick,
|
||||
inputRef,
|
||||
|
|
|
@ -126,7 +126,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
|
|||
private onPresenceUpdate = () => {
|
||||
if (this.isUnmounted) return;
|
||||
|
||||
let newIcon = this.getPresenceIcon();
|
||||
const newIcon = this.getPresenceIcon();
|
||||
if (newIcon !== this.state.icon) this.setState({icon: newIcon});
|
||||
};
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ export default class GroupAvatar extends React.Component<IProps> {
|
|||
render() {
|
||||
// extract the props we use from props so we can pass any others through
|
||||
// should consider adding this as a global rule in js-sdk?
|
||||
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props;
|
||||
|
||||
return (
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue