Merge remote-tracking branch 'upstream/develop' into compact-reply-rendering

This commit is contained in:
Tulir Asokan 2021-02-19 21:47:10 +02:00
commit dfcf701449
544 changed files with 43286 additions and 15544 deletions

View file

@ -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";
@ -31,6 +32,12 @@ import type {Renderer} from "react-dom";
import RightPanelStore from "../stores/RightPanelStore";
import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler";
import {Analytics} from "../Analytics";
import CountlyAnalytics from "../CountlyAnalytics";
import UserActivity from "../UserActivity";
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper";
declare global {
interface Window {
@ -54,12 +61,25 @@ declare global {
mxNotifier: typeof Notifier;
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxWidgetLayoutStore: WidgetLayoutStore;
mxCallHandler: CallHandler;
mxAnalytics: Analytics;
mxCountlyAnalytics: typeof CountlyAnalytics;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;
}
interface Document {
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
hasStorageAccess?: () => Promise<boolean>;
// Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now
webkitExitFullscreen(): Promise<void>;
msExitFullscreen(): Promise<void>;
readonly webkitFullscreenElement: Element | null;
readonly msFullscreenElement: Element | null;
}
interface Navigator {
@ -89,4 +109,20 @@ declare global {
interface HTMLAudioElement {
type?: string;
}
interface Element {
// Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
}
interface Error {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName
fileName?: string;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber
lineNumber?: number;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
columnNumber?: number;
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import { getCurrentLanguage, _t, _td } from './languageHandler';
import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import Modal from './Modal';
@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password
const hashVarRegex = /#\/(group|room|user)\/.*$/;
// Remove all but the first item in the hash path. Redact unexpected hashes.
function getRedactedHash(hash) {
function getRedactedHash(hash: string): string {
// Don't leak URLs we aren't expecting - they could contain tokens/PII
const match = hashRegex.exec(hash);
if (!match) {
@ -44,7 +44,7 @@ function getRedactedHash(hash) {
// Return the current origin, path and hash separated with a `/`. This does
// not include query parameters.
function getRedactedUrl() {
function getRedactedUrl(): string {
const { origin, hash } = window.location;
let { pathname } = window.location;
@ -56,7 +56,25 @@ function getRedactedUrl() {
return origin + pathname + getRedactedHash(hash);
}
const customVariables = {
interface IData {
/* eslint-disable camelcase */
gt_ms?: string;
e_c?: string;
e_a?: string;
e_n?: string;
e_v?: string;
ping?: string;
/* eslint-enable camelcase */
}
interface IVariable {
id: number;
expl: string; // explanation
example: string; // example value
getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t`
}
const customVariables: Record<string, IVariable> = {
// The Matomo installation at https://matomo.riot.im is currently configured
// with a limit of 10 custom variables.
'App Platform': {
@ -120,7 +138,7 @@ const customVariables = {
},
};
function whitelistRedact(whitelist, str) {
function whitelistRedact(whitelist: string[], str: string): string {
if (whitelist.includes(str)) return str;
return '<redacted>';
}
@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() {
function getUid(): string {
try {
let data = localStorage && localStorage.getItem(UID_KEY);
if (!data && localStorage) {
@ -145,32 +163,36 @@ 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;
}
canEnable() {
public canEnable() {
const config = SdkConfig.get();
return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId;
}
@ -179,67 +201,67 @@ class Analytics {
* 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();
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);
@ -248,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();
@ -264,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",
@ -281,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
@ -303,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,
@ -317,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();
@ -330,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);
@ -360,7 +382,7 @@ class Analytics {
'e.g. <CurrentPageURL>',
{},
{
CurrentPageURL: getRedactedUrl(),
CurrentPageURL: getRedactedUrl,
},
),
},
@ -401,7 +423,7 @@ class Analytics {
};
}
if (!global.mxAnalytics) {
global.mxAnalytics = new Analytics();
if (!window.mxAnalytics) {
window.mxAnalytics = new Analytics();
}
export default global.mxAnalytics;
export default window.mxAnalytics;

View file

@ -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(

View file

@ -18,15 +18,19 @@ limitations under the License.
*/
import {MatrixClient} from "matrix-js-sdk/src/client";
import {encodeUnpaddedBase64} from "matrix-js-sdk/src/crypto/olmlib";
import dis from './dispatcher/dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
import {ActionPayload} from "./dispatcher/payloads";
import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload";
import {Action} from "./dispatcher/actions";
import {hideToast as hideUpdateToast} from "./toasts/UpdateToast";
import {MatrixClientPeg} from "./MatrixClientPeg";
import {idbLoad, idbSave, idbDelete} from "./utils/StorageManager";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
export const SSO_IDP_ID_KEY = "mx_sso_idp_id";
export enum UpdateCheckStatus {
Checking = "CHECKING",
@ -53,7 +57,7 @@ export default abstract class BasePlatform {
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
abstract async getConfig(): Promise<{}>;
abstract getConfig(): Promise<{}>;
abstract getDefaultDeviceDisplayName(): string;
@ -105,6 +109,9 @@ export default abstract class BasePlatform {
* @param newVersion the version string to check
*/
protected shouldShowUpdate(newVersion: string): boolean {
// If the user registered on this client in the last 24 hours then do not show them the update toast
if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
try {
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
return newVersion !== version || Date.now() > deferUntil;
@ -244,15 +251,19 @@ export default abstract class BasePlatform {
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
* @param {string} idpId The ID of the Identity Provider being targeted, optional.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) {
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string, idpId?: string) {
// persist hs url and is url for when the user is returned to the app with the login token
localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
if (mxClient.getIdentityServerUrl()) {
localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
}
if (idpId) {
localStorage.setItem(SSO_IDP_ID_KEY, idpId);
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
}
onKeyDown(ev: KeyboardEvent): boolean {
@ -268,7 +279,40 @@ export default abstract class BasePlatform {
* pickle key has been stored.
*/
async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
return null;
if (!window.crypto || !window.crypto.subtle) {
return null;
}
let data;
try {
data = await idbLoad("pickleKey", [userId, deviceId]);
} catch (e) {}
if (!data) {
return null;
}
if (!data.encrypted || !data.iv || !data.cryptoKey) {
console.error("Badly formatted pickle key");
return null;
}
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
try {
const key = await crypto.subtle.decrypt(
{name: "AES-GCM", iv: data.iv, additionalData}, data.cryptoKey,
data.encrypted,
);
return encodeUnpaddedBase64(key);
} catch (e) {
console.error("Error decrypting pickle key");
return null;
}
}
/**
@ -279,7 +323,37 @@ export default abstract class BasePlatform {
* support storing pickle keys.
*/
async createPickleKey(userId: string, deviceId: string): Promise<string | null> {
return null;
if (!window.crypto || !window.crypto.subtle) {
return null;
}
const crypto = window.crypto;
const randomArray = new Uint8Array(32);
crypto.getRandomValues(randomArray);
const cryptoKey = await crypto.subtle.generateKey(
{name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"],
);
const iv = new Uint8Array(32);
crypto.getRandomValues(iv);
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
const encrypted = await crypto.subtle.encrypt(
{name: "AES-GCM", iv, additionalData}, cryptoKey, randomArray,
);
try {
await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey});
} catch (e) {
return null;
}
return encodeUnpaddedBase64(randomArray);
}
/**
@ -288,5 +362,8 @@ export default abstract class BasePlatform {
* @param {string} userId the device ID that the pickle key is for.
*/
async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try {
await idbDelete("pickleKey", [userId, deviceId]);
} catch (e) {}
}
}

View file

@ -59,13 +59,11 @@ 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 { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call';
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";
@ -77,13 +75,94 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import WidgetStore from "./stores/WidgetStore";
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
import Analytics from './Analytics';
import CountlyAnalytics from "./CountlyAnalytics";
import {UIFeature} from "./settings/UIFeature";
import { CallError } from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker"
import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomString } from "matrix-js-sdk/src/randomstring";
// until we ts-ify the js-sdk voip code
type Call = any;
export const PROTOCOL_PSTN = 'm.protocol.pstn';
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
export const PROTOCOL_SIP_NATIVE = 'im.vector.protocol.sip_native';
export const PROTOCOL_SIP_VIRTUAL = 'im.vector.protocol.sip_virtual';
const CHECK_PROTOCOLS_ATTEMPTS = 3;
// Event type for room account data and room creation content used to mark rooms as virtual rooms
// (and store the ID of their native room)
export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room';
enum AudioID {
Ring = 'ringAudio',
Ringback = 'ringbackAudio',
CallEnd = 'callendAudio',
Busy = 'busyAudio',
}
interface ThirdpartyLookupResponseFields {
/* eslint-disable camelcase */
// im.vector.sip_native
virtual_mxid?: string;
is_virtual?: boolean;
// im.vector.sip_virtual
native_mxid?: string;
is_native?: boolean;
// common
lookup_success?: boolean;
/* eslint-enable camelcase */
}
interface ThirdpartyLookupResponse {
userid: string,
protocol: string,
fields: ThirdpartyLookupResponseFields,
}
// Unlike 'CallType' in js-sdk, this one includes screen sharing
// (because a screen sharing call is only a screen sharing call to the caller,
// to the callee it's just a video call, at least as far as the current impl
// is concerned).
export enum PlaceCallType {
Voice = 'voice',
Video = 'video',
ScreenSharing = 'screensharing',
}
function getRemoteAudioElement(): HTMLAudioElement {
// this needs to be somewhere at the top of the DOM which
// always exists to avoid audio interruptions.
// Might as well just use DOM.
const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement;
if (!remoteAudioElement) {
console.error(
"Failed to find remoteAudio element - cannot play audio!" +
"You need to add an <audio/> to the DOM.",
);
return null;
}
return remoteAudioElement;
}
export default class CallHandler {
private calls = new Map<string, Call>();
private audioPromises = new Map<string, Promise<void>>();
private calls = new Map<string, MatrixCall>(); // roomId -> call
private audioPromises = new Map<AudioID, Promise<void>>();
private dispatcherRef: string = null;
private supportsPstnProtocol = null;
private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser
// For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
private invitedRoomsAreVirtual = new Map<string, boolean>();
private invitedRoomCheckInProgress = false;
static sharedInstance() {
if (!window.mxCallHandler) {
@ -93,8 +172,17 @@ export default class CallHandler {
return window.mxCallHandler;
}
constructor() {
dis.register(this.onAction);
/*
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
* if a voip_mxid_translate_pattern is set in the config)
*/
public static roomIdForCall(call: MatrixCall): string {
if (!call) return null;
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
}
start() {
this.dispatcherRef = dis.register(this.onAction);
// add empty handlers for media actions, otherwise the media keys
// end up causing the audio elements with our ring/ringback etc
// audio clips in to play.
@ -106,22 +194,138 @@ export default class CallHandler {
navigator.mediaSession.setActionHandler('previoustrack', function() {});
navigator.mediaSession.setActionHandler('nexttrack', function() {});
}
if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
}
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
}
getCallForRoom(roomId: string): Call {
stop() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener('Call.incoming', this.onCallIncoming);
}
if (this.dispatcherRef !== null) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
}
private async checkProtocols(maxTries) {
try {
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
if (protocols[PROTOCOL_PSTN] !== undefined) {
this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN]);
if (this.supportsPstnProtocol) this.pstnSupportPrefixed = false;
} else if (protocols[PROTOCOL_PSTN_PREFIXED] !== undefined) {
this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN_PREFIXED]);
if (this.supportsPstnProtocol) this.pstnSupportPrefixed = true;
} else {
this.supportsPstnProtocol = null;
}
dis.dispatch({action: Action.PstnSupportUpdated});
if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
this.supportsSipNativeVirtual = Boolean(
protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
);
}
dis.dispatch({action: Action.VirtualRoomSupportUpdated});
} catch (e) {
if (maxTries === 1) {
console.log("Failed to check for protocol support and no retries remain: assuming no support", e);
} else {
console.log("Failed to check for protocol support: will retry", e);
this.pstnSupportCheckTimer = setTimeout(() => {
this.checkProtocols(maxTries - 1);
}, 10000);
}
}
}
public getSupportsPstnProtocol() {
return this.supportsPstnProtocol;
}
public getSupportsVirtualRooms() {
return this.supportsPstnProtocol;
}
public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
return MatrixClientPeg.get().getThirdpartyUser(
this.pstnSupportPrefixed ? PROTOCOL_PSTN_PREFIXED : PROTOCOL_PSTN, {
'm.id.phone': phoneNumber,
},
);
}
public sipVirtualLookup(nativeMxid: string): Promise<ThirdpartyLookupResponse[]> {
return MatrixClientPeg.get().getThirdpartyUser(
PROTOCOL_SIP_VIRTUAL, {
'native_mxid': nativeMxid,
},
);
}
public sipNativeLookup(virtualMxid: string): Promise<ThirdpartyLookupResponse[]> {
return MatrixClientPeg.get().getThirdpartyUser(
PROTOCOL_SIP_NATIVE, {
'virtual_mxid': virtualMxid,
},
);
}
private onCallIncoming = (call) => {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
// we get an immediate hangup, we don't get a stuck call)
dis.dispatch({
action: 'incoming_call',
call: call,
}, true);
}
getCallForRoom(roomId: string): MatrixCall {
return this.calls.get(roomId) || null;
}
getAnyActiveCall() {
for (const call of this.calls.values()) {
if (call.state !== "ended") {
if (call.state !== CallState.Ended) {
return call;
}
}
return null;
}
play(audioId: string) {
getAllActiveCalls() {
const activeCalls = [];
for (const call of this.calls.values()) {
if (call.state !== CallState.Ended && call.state !== CallState.Ringing) {
activeCalls.push(call);
}
}
return activeCalls;
}
getAllActiveCallsNotInRoom(notInThisRoomId) {
const callsNotInThatRoom = [];
for (const [roomId, call] of this.calls.entries()) {
if (roomId !== notInThisRoomId && call.state !== CallState.Ended) {
callsNotInThatRoom.push(call);
}
}
return callsNotInThatRoom;
}
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -150,7 +354,7 @@ export default class CallHandler {
}
}
pause(audioId: string) {
pause(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -164,9 +368,30 @@ export default class CallHandler {
}
}
private setCallListeners(call: Call) {
call.on("error", (err) => {
private matchesCallForThisRoom(call: MatrixCall) {
// We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room.
const mappedRoomId = CallHandler.roomIdForCall(call);
const callForThisRoom = this.getCallForRoom(mappedRoomId);
return callForThisRoom && call.callId === callForThisRoom.callId;
}
private setCallListeners(call: MatrixCall) {
const mappedRoomId = CallHandler.roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callError', 'error', err.toString());
console.error("Call error:", err);
if (err.code === CallErrorCode.NoUserMedia) {
this.showMediaCaptureError(call);
return;
}
if (
MatrixClientPeg.get().getTurnServers().length === 0 &&
SettingsStore.getValue("fallbackICEServerAllowed") === null
@ -180,74 +405,160 @@ export default class CallHandler {
description: err.message,
});
});
call.on("hangup", () => {
this.removeCallForRoom(call.roomId);
call.on(CallEvent.Hangup, () => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callHangup');
this.removeCallForRoom(mappedRoomId);
});
// map web rtc states to dummy UI state
// ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
call.on("state", (newState, oldState) => {
if (newState === "ringing") {
this.setCallState(call, call.roomId, "ringing");
this.pause("ringbackAudio");
} else if (newState === "invite_sent") {
this.setCallState(call, call.roomId, "ringback");
this.play("ringbackAudio");
} else if (newState === "ended" && oldState === "connected") {
this.removeCallForRoom(call.roomId);
this.pause("ringbackAudio");
this.play("callendAudio");
} else if (newState === "ended" && oldState === "invite_sent" &&
(call.hangupParty === "remote" ||
(call.hangupParty === "local" && call.hangupReason === "invite_timeout")
)) {
this.setCallState(call, call.roomId, "busy");
this.pause("ringbackAudio");
this.play("busyAudio");
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") {
this.setCallState(call, call.roomId, "stop_ringback");
this.pause("ringbackAudio");
} else if (oldState === "ringing") {
this.setCallState(call, call.roomId, "stop_ringing");
this.pause("ringbackAudio");
} else if (newState === "connected") {
this.setCallState(call, call.roomId, "connected");
this.pause("ringbackAudio");
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
if (!this.matchesCallForThisRoom(call)) return;
this.setCallState(call, newState);
switch (oldState) {
case CallState.Ringing:
this.pause(AudioID.Ring);
break;
case CallState.InviteSent:
this.pause(AudioID.Ringback);
break;
}
switch (newState) {
case CallState.Ringing:
this.play(AudioID.Ring);
break;
case CallState.InviteSent:
this.play(AudioID.Ringback);
break;
case CallState.Ended:
{
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
this.removeCallForRoom(mappedRoomId);
if (oldState === CallState.InviteSent && (
call.hangupParty === CallParty.Remote ||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
)) {
this.play(AudioID.Busy);
let title;
let description;
if (call.hangupReason === CallErrorCode.UserHangup) {
title = _t("Call Declined");
description = _t("The other party declined the call.");
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
title = _t("Call Failed");
// XXX: full stop appended as some relic here, but these
// strings need proper input from design anyway, so let's
// not change this string until we have a proper one.
description = _t('The remote side failed to pick up') + '.';
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
}
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title, description,
});
} else if (
call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
) {
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title: _t("Answered Elsewhere"),
description: _t("The call was answered on another device."),
});
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
// don't play the end-call sound for calls that never got off the ground
this.play(AudioID.CallEnd);
}
this.logCallStats(call, mappedRoomId);
break;
}
}
});
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
if (call.state === CallState.Ringing) {
this.pause(AudioID.Ring);
} else if (call.state === CallState.InviteSent) {
this.pause(AudioID.Ringback);
}
this.calls.set(mappedRoomId, newCall);
this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state);
});
}
private setCallState(call: Call, roomId: string, status: string) {
console.log(
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
const stats = await call.getCurrentCallStats();
logger.debug(
`Call completed. Call ID: ${call.callId}, virtual room ID: ${call.roomId}, ` +
`user-facing room ID: ${mappedRoomId}, direction: ${call.direction}, ` +
`our Party ID: ${call.ourPartyId}, hangup party: ${call.hangupParty}, ` +
`hangup reason: ${call.hangupReason}`,
);
if (call) {
this.calls.set(roomId, call);
} else {
this.calls.delete(roomId);
if (!stats) {
logger.debug(
"Call statistics are undefined. The call has " +
"probably failed before a peerConn was established",
);
return;
}
logger.debug("Local candidates:");
for (const cand of stats.filter(item => item.type === 'local-candidate')) {
const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
logger.debug(
`${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
`protocol: ${cand.protocol}, relay protocol: ${cand.relayProtocol}, network type: ${cand.networkType}`,
);
}
logger.debug("Remote candidates:");
for (const cand of stats.filter(item => item.type === 'remote-candidate')) {
const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
logger.debug(
`${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
`protocol: ${cand.protocol}`,
);
}
logger.debug("Candidate pairs:");
for (const pair of stats.filter(item => item.type === 'candidate-pair')) {
logger.debug(
`${pair.localCandidateId} / ${pair.remoteCandidateId} - state: ${pair.state}, ` +
`nominated: ${pair.nominated}, ` +
`requests sent ${pair.requestsSent}, requests received ${pair.requestsReceived}, ` +
`responses received: ${pair.responsesReceived}, responses sent: ${pair.responsesSent}, ` +
`bytes received: ${pair.bytesReceived}, bytes sent: ${pair.bytesSent}, `,
);
}
}
if (status === "ringing") {
this.play("ringAudio");
} else if (call && call.call_state === "ringing") {
this.pause("ringAudio");
}
private setCallAudioElement(call: MatrixCall) {
const audioElement = getRemoteAudioElement();
if (audioElement) call.setRemoteAudioElement(audioElement);
}
private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.roomIdForCall(call);
console.log(
`Call state in ${mappedRoomId} changed to ${status}`,
);
if (call) {
call.call_state = status;
}
dis.dispatch({
action: 'call_state',
room_id: roomId,
room_id: mappedRoomId,
state: status,
});
}
private removeCallForRoom(roomId: string) {
this.setCallState(null, roomId, null);
this.calls.delete(roomId);
}
private showICEFallbackPrompt() {
@ -279,45 +590,94 @@ export default class CallHandler {
}, null, true);
}
private onAction = (payload: ActionPayload) => {
const placeCall = (newCall) => {
this.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) {
this.removeCallForRoom(newCall.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;
}
newCall.placeScreenSharingCall(
payload.remote_element,
payload.local_element,
);
} else {
console.error("Unknown conf call type: %s", payload.type);
}
private showMediaCaptureError(call: MatrixCall) {
let title;
let description;
if (call.type === CallType.Voice) {
title = _t("Unable to access microphone");
description = <div>
{_t(
"Call failed because microphone could not be accessed. " +
"Check that a microphone is plugged in and set up correctly.",
)}
</div>;
} else if (call.type === CallType.Video) {
title = _t("Unable to access webcam / microphone");
description = <div>
{_t("Call failed because webcam or microphone could not be accessed. Check that:")}
<ul>
<li>{_t("A microphone and webcam are plugged in and set up correctly")}</li>
<li>{_t("Permission is granted to use the webcam")}</li>
<li>{_t("No other application is using the webcam")}</li>
</ul>
</div>;
}
Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, {
title, description,
}, null, true);
}
private async placeCall(
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId;
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
this.calls.set(roomId, call);
this.setCallListeners(call);
this.setCallAudioElement(call);
this.setActiveCallRoomId(roomId);
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,
async () : Promise<DesktopCapturerSource> => {
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
return source;
});
} else {
console.error("Unknown conf call type: " + type);
}
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'place_call':
{
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.
// We might be using managed hybrid widgets
if (isManagedHybridWidgetEnabled()) {
addManagedHybridWidget(payload.room_id);
return;
}
// if the runtime env doesn't do VoIP, whine.
@ -329,9 +689,18 @@ export default class CallHandler {
return;
}
// don't allow > 2 calls to be placed.
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) {
console.error("Room %s does not exist.", payload.room_id);
console.error(`Room ${payload.room_id} does not exist.`);
return;
}
@ -342,9 +711,9 @@ export default class CallHandler {
});
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);
console.info(`Place ${payload.type} call in ${payload.room_id}`);
this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
@ -357,58 +726,112 @@ export default class CallHandler {
}
break;
case 'place_conference_call':
console.info("Place conference call in %s", payload.room_id);
console.info("Place conference call in " + payload.room_id);
Analytics.trackEvent('voip', 'placeConferenceCall');
CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true);
this.startCallApp(payload.room_id, payload.type);
break;
case 'end_conference':
console.info("Terminating conference call in %s", payload.room_id);
console.info("Terminating conference call in " + payload.room_id);
this.terminateCallApp(payload.room_id);
break;
case 'hangup_conference':
console.info("Leaving conference call in %s", payload.room_id);
console.info("Leaving conference call in "+ 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;
const call = payload.call as MatrixCall;
const mappedRoomId = CallHandler.roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
// ignore multiple incoming calls to the same room
return;
}
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(mappedRoomId, call)
this.setCallListeners(call);
this.setCallState(call, call.roomId, "ringing");
// get ready to send encrypted events in the room, so if the user does answer
// the call, we'll be ready to send. NB. This is the protocol-level room ID not
// the mapped one: that's where we'll send the events.
const cli = MatrixClientPeg.get();
cli.prepareToEncrypt(cli.getRoom(call.roomId));
}
break;
case 'hangup':
case 'reject':
if (!this.calls.get(payload.room_id)) {
return; // no call to hangup
}
this.calls.get(payload.room_id).hangup();
this.removeCallForRoom(payload.room_id);
if (payload.action === 'reject') {
this.calls.get(payload.room_id).reject();
} else {
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
}
// don't remove the call yet: let the hangup event handler do it (otherwise it will throw
// the hangup event away)
break;
case 'answer':
if (!this.calls.get(payload.room_id)) {
case 'answer': {
if (!this.calls.has(payload.room_id)) {
return; // no call to answer
}
this.calls.get(payload.room_id).answer();
this.setCallState(this.calls.get(payload.room_id), payload.room_id, "connected");
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const call = this.calls.get(payload.room_id);
call.answer();
this.setCallAudioElement(call);
this.setActiveCallRoomId(payload.room_id);
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
dis.dispatch({
action: "view_room",
room_id: payload.room_id,
});
break;
}
}
}
setActiveCallRoomId(activeCallRoomId: string) {
logger.info("Setting call in room " + activeCallRoomId + " active");
for (const [roomId, call] of this.calls.entries()) {
if (call.state === CallState.Ended) continue;
if (roomId === activeCallRoomId) {
call.setRemoteOnHold(false);
} else {
logger.info("Holding call in room " + roomId + " because another call is being set active");
call.setRemoteOnHold(true);
}
}
}
/**
* @returns true if we are currently in any call where we haven't put the remote party on hold
*/
hasAnyUnheldCall() {
for (const call of this.calls.values()) {
if (call.state === CallState.Ended) continue;
if (!call.isRemoteOnHold()) return true;
}
return false;
}
private async startCallApp(roomId: string, type: string) {
dis.dispatch({
action: 'appsDrawer',
@ -439,7 +862,7 @@ export default class CallHandler {
confId = base32.stringify(Buffer.from(roomId), { pad: false });
} else {
// Create a random human readable conference ID
confId = `JitsiConference${generateHumanReadableId()}`;
confId = `JitsiConference${randomString(32)}`;
}
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
@ -455,6 +878,7 @@ export default class CallHandler {
isAudioOnly: type === 'voice',
domain: jitsiDomain,
auth: jitsiAuth,
roomName: room.name,
};
const widgetId = (

View file

@ -17,7 +17,6 @@ limitations under the License.
*/
import React from "react";
import extend from './extend';
import dis from './dispatcher/dispatcher';
import {MatrixClientPeg} from './MatrixClientPeg';
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -32,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
import "blueimp-canvas-to-blob";
import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -369,10 +369,13 @@ export default class ContentMessages {
private mediaConfig: IMediaConfig = null;
sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) {
return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
const startTime = CountlyAnalytics.getTimestamp();
const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e;
});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"});
return prom;
}
getUploadLimit() {
@ -480,6 +483,7 @@ export default class ContentMessages {
}
private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) {
const startTime = CountlyAnalytics.getTimestamp();
const content: IContent = {
body: file.name || 'Attachment',
info: {
@ -493,11 +497,11 @@ export default class ContentMessages {
content.info.mimetype = file.type;
}
const prom = new Promise((resolve) => {
const prom = new Promise<void>((resolve) => {
if (file.type.indexOf('image/') === 0) {
content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
extend(content.info, imageInfo);
Object.assign(content.info, imageInfo);
resolve();
}, (e) => {
console.error(e);
@ -510,7 +514,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';
@ -564,7 +568,9 @@ export default class ContentMessages {
return promBefore;
}).then(function() {
if (upload.canceled) throw new UploadCanceledError();
return matrixClient.sendMessage(roomId, content);
const prom = matrixClient.sendMessage(roomId, content);
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content);
return prom;
}, function(err) {
error = err;
if (!upload.canceled) {

973
src/CountlyAnalytics.ts Normal file
View file

@ -0,0 +1,973 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {randomString} from "matrix-js-sdk/src/randomstring";
import {getCurrentLanguage} from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import {MatrixClientPeg} from "./MatrixClientPeg";
import {sleep} from "./utils/promise";
import RoomViewStore from "./stores/RoomViewStore";
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
const INACTIVITY_TIME = 20; // seconds
const HEARTBEAT_INTERVAL = 5_000; // ms
const SESSION_UPDATE_INTERVAL = 60; // seconds
const MAX_PENDING_EVENTS = 1000;
enum Orientation {
Landscape = "landscape",
Portrait = "portrait",
}
/* eslint-disable camelcase */
interface IMetrics {
_resolution?: string;
_app_version?: string;
_density?: number;
_ua?: string;
_locale?: string;
}
interface IEvent {
key: string;
count: number;
sum?: number;
dur?: number;
segmentation?: Record<string, unknown>;
timestamp?: number; // TODO should we use the timestamp when we start or end for the event timestamp
hour?: unknown;
dow?: unknown;
}
interface IViewEvent extends IEvent {
key: "[CLY]_view";
}
interface IOrientationEvent extends IEvent {
key: "[CLY]_orientation";
segmentation: {
mode: Orientation;
};
}
interface IStarRatingEvent extends IEvent {
key: "[CLY]_star_rating";
segmentation: {
// we just care about collecting feedback, no need to associate with a feedback widget
widget_id?: string;
contactMe?: boolean;
email?: string;
rating: 1 | 2 | 3 | 4 | 5;
comment: string;
};
}
type Value = string | number | boolean;
interface IOperationInc {
"$inc": number;
}
interface IOperationMul {
"$mul": number;
}
interface IOperationMax {
"$max": number;
}
interface IOperationMin {
"$min": number;
}
interface IOperationSetOnce {
"$setOnce": Value;
}
interface IOperationPush {
"$push": Value | Value[];
}
interface IOperationAddToSet {
"$addToSet": Value | Value[];
}
interface IOperationPull {
"$pull": Value | Value[];
}
type Operation =
IOperationInc |
IOperationMul |
IOperationMax |
IOperationMin |
IOperationSetOnce |
IOperationPush |
IOperationAddToSet |
IOperationPull;
interface IUserDetails {
name?: string;
username?: string;
email?: string;
organization?: string;
phone?: string;
picture?: string;
gender?: string;
byear?: number;
custom?: Record<string, Value | Operation>; // `.` and `$` will be stripped out
}
interface ICrash {
_resolution?: string;
_app_version: string;
_ram_current?: number;
_ram_total?: number;
_disk_current?: number;
_disk_total?: number;
_orientation?: Orientation;
_online?: boolean;
_muted?: boolean;
_background?: boolean;
_view?: string;
_name?: string;
_error: string;
_nonfatal?: boolean;
_logs?: string;
_run?: number;
_custom?: Record<string, string>;
}
interface IParams {
// APP_KEY of an app for which to report
app_key: string;
// User identifier
device_id: string;
// Should provide value 1 to indicate session start
begin_session?: number;
// JSON object as string to provide metrics to track with the user
metrics?: string;
// Provides session duration in seconds, can be used as heartbeat to update current sessions duration, recommended time every 60 seconds
session_duration?: number;
// Should provide value 1 to indicate session end
end_session?: number;
// 10 digit UTC timestamp for recording past data.
timestamp?: number;
// current user local hour (0 - 23)
hour?: number;
// day of the week (0-sunday, 1 - monday, ... 6 - saturday)
dow?: number;
// JSON array as string containing event objects
events?: string; // IEvent[]
// JSON object as string containing information about users
user_details?: string;
// provide when changing device ID, so server would merge the data
old_device_id?: string;
// See ICrash
crash?: string;
}
interface IRoomSegments extends Record<string, Value> {
room_id: string; // hashed
num_users: number;
is_encrypted: boolean;
is_public: boolean;
}
interface ISendMessageEvent extends IEvent {
key: "send_message";
dur: number; // how long it to send (until remote echo)
segmentation: IRoomSegments & {
is_edit: boolean;
is_reply: boolean;
msgtype: string;
format?: string;
};
}
interface IRoomDirectoryEvent extends IEvent {
key: "room_directory";
}
interface IRoomDirectoryDoneEvent extends IEvent {
key: "room_directory_done";
dur: number; // time spent in the room directory modal
}
interface IRoomDirectorySearchEvent extends IEvent {
key: "room_directory_search";
sum: number; // number of search results
segmentation: {
query_length: number;
query_num_words: number;
};
}
interface IStartCallEvent extends IEvent {
key: "start_call";
segmentation: IRoomSegments & {
is_video: boolean;
is_jitsi: boolean;
};
}
interface IJoinCallEvent extends IEvent {
key: "join_call";
segmentation: IRoomSegments & {
is_video: boolean;
is_jitsi: boolean;
};
}
interface IBeginInviteEvent extends IEvent {
key: "begin_invite";
segmentation: IRoomSegments;
}
interface ISendInviteEvent extends IEvent {
key: "send_invite";
sum: number; // quantity that was invited
segmentation: IRoomSegments;
}
interface ICreateRoomEvent extends IEvent {
key: "create_room";
dur: number; // how long it took to create (until remote echo)
segmentation: {
room_id: string; // hashed
num_users: number;
is_encrypted: boolean;
is_public: boolean;
}
}
interface IJoinRoomEvent extends IEvent {
key: "join_room";
dur: number; // how long it took to join (until remote echo)
segmentation: {
room_id: string; // hashed
num_users: number;
is_encrypted: boolean;
is_public: boolean;
type: "room_directory" | "slash_command" | "link" | "invite";
};
}
/* eslint-enable camelcase */
const hashHex = async (input: string): Promise<string> => {
const buf = new TextEncoder().encode(input);
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
};
const knownScreens = new Set([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
]);
interface IViewData {
name: string;
url: string;
meta: Record<string, string>;
}
// Apply fn to all hash path parts after the 1st one
async function getViewData(anonymous = true): Promise<IViewData> {
const rand = randomString(8);
const { origin, hash } = window.location;
let { pathname } = window.location;
// Redact paths which could contain unexpected PII
if (origin.startsWith('file://')) {
pathname = `/<redacted_${rand}>/`; // XXX: inject rand because Count.ly doesn't like X->X transitions
}
let [_, screen, ...parts] = hash.split("/");
if (!knownScreens.has(screen)) {
screen = `<redacted_${rand}>`;
}
for (let i = 0; i < parts.length; i++) {
parts[i] = anonymous ? `<redacted_${rand}>` : await hashHex(parts[i]);
}
const hashStr = `${_}/${screen}/${parts.join("/")}`;
const url = origin + pathname + hashStr;
const meta = {};
let name = "$/" + hash;
switch (screen) {
case "room": {
name = "view_room";
const roomId = RoomViewStore.getRoomId();
name += " " + parts[0]; // XXX: workaround Count.ly missing X->X transitions
meta["room_id"] = parts[0];
Object.assign(meta, getRoomStats(roomId));
break;
}
}
return { name, url, meta };
}
const getRoomStats = (roomId: string) => {
const cli = MatrixClientPeg.get();
const room = cli?.getRoom(roomId);
return {
"num_users": room?.getJoinedMemberCount(),
"is_encrypted": cli?.isRoomEncrypted(roomId),
// eslint-disable-next-line camelcase
"is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public",
}
}
// async wrapper for regex-powered String.prototype.replace
const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise<string>) => {
const promises: Promise<string>[] = [];
// dry-run to calculate the replace values
str.replace(regex, (...args: string[]) => {
promises.push(fn(...args));
return "";
});
const values = await Promise.all(promises);
return str.replace(regex, () => values.shift());
};
export default class CountlyAnalytics {
private baseUrl: URL = null;
private appKey: string = null;
private userKey: string = null;
private anonymous: boolean;
private appPlatform: string;
private appVersion = "unknown";
private initTime = CountlyAnalytics.getTimestamp();
private firstPage = true;
private heartbeatIntervalId: NodeJS.Timeout;
private activityIntervalId: NodeJS.Timeout;
private trackTime = true;
private lastBeat: number;
private storedDuration = 0;
private lastView: string;
private lastViewTime = 0;
private lastViewStoredDuration = 0;
private sessionStarted = false;
private heartbeatEnabled = false;
private inactivityCounter = 0;
private pendingEvents: IEvent[] = [];
private static internalInstance = new CountlyAnalytics();
public static get instance(): CountlyAnalytics {
return CountlyAnalytics.internalInstance;
}
public get disabled() {
return !this.baseUrl;
}
public canEnable() {
const config = SdkConfig.get();
return Boolean(navigator.doNotTrack !== "1" && config?.countly?.url && config?.countly?.appKey);
}
private async changeUserKey(userKey: string, merge = false) {
const oldUserKey = this.userKey;
this.userKey = userKey;
if (oldUserKey && merge) {
await this.request({ old_device_id: oldUserKey });
}
}
public async enable(anonymous = true) {
if (!this.disabled && this.anonymous === anonymous) return;
if (!this.canEnable()) return;
if (!this.disabled) {
// flush request queue as our userKey is going to change, no need to await it
this.request();
}
const config = SdkConfig.get();
this.baseUrl = new URL("/i", config.countly.url);
this.appKey = config.countly.appKey;
this.anonymous = anonymous;
if (anonymous) {
await this.changeUserKey(randomString(64))
} else {
await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true);
}
const platform = PlatformPeg.get();
this.appPlatform = platform.getHumanReadableName();
try {
this.appVersion = await platform.getAppVersion();
} catch (e) {
console.warn("Failed to get app version, using 'unknown'");
}
// start heartbeat
this.heartbeatIntervalId = setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL);
this.trackSessions();
this.trackErrors();
}
public async disable() {
if (this.disabled) return;
await this.track("Opt-Out" );
this.endSession();
window.clearInterval(this.heartbeatIntervalId);
window.clearTimeout(this.activityIntervalId)
this.baseUrl = null;
// remove listeners bound in trackSessions()
window.removeEventListener("beforeunload", this.endSession);
window.removeEventListener("unload", this.endSession);
window.removeEventListener("visibilitychange", this.onVisibilityChange);
window.removeEventListener("mousemove", this.onUserActivity);
window.removeEventListener("click", this.onUserActivity);
window.removeEventListener("keydown", this.onUserActivity);
window.removeEventListener("scroll", this.onUserActivity);
}
public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) {
this.track<IStarRatingEvent>("[CLY]_star_rating", { rating, comment }, null, {}, true);
}
public trackPageChange(generationTimeMs?: number) {
if (this.disabled) return;
// TODO use generationTimeMs
this.trackPageView();
}
private async trackPageView() {
this.reportViewDuration();
await sleep(0); // XXX: we sleep here because otherwise we get the old hash and not the new one
const viewData = await getViewData(this.anonymous);
const page = viewData.name;
this.lastView = page;
this.lastViewTime = CountlyAnalytics.getTimestamp();
const segments = {
...viewData.meta,
name: page,
visit: 1,
domain: window.location.hostname,
view: viewData.url,
segment: this.appPlatform,
start: this.firstPage,
};
if (this.firstPage) {
this.firstPage = false;
}
this.track<IViewEvent>("[CLY]_view", segments);
}
public static getTimestamp() {
return Math.floor(new Date().getTime() / 1000);
}
// store the last ms timestamp returned
// we do this to prevent the ts from ever decreasing in the case of system time changing
private lastMsTs = 0;
private getMsTimestamp() {
const ts = new Date().getTime();
if (this.lastMsTs >= ts) {
// increment ts as to keep our data points well-ordered
this.lastMsTs++;
} else {
this.lastMsTs = ts;
}
return this.lastMsTs;
}
public async recordError(err: Error | string, fatal = false) {
if (this.disabled || this.anonymous) return;
let error = "";
if (typeof err === "object") {
if (typeof err.stack !== "undefined") {
error = err.stack;
} else {
if (typeof err.name !== "undefined") {
error += err.name + ":";
}
if (typeof err.message !== "undefined") {
error += err.message + "\n";
}
if (typeof err.fileName !== "undefined") {
error += "in " + err.fileName + "\n";
}
if (typeof err.lineNumber !== "undefined") {
error += "on " + err.lineNumber;
}
if (typeof err.columnNumber !== "undefined") {
error += ":" + err.columnNumber;
}
}
} else {
error = err + "";
}
// sanitize the error from identifiers
error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => {
return glyph + await hashHex(substring.substring(1));
});
const metrics = this.getMetrics();
const ob: ICrash = {
_resolution: metrics?._resolution,
_error: error,
_app_version: this.appVersion,
_run: CountlyAnalytics.getTimestamp() - this.initTime,
_nonfatal: !fatal,
_view: this.lastView,
};
if (typeof navigator.onLine !== "undefined") {
ob._online = navigator.onLine;
}
ob._background = document.hasFocus();
this.request({ crash: JSON.stringify(ob) });
}
private trackErrors() {
//override global uncaught error handler
window.onerror = (msg, url, line, col, err) => {
if (typeof err !== "undefined") {
this.recordError(err, false);
} else {
let error = "";
if (typeof msg !== "undefined") {
error += msg + "\n";
}
if (typeof url !== "undefined") {
error += "at " + url;
}
if (typeof line !== "undefined") {
error += ":" + line;
}
if (typeof col !== "undefined") {
error += ":" + col;
}
error += "\n";
try {
const stack = [];
// eslint-disable-next-line no-caller
let f = arguments.callee.caller;
while (f) {
stack.push(f.name);
f = f.caller;
}
error += stack.join("\n");
} catch (ex) {
//silent error
}
this.recordError(error, false);
}
};
window.addEventListener('unhandledrejection', (event) => {
this.recordError(new Error(`Unhandled rejection (reason: ${event.reason?.stack || event.reason}).`), true);
});
}
private heartbeat() {
const args: Pick<IParams, "session_duration"> = {};
// extend session if needed
if (this.sessionStarted && this.trackTime) {
const last = CountlyAnalytics.getTimestamp();
if (last - this.lastBeat >= SESSION_UPDATE_INTERVAL) {
args.session_duration = last - this.lastBeat;
this.lastBeat = last;
}
}
// process event queue
if (this.pendingEvents.length > 0 || args.session_duration) {
this.request(args);
}
}
private async request(
args: Omit<IParams, "app_key" | "device_id" | "timestamp" | "hour" | "dow">
& Partial<Pick<IParams, "device_id">> = {},
) {
const request: IParams = {
app_key: this.appKey,
device_id: this.userKey,
...this.getTimeParams(),
...args,
};
if (this.pendingEvents.length > 0) {
const EVENT_BATCH_SIZE = 10;
const events = this.pendingEvents.splice(0, EVENT_BATCH_SIZE);
request.events = JSON.stringify(events);
}
const params = new URLSearchParams(request as {});
try {
await window.fetch(this.baseUrl.toString(), {
method: "POST",
mode: "no-cors",
cache: "no-cache",
redirect: "follow",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});
} catch (e) {
console.error("Analytics error: ", e);
}
}
private getTimeParams(): Pick<IParams, "timestamp" | "hour" | "dow"> {
const date = new Date();
return {
timestamp: this.getMsTimestamp(),
hour: date.getHours(),
dow: date.getDay(),
};
}
private queue(args: Omit<IEvent, "timestamp" | "hour" | "dow" | "count"> & Partial<Pick<IEvent, "count">>) {
const {count = 1, ...rest} = args;
const ev = {
...this.getTimeParams(),
...rest,
count,
platform: this.appPlatform,
app_version: this.appVersion,
}
this.pendingEvents.push(ev);
if (this.pendingEvents.length > MAX_PENDING_EVENTS) {
this.pendingEvents.shift();
}
}
private getOrientation = (): Orientation => {
return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait;
};
private reportOrientation = () => {
this.track<IOrientationEvent>("[CLY]_orientation", {
mode: this.getOrientation(),
});
};
private startTime() {
if (!this.trackTime) {
this.trackTime = true;
this.lastBeat = CountlyAnalytics.getTimestamp() - this.storedDuration;
this.lastViewTime = CountlyAnalytics.getTimestamp() - this.lastViewStoredDuration;
this.lastViewStoredDuration = 0;
}
}
private stopTime() {
if (this.trackTime) {
this.trackTime = false;
this.storedDuration = CountlyAnalytics.getTimestamp() - this.lastBeat;
this.lastViewStoredDuration = CountlyAnalytics.getTimestamp() - this.lastViewTime;
}
}
private getMetrics(): IMetrics {
if (this.anonymous) return undefined;
const metrics: IMetrics = {};
// getting app version
metrics._app_version = this.appVersion;
metrics._ua = navigator.userAgent;
// getting resolution
if (screen.width && screen.height) {
metrics._resolution = `${screen.width}x${screen.height}`;
}
// getting density ratio
if (window.devicePixelRatio) {
metrics._density = window.devicePixelRatio;
}
// getting locale
metrics._locale = getCurrentLanguage();
return metrics;
}
private async beginSession(heartbeat = true) {
if (!this.sessionStarted) {
this.reportOrientation();
window.addEventListener("resize", this.reportOrientation);
this.lastBeat = CountlyAnalytics.getTimestamp();
this.sessionStarted = true;
this.heartbeatEnabled = heartbeat;
const userDetails: IUserDetails = {
custom: {
"home_server": MatrixClientPeg.get() && MatrixClientPeg.getHomeserverName(), // TODO hash?
"anonymous": this.anonymous,
},
};
const request: Parameters<typeof CountlyAnalytics.prototype.request>[0] = {
begin_session: 1,
user_details: JSON.stringify(userDetails),
}
const metrics = this.getMetrics();
if (metrics) {
request.metrics = JSON.stringify(metrics);
}
await this.request(request);
}
}
private reportViewDuration() {
if (this.lastView) {
this.track<IViewEvent>("[CLY]_view", {
name: this.lastView,
}, null, {
dur: this.trackTime ? CountlyAnalytics.getTimestamp() - this.lastViewTime : this.lastViewStoredDuration,
});
this.lastView = null;
}
}
private endSession = () => {
if (this.sessionStarted) {
window.removeEventListener("resize", this.reportOrientation)
this.reportViewDuration();
this.request({
end_session: 1,
session_duration: CountlyAnalytics.getTimestamp() - this.lastBeat,
});
}
this.sessionStarted = false;
};
private onVisibilityChange = () => {
if (document.hidden) {
this.stopTime();
} else {
this.startTime();
}
};
private onUserActivity = () => {
if (this.inactivityCounter >= INACTIVITY_TIME) {
this.startTime();
}
this.inactivityCounter = 0;
};
private trackSessions() {
this.beginSession();
this.startTime();
window.addEventListener("beforeunload", this.endSession);
window.addEventListener("unload", this.endSession);
window.addEventListener("visibilitychange", this.onVisibilityChange);
window.addEventListener("mousemove", this.onUserActivity);
window.addEventListener("click", this.onUserActivity);
window.addEventListener("keydown", this.onUserActivity);
window.addEventListener("scroll", this.onUserActivity);
this.activityIntervalId = setInterval(() => {
this.inactivityCounter++;
if (this.inactivityCounter >= INACTIVITY_TIME) {
this.stopTime();
}
}, 60_000);
}
public trackBeginInvite(roomId: string) {
this.track<IBeginInviteEvent>("begin_invite", {}, roomId);
}
public trackSendInvite(startTime: number, roomId: string, qty: number) {
this.track<ISendInviteEvent>("send_invite", {}, roomId, {
dur: CountlyAnalytics.getTimestamp() - startTime,
sum: qty,
});
}
public async trackRoomCreate(startTime: number, roomId: string) {
if (this.disabled) return;
let endTime = CountlyAnalytics.getTimestamp();
const cli = MatrixClientPeg.get();
if (!cli.getRoom(roomId)) {
await new Promise<void>(resolve => {
const handler = (room) => {
if (room.roomId === roomId) {
cli.off("Room", handler);
resolve();
}
};
cli.on("Room", handler);
});
endTime = CountlyAnalytics.getTimestamp();
}
this.track<ICreateRoomEvent>("create_room", {}, roomId, {
dur: endTime - startTime,
});
}
public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) {
this.track<IJoinRoomEvent>("join_room", { type }, roomId, {
dur: CountlyAnalytics.getTimestamp() - startTime,
});
}
public async trackSendMessage(
startTime: number,
// eslint-disable-next-line camelcase
sendPromise: Promise<{event_id: string}>,
roomId: string,
isEdit: boolean,
isReply: boolean,
content: {format?: string, msgtype: string},
) {
if (this.disabled) return;
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
const eventId = (await sendPromise).event_id;
let endTime = CountlyAnalytics.getTimestamp();
if (!room.findEventById(eventId)) {
await new Promise<void>(resolve => {
const handler = (ev) => {
if (ev.getId() === eventId) {
room.off("Room.localEchoUpdated", handler);
resolve();
}
};
room.on("Room.localEchoUpdated", handler);
});
endTime = CountlyAnalytics.getTimestamp();
}
this.track<ISendMessageEvent>("send_message", {
is_edit: isEdit,
is_reply: isReply,
msgtype: content.msgtype,
format: content.format,
}, roomId, {
dur: endTime - startTime,
});
}
public trackStartCall(roomId: string, isVideo = false, isJitsi = false) {
this.track<IStartCallEvent>("start_call", {
is_video: isVideo,
is_jitsi: isJitsi,
}, roomId);
}
public trackJoinCall(roomId: string, isVideo = false, isJitsi = false) {
this.track<IJoinCallEvent>("join_call", {
is_video: isVideo,
is_jitsi: isJitsi,
}, roomId);
}
public trackRoomDirectoryBegin() {
this.track<IRoomDirectoryEvent>("room_directory");
}
public trackRoomDirectory(startTime: number) {
this.track<IRoomDirectoryDoneEvent>("room_directory_done", {}, null, {
dur: CountlyAnalytics.getTimestamp() - startTime,
});
}
public trackRoomDirectorySearch(numResults: number, query: string) {
this.track<IRoomDirectorySearchEvent>("room_directory_search", {
query_length: query.length,
query_num_words: query.split(" ").length,
}, null, {
sum: numResults,
});
}
public async track<E extends IEvent>(
key: E["key"],
segments?: Omit<E["segmentation"], "room_id" | "num_users" | "is_encrypted" | "is_public">,
roomId?: string,
args?: Partial<Pick<E, "dur" | "sum" | "timestamp">>,
anonymous = false,
) {
if (this.disabled && !anonymous) return;
let segmentation = segments || {};
if (roomId) {
segmentation = {
room_id: await hashHex(roomId),
...getRoomStats(roomId),
...segments,
};
}
this.queue({
key,
count: 1,
segmentation,
...args,
});
// if this event can be sent anonymously and we are disabled then dispatch it right away
if (this.disabled && anonymous) {
await this.request({ device_id: randomString(64) });
}
}
}
// expose on window for easy access from the console
window.mxCountlyAnalytics = CountlyAnalytics;

View file

@ -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;
}

View file

@ -27,6 +27,10 @@ import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames';
import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url';
import katex from 'katex';
import { AllHtmlEntities } from 'html-entities';
import SettingsStore from './settings/SettingsStore';
import cheerio from 'cheerio';
import {MatrixClientPeg} from './MatrixClientPeg';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
@ -53,7 +57,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
/*
* Return true if the given string contains emoji
@ -159,7 +163,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
attribs.target = '_blank'; // by default
const transformed = tryTransformPermalinkToLocalHref(attribs.href);
if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN)) {
if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.ELEMENT_URL_PATTERN)) {
attribs.href = transformed;
delete attribs.target;
}
@ -171,7 +175,10 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
if (!attribs.src || !attribs.src.startsWith('mxc://')) {
// We also drop inline images (as if they were not present at all) when the "show
// images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have.
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {}};
}
attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
@ -236,7 +243,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = {
allowedAttributes: {
// custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
div: ['data-mx-maths'],
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'],
@ -410,18 +418,36 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
if (isHtmlMessage) {
isDisplayedWithHtml = true;
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
if (SettingsStore.getValue("feature_latex_maths")) {
const phtml = cheerio.load(safeBody,
{ _useHtmlParser2: true, decodeEntities: false })
// @ts-ignore - The types for `replaceWith` wrongly expect
// Cheerio instance to be returned.
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
return katex.renderToString(
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
{
throwOnError: false,
displayMode: e.name == 'div',
output: "htmlAndMathml",
});
});
safeBody = phtml.html();
}
}
} finally {
delete sanitizeParams.textFilter;
}
const contentBody = isDisplayedWithHtml ? safeBody : strippedBody;
if (opts.returnString) {
return isDisplayedWithHtml ? safeBody : strippedBody;
return contentBody;
}
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
let contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : '';
// Ignore spaces in body text. Emojis with spaces in between should
// still be counted as purely emoji messages.
@ -511,7 +537,6 @@ export function checkBlockNode(node: Node) {
case "H6":
case "PRE":
case "BLOCKQUOTE":
case "DIV":
case "P":
case "UL":
case "OL":
@ -524,6 +549,9 @@ export function checkBlockNode(node: Node) {
case "TH":
case "TD":
return true;
case "DIV":
// don't treat math nodes as block nodes for deserializing
return !(node as HTMLElement).hasAttribute("data-mx-maths");
default:
return false;
}

View file

@ -165,6 +165,7 @@ export default class IdentityAuthClient {
});
const [confirmed] = await finished;
if (confirmed) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useDefaultIdentityServer();
} else {
throw new AbortedIdentityActionError(

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015, 2016, 2020 Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
/**
* Returns the actual height that an image of dimensions (fullWidth, fullHeight)
* will occupy if resized to fit inside a thumbnail bounding box of size
@ -30,11 +28,11 @@ limitations under the License.
* consume in the timeline, when performing scroll offset calcuations
* (e.g. scroll locking)
*/
export function thumbHeight(fullWidth, fullHeight, thumbWidth, thumbHeight) {
export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
return undefined;
return null;
}
if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
// no scaling needs to be applied

View file

@ -17,9 +17,14 @@ 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 {decryptAES, encryptAES} from "matrix-js-sdk/src/crypto/aes";
import {MatrixClientPeg} from './MatrixClientPeg';
import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg';
import SecurityCustomisations from "./customisations/Security";
import EventIndexPeg from './indexing/EventIndexPeg';
import createMatrixClient from './utils/createMatrixClient';
import Analytics from './Analytics';
@ -41,50 +46,57 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {Mjolnir} from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener";
import {Jitsi} from "./widgets/Jitsi";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY} from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
import CountlyAnalytics from "./CountlyAnalytics";
import CallHandler from './CallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import {_t} from "./languageHandler";
const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url";
interface ILoadSessionOpts {
enableGuest?: boolean;
guestHsUrl?: string;
guestIsUrl?: string;
ignoreGuest?: boolean;
defaultDeviceDisplayName?: string;
fragmentQueryParams?: Record<string, string>;
}
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
* a number of things:
*
*
* 1. if we have a guest access token in the fragment query params, it uses
* that.
*
* 2. if an access token is stored in local storage (from a previous session),
* it uses that.
*
* 3. it attempts to auto-register as a guest user.
*
* If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in
* turn will raise on_logged_in and will_start_client events.
*
* @param {object} opts
*
* @param {object} opts.fragmentQueryParams: string->string map of the
* @param {object} [opts]
* @param {object} [opts.fragmentQueryParams]: string->string map of the
* query-parameters extracted from the #-fragment of the starting URI.
*
* @param {boolean} opts.enableGuest: set to true to enable guest access tokens
* and auto-guest registrations.
*
* @params {string} opts.guestHsUrl: homeserver URL. Only used if enableGuest is
* true; defines the HS to register against.
*
* @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is
* true; defines the IS to use.
*
* @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore
* it and don't load it.
*
* @param {boolean} [opts.enableGuest]: set to true to enable guest access
* tokens and auto-guest registrations.
* @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest
* is true; defines the HS to register against.
* @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest
* is true; defines the IS to use.
* @param {bool} [opts.ignoreGuest]: If the stored session is a guest account,
* ignore it and don't load it.
* @param {string} [opts.defaultDeviceDisplayName]: Default display name to use
* when registering as a guest.
* @returns {Promise} a promise which resolves when the above process completes.
* Resolves to `true` if we ended up starting a session, or `false` if we
* failed.
*/
export async function loadSession(opts) {
export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean> {
try {
let enableGuest = opts.enableGuest || false;
const guestHsUrl = opts.guestHsUrl;
@ -97,12 +109,13 @@ export async function loadSession(opts) {
enableGuest = false;
}
if (enableGuest &&
if (
enableGuest &&
fragmentQueryParams.guest_user_id &&
fragmentQueryParams.guest_access_token
) {
) {
console.log("Using guest access credentials");
return _doSetLoggedIn({
return doSetLoggedIn({
userId: fragmentQueryParams.guest_user_id,
accessToken: fragmentQueryParams.guest_access_token,
homeserverUrl: guestHsUrl,
@ -110,7 +123,7 @@ export async function loadSession(opts) {
guest: true,
}, true).then(() => true);
}
const success = await _restoreFromLocalStorage({
const success = await restoreFromLocalStorage({
ignoreGuest: Boolean(opts.ignoreGuest),
});
if (success) {
@ -118,7 +131,7 @@ export async function loadSession(opts) {
}
if (enableGuest) {
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
}
// fall back to welcome screen
@ -129,7 +142,7 @@ export async function loadSession(opts) {
// need to show the general failure dialog. Instead, just go back to welcome.
return false;
}
return _handleLoadSessionFailure(e);
return handleLoadSessionFailure(e);
}
}
@ -137,20 +150,13 @@ export async function loadSession(opts) {
* Gets the user ID of the persisted session, if one exists. This does not validate
* that the user's credentials still work, just that they exist and that a user ID
* is associated with them. The session is not loaded.
* @returns {String} The persisted session's owner, if an owner exists. Null otherwise.
* @returns {[String, bool]} The persisted session's owner and whether the stored
* session is for a guest user, if an owner exists. If there is no stored session,
* return [null, null].
*/
export function getStoredSessionOwner() {
const {hsUrl, userId, accessToken} = getLocalStorageSessionVars();
return hsUrl && userId && accessToken ? userId : null;
}
/**
* @returns {bool} True if the stored session is for a guest user or false if it is
* for a real user. If there is no stored session, return null.
*/
export function getStoredSessionIsGuest() {
const sessVars = getLocalStorageSessionVars();
return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null;
export async function getStoredSessionOwner(): Promise<[string, boolean]> {
const {hsUrl, userId, hasAccessToken, isGuest} = await getStoredSessionVars();
return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null];
}
/**
@ -158,12 +164,17 @@ export function getStoredSessionIsGuest() {
* query-parameters extracted from the real query-string of the starting
* URI.
*
* @param {String} defaultDeviceDisplayName
* @param {string} defaultDeviceDisplayName
* @param {string} fragmentAfterLogin path to go to after a successful login, only used for "Try again"
*
* @returns {Promise} promise which resolves to true if we completed the token
* login, else false
*/
export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
export function attemptTokenLogin(
queryParams: Record<string, string>,
defaultDeviceDisplayName?: string,
fragmentAfterLogin?: string,
): Promise<boolean> {
if (!queryParams.loginToken) {
return Promise.resolve(false);
}
@ -172,6 +183,12 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY);
if (!homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use");
Modal.createTrackedDialog("SSO", "Unknown HS", ErrorDialog, {
title: _t("We couldn't log you in"),
description: _t("We asked the browser to remember which homeserver you use to let you sign in, " +
"but unfortunately your browser has forgotten it. Go to the sign in page and try again."),
button: _t("Try again"),
});
return Promise.resolve(false);
}
@ -184,19 +201,41 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
},
).then(function(creds) {
console.log("Logged in with token");
return _clearStorage().then(() => {
_persistCredentialsToLocalStorage(creds);
return clearStorage().then(async () => {
await persistCredentials(creds);
// remember that we just logged in
sessionStorage.setItem("mx_fresh_login", String(true));
return true;
});
}).catch((err) => {
console.error("Failed to log in with login token: " + err + " " +
err.data);
Modal.createTrackedDialog("SSO", "Token Rejected", ErrorDialog, {
title: _t("We couldn't log you in"),
description: err.name === "ConnectionError"
? _t("Your homeserver was unreachable and was not able to log you in. Please try again. " +
"If this continues, please contact your homeserver administrator.")
: _t("Your homeserver rejected your log in attempt. " +
"This could be due to things just taking too long. Please try again. " +
"If this continues, please contact your homeserver administrator."),
button: _t("Try again"),
onFinished: tryAgain => {
if (tryAgain) {
const cli = Matrix.createClient({
baseUrl: homeserver,
idBaseUrl: identityServer,
});
const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined;
PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId);
}
},
});
console.error("Failed to log in with login token:");
console.error(err);
return false;
});
}
export function handleInvalidStoreError(e) {
if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) {
export function handleInvalidStoreError(e: InvalidStoreError): Promise<void> {
if (e.reason === InvalidStoreError.TOGGLED_LAZY_LOADING) {
return Promise.resolve().then(() => {
const lazyLoadEnabled = e.value;
if (lazyLoadEnabled) {
@ -229,7 +268,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
@ -243,7 +286,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
},
}).then((creds) => {
console.log(`Registered as guest: ${creds.user_id}`);
return _doSetLoggedIn({
return doSetLoggedIn({
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
@ -257,15 +300,42 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
});
}
export interface IStoredSession {
hsUrl: string;
isUrl: string;
hasAccessToken: boolean;
accessToken: string | object;
userId: string;
deviceId: string;
isGuest: boolean;
}
/**
* Retrieves information about the stored session in localstorage. The session
* Retrieves information about the stored session from the browser's storage. The session
* may not be valid, as it is not tested for consistency here.
* @returns {Object} Information about the session - see implementation for variables.
*/
export function getLocalStorageSessionVars() {
export async function getStoredSessionVars(): Promise<IStoredSession> {
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
const accessToken = localStorage.getItem("mx_access_token");
let accessToken;
try {
accessToken = await StorageManager.idbLoad("account", "mx_access_token");
} catch (e) {}
if (!accessToken) {
accessToken = localStorage.getItem("mx_access_token");
if (accessToken) {
try {
// try to migrate access token to IndexedDB if we can
await StorageManager.idbSave("account", "mx_access_token", accessToken);
localStorage.removeItem("mx_access_token");
} catch (e) {}
}
}
// if we pre-date storing "mx_has_access_token", but we retrieved an access
// token, then we should say we have an access token
const hasAccessToken =
(localStorage.getItem("mx_has_access_token") === "true") || !!accessToken;
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
@ -277,7 +347,43 @@ export function getLocalStorageSessionVars() {
isGuest = localStorage.getItem("matrix-is-guest") === "true";
}
return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest};
return {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest};
}
// The pickle key is a string of unspecified length and format. For AES, we
// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES
// key. The AES key should be zeroed after it is used.
async function pickleKeyToAesKey(pickleKey: string): Promise<Uint8Array> {
const pickleKeyBuffer = new Uint8Array(pickleKey.length);
for (let i = 0; i < pickleKey.length; i++) {
pickleKeyBuffer[i] = pickleKey.charCodeAt(i);
}
const hkdfKey = await window.crypto.subtle.importKey(
"raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"],
);
pickleKeyBuffer.fill(0);
return new Uint8Array(await window.crypto.subtle.deriveBits(
{
name: "HKDF", hash: "SHA-256",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879
salt: new Uint8Array(32), info: new Uint8Array(0),
},
hkdfKey,
256,
));
}
async function abortLogin() {
const signOut = await showStorageEvictedDialog();
if (signOut) {
await clearStorage();
// This error feels a bit clunky, but we want to make sure we don't go any
// further and instead head back to sign in.
throw new AbortLoginAndRebuildStorage(
"Aborting login in progress because of storage inconsistency",
);
}
}
// returns a promise which resolves to true if a session is found in
@ -290,14 +396,18 @@ export function getLocalStorageSessionVars() {
// The plan is to gradually move the localStorage access done here into
// SessionStore to avoid bugs where the view becomes out-of-sync with
// localStorage (e.g. isGuest etc.)
async function _restoreFromLocalStorage(opts) {
const ignoreGuest = opts.ignoreGuest;
export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise<boolean> {
const ignoreGuest = opts?.ignoreGuest;
if (!localStorage) {
return false;
}
const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = getLocalStorageSessionVars();
const {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest} = await getStoredSessionVars();
if (hasAccessToken && !accessToken) {
abortLogin();
}
if (accessToken && userId && hsUrl) {
if (ignoreGuest && isGuest) {
@ -305,22 +415,32 @@ async function _restoreFromLocalStorage(opts) {
return false;
}
let decryptedAccessToken = accessToken;
const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
if (pickleKey) {
console.log("Got pickle key");
if (typeof accessToken !== "string") {
const encrKey = await pickleKeyToAesKey(pickleKey);
decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token");
encrKey.fill(0);
}
} else {
console.log("No pickle key available");
}
const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true";
sessionStorage.removeItem("mx_fresh_login");
console.log(`Restoring session for ${userId}`);
await _doSetLoggedIn({
await doSetLoggedIn({
userId: userId,
deviceId: deviceId,
accessToken: accessToken,
accessToken: decryptedAccessToken as string,
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
guest: isGuest,
pickleKey: pickleKey,
freshLogin: freshLogin,
}, false);
return true;
} else {
@ -329,7 +449,7 @@ async function _restoreFromLocalStorage(opts) {
}
}
async function _handleLoadSessionFailure(e) {
async function handleLoadSessionFailure(e: Error): Promise<boolean> {
console.error("Unable to load session", e);
const SessionRestoreErrorDialog =
@ -342,7 +462,7 @@ async function _handleLoadSessionFailure(e) {
const [success] = await modal.finished;
if (success) {
// user clicked continue.
await _clearStorage();
await clearStorage();
return false;
}
@ -363,11 +483,12 @@ async function _handleLoadSessionFailure(e) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export async function setLoggedIn(credentials) {
export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<MatrixClient> {
credentials.freshLogin = true;
stopMatrixClient();
const pickleKey = credentials.userId && credentials.deviceId
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
: null;
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
: null;
if (pickleKey) {
console.log("Created pickle key");
@ -375,7 +496,7 @@ export async function setLoggedIn(credentials) {
console.log("Pickle key not created");
}
return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
return doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
}
/**
@ -393,7 +514,7 @@ export async function setLoggedIn(credentials) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export function hydrateSession(credentials) {
export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
const oldUserId = MatrixClientPeg.get().getUserId();
const oldDeviceId = MatrixClientPeg.get().getDeviceId();
@ -406,7 +527,7 @@ export function hydrateSession(credentials) {
console.warn("Clearing all data: Old session belongs to a different user/session");
}
return _doSetLoggedIn(credentials, overwrite);
return doSetLoggedIn(credentials, overwrite);
}
/**
@ -418,7 +539,10 @@ export function hydrateSession(credentials) {
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
async function _doSetLoggedIn(credentials, clearStorage) {
async function doSetLoggedIn(
credentials: IMatrixClientCreds,
clearStorageEnabled: boolean,
): Promise<MatrixClient> {
credentials.guest = Boolean(credentials.guest);
const softLogout = isSoftLogout();
@ -429,6 +553,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
" guest: " + credentials.guest +
" hs: " + credentials.homeserverUrl +
" softLogout: " + softLogout,
" freshLogin: " + credentials.freshLogin,
);
// This is dispatched to indicate that the user is still in the process of logging in
@ -440,8 +565,8 @@ async function _doSetLoggedIn(credentials, clearStorage) {
// (dis.dispatch uses `setTimeout`, which does not guarantee ordering.)
dis.dispatch({action: 'on_logging_in'}, true);
if (clearStorage) {
await _clearStorage();
if (clearStorageEnabled) {
await clearStorage();
}
const results = await StorageManager.checkConsistency();
@ -449,32 +574,31 @@ async function _doSetLoggedIn(credentials, clearStorage) {
// crypto store, we'll be generally confused when handling encrypted data.
// Show a modal recommending a full reset of storage.
if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
const signOut = await _showStorageEvictedDialog();
if (signOut) {
await _clearStorage();
// This error feels a bit clunky, but we want to make sure we don't go any
// further and instead head back to sign in.
throw new AbortLoginAndRebuildStorage(
"Aborting login in progress because of storage inconsistency",
);
}
await abortLogin();
}
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials);
const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
// If we just logged in, try to rehydrate a device instead of using a
// new device. If it succeeds, we'll get a new device ID, so make sure
// we persist that ID to localStorage
const newDeviceId = await client.rehydrateDevice();
if (newDeviceId) {
credentials.deviceId = newDeviceId;
}
delete credentials.freshLogin;
}
if (localStorage) {
try {
_persistCredentialsToLocalStorage(credentials);
// The user registered as a PWLU (PassWord-Less User), the generated password
// is cached here such that the user can change it at a later time.
if (credentials.password) {
// Update SessionStore
dis.dispatch({
action: 'cached_password',
cachedPassword: credentials.password,
});
}
await persistCredentials(credentials);
// make sure we don't think that it's a fresh login any more
sessionStorage.removeItem("mx_fresh_login");
} catch (e) {
console.warn("Error using local storage: can't persist session!", e);
}
@ -482,15 +606,13 @@ async function _doSetLoggedIn(credentials, clearStorage) {
console.warn("No local storage available: can't persist session!");
}
MatrixClientPeg.replaceUsingCreds(credentials);
dis.dispatch({ action: 'on_logged_in' });
await startMatrixClient(/*startSyncing=*/!softLogout);
return MatrixClientPeg.get();
return client;
}
function _showStorageEvictedDialog() {
function showStorageEvictedDialog(): Promise<boolean> {
const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog');
return new Promise(resolve => {
Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, {
@ -503,18 +625,55 @@ function _showStorageEvictedDialog() {
// `instanceof`. Babel 7 supports this natively in their class handling.
class AbortLoginAndRebuildStorage extends Error { }
function _persistCredentialsToLocalStorage(credentials) {
async function persistCredentials(credentials: IMatrixClientCreds): Promise<void> {
localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl);
if (credentials.identityServerUrl) {
localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl);
}
localStorage.setItem("mx_user_id", credentials.userId);
localStorage.setItem("mx_access_token", credentials.accessToken);
localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
if (credentials.pickleKey) {
localStorage.setItem("mx_has_pickle_key", true);
// store whether we expect to find an access token, to detect the case
// where IndexedDB is blown away
if (credentials.accessToken) {
localStorage.setItem("mx_has_access_token", "true");
} else {
localStorage.deleteItem("mx_has_access_token");
}
if (credentials.pickleKey) {
let encryptedAccessToken;
try {
// try to encrypt the access token using the pickle key
const encrKey = await pickleKeyToAesKey(credentials.pickleKey);
encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token");
encrKey.fill(0);
} catch (e) {
console.warn("Could not encrypt access token", e);
}
try {
// save either the encrypted access token, or the plain access
// token if we were unable to encrypt (e.g. if the browser doesn't
// have WebCrypto).
await StorageManager.idbSave(
"account", "mx_access_token",
encryptedAccessToken || credentials.accessToken,
);
} catch (e) {
// if we couldn't save to indexedDB, fall back to localStorage. We
// store the access token unencrypted since localStorage only saves
// strings.
localStorage.setItem("mx_access_token", credentials.accessToken);
}
localStorage.setItem("mx_has_pickle_key", String(true));
} else {
try {
await StorageManager.idbSave(
"account", "mx_access_token", credentials.accessToken,
);
} catch (e) {
localStorage.setItem("mx_access_token", credentials.accessToken);
}
if (localStorage.getItem("mx_has_pickle_key")) {
console.error("Expected a pickle key, but none provided. Encryption may not work.");
}
@ -529,6 +688,8 @@ function _persistCredentialsToLocalStorage(credentials) {
localStorage.setItem("mx_device_id", credentials.deviceId);
}
SecurityCustomisations.persistCredentials?.(credentials);
console.log(`Session persisted for ${credentials.userId}`);
}
@ -537,14 +698,18 @@ let _isLoggingOut = false;
/**
* Logs the current session out and transitions to the logged-out state
*/
export function logout() {
export function logout(): void {
if (!MatrixClientPeg.get()) return;
if (!CountlyAnalytics.instance.disabled) {
// user has logged out, fall back to anonymous
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session
// if we abort the login
onLoggedOut();
// Also we sometimes want to re-log in a guest session if we abort the login.
// defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch.
setImmediate(() => onLoggedOut());
return;
}
@ -566,7 +731,7 @@ export function logout() {
);
}
export function softLogout() {
export function softLogout(): void {
if (!MatrixClientPeg.get()) return;
// Track that we've detected and trapped a soft logout. This helps prevent other
@ -587,11 +752,11 @@ export function softLogout() {
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
}
export function isSoftLogout() {
export function isSoftLogout(): boolean {
return localStorage.getItem("mx_soft_logout") === "true";
}
export function isLoggingOut() {
export function isLoggingOut(): boolean {
return _isLoggingOut;
}
@ -601,7 +766,7 @@ export function isLoggingOut() {
* @param {boolean} startSyncing True (default) to actually start
* syncing the client.
*/
async function startMatrixClient(startSyncing=true) {
async function startMatrixClient(startSyncing = true): Promise<void> {
console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used
@ -619,6 +784,7 @@ async function startMatrixClient(startSyncing=true) {
DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.start();
CallHandler.sharedInstance().start();
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
// the thing just wastes CPU cycles, but should result in no actual functionality
@ -660,21 +826,22 @@ 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({deleteEverything: true});
await clearStorage({deleteEverything: true});
LifecycleCustomisations.onLoggedOutAndStorageCleared?.();
}
/**
* @param {object} opts Options for how to clear storage.
* @returns {Promise} promise which resolves once the stores have been cleared
*/
async function _clearStorage(opts: {deleteEverything: boolean}) {
async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void> {
Analytics.disable();
if (window.localStorage) {
@ -683,6 +850,10 @@ async function _clearStorage(opts: {deleteEverything: boolean}) {
window.localStorage.clear();
try {
await StorageManager.idbDelete("account", "mx_access_token");
} catch (e) {}
// now restore those invites
if (!opts?.deleteEverything) {
pendingInvites.forEach(i => {
@ -712,8 +883,9 @@ async function _clearStorage(opts: {deleteEverything: boolean}) {
* @param {boolean} unsetClient True (default) to abandon the client
* on MatrixClientPeg after stopping.
*/
export function stopMatrixClient(unsetClient=true) {
export function stopMatrixClient(unsetClient = true): void {
Notifier.stop();
CallHandler.sharedInstance().stop();
UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset();
Presence.stop();

View file

@ -18,35 +18,94 @@ 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 IPasswordFlow {
type: "m.login.password";
}
export enum IdentityProviderBrand {
Gitlab = "org.matrix.gitlab",
Github = "org.matrix.github",
Apple = "org.matrix.apple",
Google = "org.matrix.google",
Facebook = "org.matrix.facebook",
Twitter = "org.matrix.twitter",
}
export interface IIdentityProvider {
id: string;
name: string;
icon?: string;
brand?: IdentityProviderBrand | string;
}
export interface ISSOFlow {
type: "m.login.sso" | "m.login.cas";
"org.matrix.msc2858.identity_providers": IIdentityProvider[]; // Unstable prefix for MSC2858
}
export type LoginFlow = ISSOFlow | IPasswordFlow;
// TODO: Move this to JS SDK
/* eslint-disable camelcase */
interface ILoginParams {
identifier?: 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;
// TODO: Flows need a type in JS SDK
private flows: Array<LoginFlow>;
private defaultDeviceDisplayName: string;
private tempClient: MatrixClient;
constructor(
hsUrl: string,
isUrl: string,
fallbackHsUrl?: string,
opts?: ILoginOptions,
) {
this.hsUrl = hsUrl;
this.isUrl = isUrl;
this.fallbackHsUrl = fallbackHsUrl;
this.flows = [];
this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this.tempClient = null; // memoize
}
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 +113,27 @@ 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<LoginFlow>> {
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;
return this.flows;
}
chooseFlow(flowIndex) {
this._currentFlowIndex = flowIndex;
}
getCurrentFlowStep() {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
const flowStep = this._flows[this._currentFlowIndex];
return flowStep ? flowStep.type : null;
}
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
const self = this;
public loginViaPassword(
username: string,
phoneCountry: string,
phoneNumber: string,
password: string,
): Promise<IMatrixClientCreds> {
const isEmail = username.indexOf("@") > 0;
let identifier;
@ -113,14 +159,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 +176,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 +200,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 +230,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;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import commonmark from 'commonmark';
import * as commonmark from 'commonmark';
import {escape} from "lodash";
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
@ -23,6 +23,11 @@ const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
function is_allowed_html_tag(node) {
if (node.literal != null &&
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) {
return true;
}
// Regex won't work for tags with attrs, but we only
// allow <del> anyway.
const matches = /^<\/?(.*)>$/.exec(node.literal);
@ -30,6 +35,7 @@ function is_allowed_html_tag(node) {
const tag = matches[1];
return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
}
return false;
}

View file

@ -17,6 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
import {MatrixClient} from 'matrix-js-sdk/src/client';
import {MemoryStore} from 'matrix-js-sdk/src/store/memory';
import * as utils from 'matrix-js-sdk/src/utils';
@ -31,17 +32,19 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks } from './SecurityManager';
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
import SecurityCustomisations from "./customisations/Security";
export interface IMatrixClientCreds {
homeserverUrl: string;
identityServerUrl: string;
userId: string;
deviceId: string;
deviceId?: string;
accessToken: string;
guest: boolean;
guest?: boolean;
pickleKey?: string;
freshLogin?: boolean;
}
// TODO: Move this to the js-sdk
@ -98,6 +101,12 @@ export interface IMatrixClientPeg {
*/
currentUserIsJustRegistered(): boolean;
/**
* If the current user has been registered by this device then this
* returns a boolean of whether it was within the last N hours given.
*/
userRegisteredWithinLastHours(hours: number): boolean;
/**
* Replace this MatrixClientPeg's client with a client instance that has
* homeserver / identity server URLs and active credentials
@ -148,6 +157,9 @@ class _MatrixClientPeg implements IMatrixClientPeg {
public setJustRegisteredUserId(uid: string): void {
this.justRegisteredUserId = uid;
if (uid) {
window.localStorage.setItem("mx_registration_time", String(new Date().getTime()));
}
}
public currentUserIsJustRegistered(): boolean {
@ -157,6 +169,15 @@ class _MatrixClientPeg implements IMatrixClientPeg {
);
}
public userRegisteredWithinLastHours(hours: number): boolean {
try {
const date = new Date(window.localStorage.getItem("mx_registration_time"));
return ((new Date().getTime() - date.getTime()) / 36e5) <= hours;
} catch (e) {
return false;
}
}
public replaceUsingCreds(creds: IMatrixClientCreds): void {
this.currentClientCreds = creds;
this.createClient(creds);
@ -192,6 +213,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
);
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
StorageManager.setCryptoInitialised(true);
}
} catch (e) {
@ -247,8 +269,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
private createClient(creds: IMatrixClientCreds): void {
// TODO: Make these opts typesafe with the js-sdk
const opts = {
const opts: ICreateClientOpts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,
@ -258,6 +279,10 @@ class _MatrixClientPeg implements IMatrixClientPeg {
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer'),
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
// Gather up to 20 ICE candidates when a call arrives: this should be more than we'd
// ever normally need, so effectively this should make all the gathering happen when
// the call arrives.
iceCandidatePoolSize: 20,
verificationMethods: [
verificationMethods.SAS,
SHOW_QR_CODE_METHOD,
@ -271,7 +296,10 @@ class _MatrixClientPeg implements IMatrixClientPeg {
// These are always installed regardless of the labs flag so that
// cross-signing features can toggle on without reloading and also be
// accessed immediately after login.
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks);
const customisedCallbacks = {
getDehydrationKey: SecurityCustomisations.getDehydrationKey,
};
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks, customisedCallbacks);
this.matrixClient = createMatrixClient(opts);

View file

@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
interface IModal<T extends any[]> {
export interface IModal<T extends any[]> {
elem: React.ReactNode;
className?: string;
beforeClosePromise?: Promise<boolean>;
@ -38,7 +38,7 @@ interface IModal<T extends any[]> {
close(...args: T): void;
}
interface IHandle<T extends any[]> {
export interface IHandle<T extends any[]> {
finished: Promise<T>;
close(...args: T): void;
}
@ -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);
@ -147,6 +147,15 @@ export class ModalManager {
return this.appendDialogAsync<T>(...rest);
}
public closeCurrentModal(reason: string) {
const modal = this.getCurrentModal();
if (!modal) {
return;
}
modal.closeReason = reason;
modal.close();
}
private buildModal<T extends any[]>(
prom: Promise<React.ComponentType>,
props?: IProps<T>,

View file

@ -34,6 +34,8 @@ import SettingsStore from "./settings/SettingsStore";
import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast";
import {SettingLevel} from "./settings/SettingLevel";
import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers";
import RoomViewStore from "./stores/RoomViewStore";
import UserActivity from "./UserActivity";
/*
* Dispatches:
@ -218,7 +220,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
@ -287,7 +289,7 @@ export const Notifier = {
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();
@ -376,6 +378,11 @@ export const Notifier = {
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) {
if (RoomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently()) {
// don't bother notifying as user was recently active in this room
return;
}
if (this.isEnabled()) {
this._displayPopupNotification(ev, room);
}

View file

@ -1,113 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* For two objects of the form { key: [val1, val2, val3] }, work out the added/removed
* values. Entirely new keys will result in the entire value array being added.
* @param {Object} before
* @param {Object} after
* @return {Object[]} An array of objects with the form:
* { key: $KEY, val: $VALUE, place: "add|del" }
*/
export function getKeyValueArrayDiffs(before, after) {
const results = [];
const delta = {};
Object.keys(before).forEach(function(beforeKey) {
delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially
delta[beforeKey]--; // keys present in the past have -ve values
});
Object.keys(after).forEach(function(afterKey) {
delta[afterKey] = delta[afterKey] || 0; // init to 0 initially
delta[afterKey]++; // keys present in the future have +ve values
});
Object.keys(delta).forEach(function(muxedKey) {
switch (delta[muxedKey]) {
case 1: // A new key in after
after[muxedKey].forEach(function(afterVal) {
results.push({ place: "add", key: muxedKey, val: afterVal });
});
break;
case -1: // A before key was removed
before[muxedKey].forEach(function(beforeVal) {
results.push({ place: "del", key: muxedKey, val: beforeVal });
});
break;
case 0: {// A mix of added/removed keys
// compare old & new vals
const itemDelta = {};
before[muxedKey].forEach(function(beforeVal) {
itemDelta[beforeVal] = itemDelta[beforeVal] || 0;
itemDelta[beforeVal]--;
});
after[muxedKey].forEach(function(afterVal) {
itemDelta[afterVal] = itemDelta[afterVal] || 0;
itemDelta[afterVal]++;
});
Object.keys(itemDelta).forEach(function(item) {
if (itemDelta[item] === 1) {
results.push({ place: "add", key: muxedKey, val: item });
} else if (itemDelta[item] === -1) {
results.push({ place: "del", key: muxedKey, val: item });
} else {
// itemDelta of 0 means it was unchanged between before/after
}
});
break;
}
default:
console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!");
break;
}
});
return results;
}
/**
* Shallow-compare two objects for equality: each key and value must be identical
* @param {Object} objA First object to compare against the second
* @param {Object} objB Second object to compare against the first
* @return {boolean} whether the two objects have same key=values
*/
export function shallowEqual(objA, objB) {
if (objA === objB) {
return true;
}
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) {
return false;
}
}
return true;
}

View file

@ -40,10 +40,6 @@ export default class PasswordReset {
this.identityServerDomain = identityUrl ? identityUrl.split("://")[1] : null;
}
doesServerRequireIdServerParam() {
return this.client.doesServerRequireIdServerParam();
}
/**
* Attempt to reset the user's password. This will trigger a side-effect of
* sending an email to the provided email address.
@ -78,9 +74,6 @@ export default class PasswordReset {
sid: this.sessionId,
client_secret: this.clientSecret,
};
if (await this.doesServerRequireIdServerParam()) {
creds.id_server = this.identityServerDomain;
}
try {
await this.client.setPassword({

View file

@ -1,51 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SdkConfig from './SdkConfig';
import {hashCode} from './utils/FormattingUtils';
export function phasedRollOutExpiredForUser(username, feature, now, rollOutConfig = SdkConfig.get().phasedRollOut) {
if (!rollOutConfig) {
console.log(`no phased rollout configuration, so enabling ${feature}`);
return true;
}
const featureConfig = rollOutConfig[feature];
if (!featureConfig) {
console.log(`${feature} doesn't have phased rollout configured, so enabling`);
return true;
}
if (!Number.isFinite(featureConfig.offset) || !Number.isFinite(featureConfig.period)) {
console.error(`phased rollout of ${feature} is misconfigured, ` +
`offset and/or period are not numbers, so disabling`, featureConfig);
return false;
}
const hash = hashCode(username);
//ms -> min, enable users at minute granularity
const bucketRatio = 1000 * 60;
const bucketCount = featureConfig.period / bucketRatio;
const userBucket = hash % bucketCount;
const userMs = userBucket * bucketRatio;
const enableAt = featureConfig.offset + userMs;
const result = now >= enableAt;
const bucketStr = `(bucket ${userBucket}/${bucketCount})`;
if (result) {
console.log(`${feature} enabled for ${username} ${bucketStr}`);
} else {
console.log(`${feature} will be enabled for ${username} in ${Math.ceil((enableAt - now)/1000)}s ${bucketStr}`);
}
return result;
}

View file

@ -19,30 +19,34 @@ limitations under the License.
import {MatrixClientPeg} from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
import Timer from './utils/Timer';
import {ActionPayload} from "./dispatcher/payloads";
// Time in ms after that a user is considered as unavailable/away
// Time in ms after that a user is considered as unavailable/away
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
const PRESENCE_STATES = ["online", "offline", "unavailable"];
enum State {
Online = "online",
Offline = "offline",
Unavailable = "unavailable",
}
class Presence {
constructor() {
this._activitySignal = null;
this._unavailableTimer = null;
this._onAction = this._onAction.bind(this);
this._dispatcherRef = null;
}
private unavailableTimer: Timer = null;
private dispatcherRef: string = null;
private state: State = null;
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the homeserver.
*/
async start() {
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
public async start() {
this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
// the user_activity_start action starts the timer
this._dispatcherRef = dis.register(this._onAction);
while (this._unavailableTimer) {
this.dispatcherRef = dis.register(this.onAction);
while (this.unavailableTimer) {
try {
await this._unavailableTimer.finished();
this.setState("unavailable");
await this.unavailableTimer.finished();
this.setState(State.Unavailable);
} catch (e) { /* aborted, stop got called */ }
}
}
@ -50,14 +54,14 @@ class Presence {
/**
* Stop tracking user activity
*/
stop() {
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
public stop() {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
if (this._unavailableTimer) {
this._unavailableTimer.abort();
this._unavailableTimer = null;
if (this.unavailableTimer) {
this.unavailableTimer.abort();
this.unavailableTimer = null;
}
}
@ -65,14 +69,14 @@ class Presence {
* Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum)
*/
getState() {
public getState() {
return this.state;
}
_onAction(payload) {
private onAction = (payload: ActionPayload) => {
if (payload.action === 'user_activity') {
this.setState("online");
this._unavailableTimer.restart();
this.setState(State.Online);
this.unavailableTimer.restart();
}
}
@ -81,13 +85,11 @@ class Presence {
* If the state has changed, the homeserver will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
async setState(newState) {
private async setState(newState: State) {
if (newState === this.state) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
throw new Error("Bad presence state: " + newState);
}
const oldState = this.state;
this.state = newState;

View file

@ -24,7 +24,6 @@ import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
// import {MatrixClientPeg} from './MatrixClientPeg';
// Regex for what a "safe" or "Matrix-looking" localpart would be.
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -44,70 +43,27 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
*/
export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {};
// look for an ILAG compatible flow. We define this as one
// which has only dummy or recaptcha flows. In practice it
// would support any stage InteractiveAuth supports, just not
// ones like email & msisdn which require the user to supply
// the relevant details in advance. We err on the side of
// caution though.
// XXX: ILAG is disabled for now,
// see https://github.com/vector-im/element-web/issues/8222
// const flows = await _getRegistrationFlows();
// const hasIlagFlow = flows.some((flow) => {
// return flow.stages.every((stage) => {
// return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage);
// });
// });
// if (hasIlagFlow) {
// dis.dispatch({
// action: 'view_set_mxid',
// go_home_on_cancel: options.go_home_on_cancel,
// });
//} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button key="start_login" onClick={() => {
modal.close();
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
}}>{ _t('Sign In') }</button>,
],
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
} else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'});
} else if (options.go_welcome_on_cancel) {
dis.dispatch({action: 'view_welcome_page'});
}
},
});
//}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button key="start_login" onClick={() => {
modal.close();
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
}}>{ _t('Sign In') }</button>,
],
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
} else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'});
} else if (options.go_welcome_on_cancel) {
dis.dispatch({action: 'view_welcome_page'});
}
},
});
}
// async function _getRegistrationFlows() {
// try {
// await MatrixClientPeg.get().register(
// null,
// null,
// undefined,
// {},
// {},
// );
// console.log("Register request succeeded when it should have returned 401!");
// } catch (e) {
// if (e.httpStatus === 401) {
// return e.data.flows;
// }
// throw e;
// }
// throw new Error("Register request succeeded when it should have returned 401!");
// }

View file

@ -13,9 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { _t } from './languageHandler';
export function levelRoleMap(usersDefault) {
export function levelRoleMap(usersDefault: number) {
return {
undefined: _t('Default'),
0: _t('Restricted'),
@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) {
};
}
export function textualPowerLevel(level, usersDefault) {
export function textualPowerLevel(level: number, usersDefault: number): string {
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level];

View file

@ -40,11 +40,11 @@ export function inviteMultipleToRoom(roomId, addrs) {
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter}));
}
export function showStartChatInviteDialog() {
export function showStartChatInviteDialog(initialText) {
// This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog(
'Start DM', '', InviteDialog, {kind: KIND_DM},
'Start DM', '', InviteDialog, {kind: KIND_DM, initialText},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}

View file

@ -1,31 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
function tsOfNewestEvent(room) {
if (room.timeline.length) {
return room.timeline[room.timeline.length - 1].getTs();
} else {
return Number.MAX_SAFE_INTEGER;
}
}
export function mostRecentActivityFirst(roomList) {
return roomList.sort(function(a, b) {
return tsOfNewestEvent(b) - tsOfNewestEvent(a);
});
}

View file

@ -202,12 +202,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
}
function findOverrideMuteRule(roomId) {
if (!MatrixClientPeg.get().pushRules ||
!MatrixClientPeg.get().pushRules['global'] ||
!MatrixClientPeg.get().pushRules['global'].override) {
const cli = MatrixClientPeg.get();
if (!cli.pushRules ||
!cli.pushRules['global'] ||
!cli.pushRules['global'].override) {
return null;
}
for (const rule of MatrixClientPeg.get().pushRules['global'].override) {
for (const rule of cli.pushRules['global'].override) {
if (isRuleForRoom(roomId, rule)) {
if (isMuteRule(rule) && rule.enabled) {
return rule;

View file

@ -21,6 +21,9 @@ import {MatrixClientPeg} from './MatrixClientPeg';
* if any. This could be the canonical alias if one exists, otherwise
* an alias selected arbitrarily but deterministically from the list
* of aliases. Otherwise return null;
*
* @param {Object} room The room object
* @returns {string} A display alias for the given room
*/
export function getDisplayAliasForRoom(room) {
return room.getCanonicalAlias() || room.getAltAliases()[0];

View file

@ -360,7 +360,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
let oldestEventFrom = previousSearchResult.oldestEventFrom;
response.highlights = previousSearchResult.highlights;
if (localEvents && serverEvents) {
if (localEvents && serverEvents && serverEvents.results) {
// This is a first search call, combine the events from the server and
// the local index. Note where our oldest event came from, we shall
// fetch the next batch of events from the other source.
@ -379,7 +379,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
oldestEventFrom = "local";
}
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
} else if (serverEvents) {
} else if (serverEvents && serverEvents.results) {
// This is a pagination call fetching more events from the server,
// meaning that our oldest event was in the local index.
// Change the source of the oldest event if our server event is older
@ -454,7 +454,7 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
return response;
}
function restoreEncryptionInfo(searchResultSlice) {
function restoreEncryptionInfo(searchResultSlice = []) {
for (let i = 0; i < searchResultSlice.length; i++) {
const timeline = searchResultSlice[i].context.getTimeline();
@ -517,7 +517,7 @@ async function combinedPagination(searchResult) {
},
};
const oldResultCount = searchResult.results.length;
const oldResultCount = searchResult.results ? searchResult.results.length : 0;
// Let the client process the combined result.
const result = client._processRoomEventsSearch(searchResult, response);

View file

@ -14,26 +14,38 @@ 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 { 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 = {};
let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, ISecretStorageKeyInfo> = {};
let secretStorageBeingAccessed = false;
function isCachingAllowed() {
let nonInteractive = false;
let dehydrationCache: {
key?: Uint8Array,
keyInfo?: ISecretStorageKeyInfo,
} = {};
function isCachingAllowed(): boolean {
return secretStorageBeingAccessed;
}
@ -44,7 +56,7 @@ function isCachingAllowed() {
*
* @returns {bool}
*/
export function isSecretStorageBeingAccessed() {
export function isSecretStorageBeingAccessed(): boolean {
return secretStorageBeingAccessed;
}
@ -54,7 +66,7 @@ export class AccessCancelledError extends Error {
}
}
async function confirmToDismiss() {
async function confirmToDismiss(): Promise<boolean> {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
@ -66,7 +78,26 @@ async function confirmToDismiss() {
return !sure;
}
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
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");
@ -78,17 +109,25 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
return [keyId, secretStorageKeys[keyId]];
}
const inputToKey = async ({ passphrase, recoveryKey }) => {
if (passphrase) {
return deriveKey(
passphrase,
keyInfo.passphrase.salt,
keyInfo.passphrase.iterations,
);
} else {
return decodeRecoveryKey(recoveryKey);
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= */
@ -118,24 +157,79 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session
cacheSecretStorageKey(keyId, key);
cacheSecretStorageKey(keyId, keyInfo, key);
return [keyId, key];
}
function cacheSecretStorageKey(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;
}
}
const onSecretRequested = async function({
user_id: userId,
device_id: deviceId,
request_id: requestId,
name,
device_trust: deviceTrust,
}) {
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()) {
@ -170,15 +264,16 @@ const onSecretRequested = async function({
return key && encodeBase64(key);
}
console.warn("onSecretRequested didn't recognise the secret named ", name);
};
}
export const crossSigningCallbacks = {
export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey,
cacheSecretStorageKey,
onSecretRequested,
getDehydrationKey,
};
export async function promptForBackupPassphrase() {
export async function promptForBackupPassphrase(): Promise<Uint8Array> {
let key;
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
@ -228,7 +323,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
/* priority = */ false,
/* static = */ true,
/* options = */ {
onBeforeClose(reason) {
onBeforeClose: async (reason) => {
// If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") {
return !isSecureBackupRequired();
@ -262,16 +357,86 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
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 = {};
}
}
}
}
}

View file

@ -16,12 +16,21 @@ limitations under the License.
*/
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,15 +53,22 @@ 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 {
save(editorModel: EditorModel, replyEvent?: MatrixEvent) {
const item = SendHistoryManager.createItem(editorModel, replyEvent);
this.history.push(item);
this.currentIndex = this.history.length;
this.lastIndex += 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item));
}
getItem(offset: number): IHistoryItem {
this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}

View file

@ -50,8 +50,8 @@ class Skinner {
return null;
}
// components have to be functions.
const validType = typeof comp === 'function';
// components have to be functions or forwardRef objects with a render function.
const validType = typeof comp === 'function' || comp.render;
if (!validType) {
throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`);
}

View file

@ -46,6 +46,9 @@ import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from
import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore";
import {UIFeature} from "./settings/UIFeature";
import {CHAT_EFFECTS} from "./effects"
import CallHandler from "./CallHandler";
import {guessAndSetDMRoom} from "./Rooms";
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event {
@ -77,6 +80,7 @@ export const CommandCategories = {
"actions": _td("Actions"),
"admin": _td("Admin"),
"advanced": _td("Advanced"),
"effects": _td("Effects"),
"other": _td("Other"),
};
@ -163,6 +167,32 @@ export const Commands = [
},
category: CommandCategories.messages,
}),
new Command({
command: 'tableflip',
args: '<message>',
description: _td('Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message'),
runFn: function(roomId, args) {
let message = '(╯°□°)╯︵ ┻━┻';
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
},
category: CommandCategories.messages,
}),
new Command({
command: 'unflip',
args: '<message>',
description: _td('Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message'),
runFn: function(roomId, args) {
let message = '┬──┬ ( ゜-゜ノ)';
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
},
category: CommandCategories.messages,
}),
new Command({
command: 'lenny',
args: '<message>',
@ -517,6 +547,7 @@ export const Commands = [
action: 'view_room',
room_alias: roomAlias,
auto_join: true,
_type: "slash_command", // instrumentation
});
return success();
} else if (params[0][0] === '!') {
@ -531,6 +562,7 @@ export const Commands = [
},
via_servers: viaServers, // for the rejoin button
auto_join: true,
_type: "slash_command", // instrumentation
});
return success();
} else if (isPermalink) {
@ -555,6 +587,7 @@ export const Commands = [
const dispatch = {
action: 'view_room',
auto_join: true,
_type: "slash_command", // instrumentation
};
if (entity[0] === '!') dispatch["room_id"] = entity;
@ -998,14 +1031,27 @@ export const Commands = [
description: _td("Opens chat with the given user"),
args: "<user-id>",
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
// easter-egg for now: look up phone numbers through the thirdparty API
// (very dumb phone number detection...)
const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId);
if (!userId || (!userId.startsWith("@") || !userId.includes(":")) && !isPhoneNumber) {
return reject(this.getUsage());
}
return success((async () => {
if (isPhoneNumber) {
const results = await CallHandler.sharedInstance().pstnLookup(this.state.value);
if (!results || results.length === 0 || !results[0].userid) {
throw new Error("Unable to find Matrix ID for phone number");
}
userId = results[0].userid;
}
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
dis.dispatch({
action: 'view_room',
room_id: await ensureDMExists(MatrixClientPeg.get(), userId),
room_id: roomId,
});
})());
},
@ -1039,6 +1085,50 @@ export const Commands = [
},
category: CommandCategories.actions,
}),
new Command({
command: "holdcall",
description: _td("Places the call in the current room on hold"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
if (!call) {
return reject("No active call in this room");
}
call.setRemoteOnHold(true);
return success();
},
}),
new Command({
command: "unholdcall",
description: _td("Takes the call in the current room off hold"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
if (!call) {
return reject("No active call in this room");
}
call.setRemoteOnHold(false);
return success();
},
}),
new Command({
command: "converttodm",
description: _td("Converts the room to a DM"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const room = MatrixClientPeg.get().getRoom(roomId);
return success(guessAndSetDMRoom(room, true));
},
}),
new Command({
command: "converttoroom",
description: _td("Converts the DM to a room"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const room = MatrixClientPeg.get().getRoom(roomId);
return success(guessAndSetDMRoom(room, false));
},
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
@ -1049,6 +1139,30 @@ export const Commands = [
category: CommandCategories.messages,
hideCompletionAfterSpace: true,
}),
...CHAT_EFFECTS.map((effect) => {
return new Command({
command: effect.command,
description: effect.description(),
args: '<message>',
runFn: function(roomId, args) {
return success((async () => {
if (!args) {
args = effect.fallbackMessage();
MatrixClientPeg.get().sendEmoteMessage(roomId, args);
} else {
const content = {
msgtype: effect.msgType,
body: args,
};
MatrixClientPeg.get().sendMessage(roomId, content);
}
dis.dispatch({action: `effects.${effect.command}`});
})());
},
category: CommandCategories.effects,
})
}),
];
// build a map from names and aliases to the Command objects.
@ -1066,7 +1180,7 @@ export function parseCommandString(input: string) {
input = input.replace(/\s+$/, '');
if (input[0] !== '/') return {}; // not a command
const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
let cmd;
let args;
if (bits) {

View file

@ -19,6 +19,7 @@ import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore";
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
@ -198,59 +199,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) {
@ -329,14 +301,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});
@ -345,6 +330,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?
@ -466,7 +456,7 @@ function textForWidgetEvent(event) {
let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name
if (widgetName && widgetName.length > 0) {
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' ';
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1);
}
// If the widget was removed, its content should be {}, but this is sufficiently
@ -488,6 +478,11 @@ function textForWidgetEvent(event) {
}
}
function textForWidgetLayoutEvent(event) {
const senderName = event.sender?.name || event.getSender();
return _t("%(senderName)s has updated the widget layout", {senderName});
}
function textForMjolnirEvent(event) {
const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent();
@ -574,6 +569,7 @@ const handlers = {
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
'm.call.reject': textForCallRejectEvent,
};
const stateHandlers = {
@ -593,6 +589,7 @@ const stateHandlers = {
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
'im.vector.modular.widgets': textForWidgetEvent,
[WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent,
};
// Add all the Mjolnir stuff to the renderer

View file

@ -16,12 +16,14 @@ limitations under the License.
import {MatrixClientPeg} from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import * as sdk from "./index";
import {haveTileForEvent} from "./components/views/rooms/EventTile";
/**
* Returns true iff this event arriving in a room should affect the room's
* count of unread messages
*
* @param {Object} ev The event
* @returns {boolean} True if the given event should affect the unread message count
*/
export function eventTriggersUnreadCount(ev) {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {

View file

@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
* see doc on the userActive* functions for what these mean.
*/
export default class UserActivity {
constructor(windowObj, documentObj) {
this._window = windowObj;
this._document = documentObj;
private readonly activeNowTimeout: Timer;
private readonly activeRecentlyTimeout: Timer;
private attachedActiveNowTimers: Timer[] = [];
private attachedActiveRecentlyTimers: Timer[] = [];
private lastScreenX = 0;
private lastScreenY = 0;
this._attachedActiveNowTimers = [];
this._attachedActiveRecentlyTimers = [];
this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
this._onUserActivity = this._onUserActivity.bind(this);
this._onWindowBlurred = this._onWindowBlurred.bind(this);
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
this.lastScreenX = 0;
this.lastScreenY = 0;
constructor(private readonly window: Window, private readonly document: Document) {
this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
}
static sharedInstance() {
if (global.mxUserActivity === undefined) {
global.mxUserActivity = new UserActivity(window, document);
if (window.mxUserActivity === undefined) {
window.mxUserActivity = new UserActivity(window, document);
}
return global.mxUserActivity;
return window.mxUserActivity;
}
/**
@ -69,8 +66,8 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
timeWhileActiveNow(timer) {
this._timeWhile(timer, this._attachedActiveNowTimers);
public timeWhileActiveNow(timer: Timer) {
this.timeWhile(timer, this.attachedActiveNowTimers);
if (this.userActiveNow()) {
timer.start();
}
@ -85,14 +82,14 @@ export default class UserActivity {
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
timeWhileActiveRecently(timer) {
this._timeWhile(timer, this._attachedActiveRecentlyTimers);
public timeWhileActiveRecently(timer: Timer) {
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
if (this.userActiveRecently()) {
timer.start();
}
}
_timeWhile(timer, attachedTimers) {
private timeWhile(timer: Timer, attachedTimers: Timer[]) {
// important this happens first
const index = attachedTimers.indexOf(timer);
if (index === -1) {
@ -112,36 +109,36 @@ export default class UserActivity {
/**
* Start listening to user activity
*/
start() {
this._document.addEventListener('mousedown', this._onUserActivity);
this._document.addEventListener('mousemove', this._onUserActivity);
this._document.addEventListener('keydown', this._onUserActivity);
this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.addEventListener("blur", this._onWindowBlurred);
this._window.addEventListener("focus", this._onUserActivity);
public start() {
this.document.addEventListener('mousedown', this.onUserActivity);
this.document.addEventListener('mousemove', this.onUserActivity);
this.document.addEventListener('keydown', this.onUserActivity);
this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.addEventListener("blur", this.onWindowBlurred);
this.window.addEventListener("focus", this.onUserActivity);
// can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
this._window.addEventListener('wheel', this._onUserActivity, {
passive: true, capture: true,
this.window.addEventListener('wheel', this.onUserActivity, {
passive: true,
capture: true,
});
}
/**
* Stop tracking user activity
*/
stop() {
this._document.removeEventListener('mousedown', this._onUserActivity);
this._document.removeEventListener('mousemove', this._onUserActivity);
this._document.removeEventListener('keydown', this._onUserActivity);
this._window.removeEventListener('wheel', this._onUserActivity, {
passive: true, capture: true,
public stop() {
this.document.removeEventListener('mousedown', this.onUserActivity);
this.document.removeEventListener('mousemove', this.onUserActivity);
this.document.removeEventListener('keydown', this.onUserActivity);
this.window.removeEventListener('wheel', this.onUserActivity, {
capture: true,
});
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.removeEventListener("blur", this._onWindowBlurred);
this._window.removeEventListener("focus", this._onUserActivity);
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.removeEventListener("blur", this.onWindowBlurred);
this.window.removeEventListener("focus", this.onUserActivity);
}
/**
@ -151,8 +148,8 @@ export default class UserActivity {
* user's attention at any given moment.
* @returns {boolean} true if user is currently 'active'
*/
userActiveNow() {
return this._activeNowTimeout.isRunning();
public userActiveNow() {
return this.activeNowTimeout.isRunning();
}
/**
@ -163,27 +160,27 @@ export default class UserActivity {
* (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently
*/
userActiveRecently() {
return this._activeRecentlyTimeout.isRunning();
public userActiveRecently() {
return this.activeRecentlyTimeout.isRunning();
}
_onPageVisibilityChanged(e) {
if (this._document.visibilityState === "hidden") {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
private onPageVisibilityChanged = e => {
if (this.document.visibilityState === "hidden") {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
} else {
this._onUserActivity(e);
this.onUserActivity(e);
}
}
};
_onWindowBlurred() {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
}
private onWindowBlurred = () => {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
};
_onUserActivity(event) {
private onUserActivity = (event: MouseEvent) => {
// ignore anything if the window isn't focused
if (!this._document.hasFocus()) return;
if (!this.document.hasFocus()) return;
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
@ -195,25 +192,25 @@ export default class UserActivity {
}
dis.dispatch({action: 'user_activity'});
if (!this._activeNowTimeout.isRunning()) {
this._activeNowTimeout.start();
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.start();
dis.dispatch({action: 'user_activity_start'});
this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout);
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else {
this._activeNowTimeout.restart();
this.activeNowTimeout.restart();
}
if (!this._activeRecentlyTimeout.isRunning()) {
this._activeRecentlyTimeout.start();
if (!this.activeRecentlyTimeout.isRunning()) {
this.activeRecentlyTimeout.start();
this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout);
UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
} else {
this._activeRecentlyTimeout.restart();
this.activeRecentlyTimeout.restart();
}
}
};
async _runTimersUntilTimeout(attachedTimers, timeout) {
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();

110
src/VoipUserMapper.ts Normal file
View file

@ -0,0 +1,110 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ensureVirtualRoomExists, findDMForUser } from './createRoom';
import { MatrixClientPeg } from "./MatrixClientPeg";
import DMRoomMap from "./utils/DMRoomMap";
import CallHandler, { VIRTUAL_ROOM_EVENT_TYPE } from './CallHandler';
import { Room } from 'matrix-js-sdk/src/models/room';
// Functions for mapping virtual users & rooms. Currently the only lookup
// is sip virtual: there could be others in the future.
export default class VoipUserMapper {
private virtualRoomIdCache = new Set<string>();
public static sharedInstance(): VoipUserMapper {
if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
return window.mxVoipUserMapper;
}
private async userToVirtualUser(userId: string): Promise<string> {
const results = await CallHandler.sharedInstance().sipVirtualLookup(userId);
if (results.length === 0) return null;
return results[0].userid;
}
public async getOrCreateVirtualRoomForRoom(roomId: string):Promise<string> {
const userId = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (!userId) return null;
const virtualUser = await this.userToVirtualUser(userId);
if (!virtualUser) return null;
const virtualRoomId = await ensureVirtualRoomExists(MatrixClientPeg.get(), virtualUser, roomId);
MatrixClientPeg.get().setRoomAccountData(virtualRoomId, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: roomId,
});
return virtualRoomId;
}
public nativeRoomForVirtualRoom(roomId: string):string {
const virtualRoom = MatrixClientPeg.get().getRoom(roomId);
if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
return virtualRoomEvent.getContent()['native_room'] || null;
}
public isVirtualRoom(room: Room):boolean {
if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
if (this.virtualRoomIdCache.has(room.roomId)) return true;
// also look in the create event for the claimed native room ID, which is the only
// way we can recognise a virtual room we've created when it first arrives down
// our stream. We don't trust this in general though, as it could be faked by an
// inviter: our main source of truth is the DM state.
const roomCreateEvent = room.currentState.getStateEvents("m.room.create", "");
if (!roomCreateEvent || !roomCreateEvent.getContent()) return false;
// we only look at this for rooms we created (so inviters can't just cause rooms
// to be invisible)
if (roomCreateEvent.getSender() !== MatrixClientPeg.get().getUserId()) return false;
const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE];
return Boolean(claimedNativeRoomId);
}
public async onNewInvitedRoom(invitedRoom: Room) {
const inviterId = invitedRoom.getDMInviter();
console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
if (result.length === 0) {
return true;
}
if (result[0].fields.is_virtual) {
const nativeUser = result[0].userid;
const nativeRoom = findDMForUser(MatrixClientPeg.get(), nativeUser);
if (nativeRoom) {
// It's a virtual room with a matching native room, so set the room account data. This
// will make sure we know where how to map calls and also allow us know not to display
// it in the future.
MatrixClientPeg.get().setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, {
native_room: nativeRoom.roomId,
});
// also auto-join the virtual room if we have a matching native room
// (possibly we should only join if we've also joined the native room, then we'd also have
// to make sure we joined virtual rooms on joining a native one)
MatrixClientPeg.get().joinRoom(invitedRoom.roomId);
}
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer
// in however long it takes for the echo of setAccountData to come down the sync
this.virtualRoomIdCache.add(invitedRoom.roomId);
}
}
}

View file

@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {Room} from "matrix-js-sdk/src/models/room";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {MatrixClientPeg} from "./MatrixClientPeg";
import { _t } from './languageHandler';
export function usersTypingApartFromMeAndIgnored(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
);
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers()));
}
export function usersTypingApartFromMe(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId],
);
export function usersTypingApartFromMe(room: Room): RoomMember[] {
return usersTyping(room, [MatrixClientPeg.get().getUserId()]);
}
/**
@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) {
* to exclude, return a list of user objects who are typing.
* @param {Room} room: room object to get users from.
* @param {string[]} exclude: list of user mxids to exclude.
* @returns {string[]} list of user objects who are typing.
* @returns {RoomMember[]} list of user objects who are typing.
*/
export function usersTyping(room, exclude) {
export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
const whoIsTyping = [];
if (exclude === undefined) {
exclude = [];
}
const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i];
@ -57,20 +52,21 @@ export function usersTyping(room, exclude) {
return whoIsTyping;
}
export function whoIsTypingString(whoIsTyping, limit) {
export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
if (whoIsTyping.length === 0) {
return '';
} else if (whoIsTyping.length === 1) {
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
}
const names = whoIsTyping.map(function(m) {
return m.name;
});
if (othersCount>=1) {
const names = whoIsTyping.map(m => m.name);
if (othersCount >= 1) {
return _t('%(names)s and %(count)s others are typing …', {
names: names.slice(0, limit - 1).join(', '),
count: othersCount,

View file

@ -168,6 +168,12 @@ const shortcuts: Record<Categories, IShortcut[]> = {
key: Key.U,
}],
description: _td("Upload a file"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.F,
}],
description: _td("Search (must be enabled)"),
},
],
@ -257,6 +263,12 @@ const shortcuts: Record<Categories, IShortcut[]> = {
key: Key.SLASH,
}],
description: _td("Toggle this dialog"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.ALT],
key: Key.H,
}],
description: _td("Go to Home View"),
},
],

View file

@ -166,7 +166,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
if (handleHomeEnd) {
// Don't interfere with input default keydown behaviour
if (handleHomeEnd && ev.target.tagName !== "INPUT") {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:
@ -204,7 +205,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
// onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => {
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
const context = useContext(RovingTabIndexContext);
let ref = useRef<HTMLElement>(null);

View file

@ -28,6 +28,9 @@ 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

View file

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

View file

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

View file

@ -32,6 +32,7 @@ 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;
@ -99,7 +100,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
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
@ -110,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();
@ -219,7 +235,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
const blob = new Blob([this._recoveryKey.encodedPrivateKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
FileSaver.saveAs(blob, 'security-key.txt');
this.setState({
downloaded: true,
@ -454,6 +470,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
value={CREATE_STORAGE_OPTION_KEY}
name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
onChange={this._onKeyPassphraseChange}
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
@ -472,6 +489,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
value={CREATE_STORAGE_OPTION_PASSPHRASE}
name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE}
onChange={this._onKeyPassphraseChange}
outlined
>
<div className="mx_CreateSecretStorageDialog_optionTitle">
@ -493,7 +511,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
"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}>
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup">
{optionKey}
{optionPassphrase}
</div>
@ -575,10 +593,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
onValidate={this._onPassPhraseValidate}
fieldRef={this._passphraseField}
autoFocus={true}
label={_td("Enter a recovery passphrase")}
labelEnterPassword={_td("Enter a recovery passphrase")}
labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")}
label={_td("Enter a Security Phrase")}
labelEnterPassword={_td("Enter a Security Phrase")}
labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")}
/>
</div>

View file

@ -58,7 +58,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
</span>;
const newMethodDetected = <p>{_t(
"A new recovery passphrase and key for Secure Messages have been detected.",
"A new Security Phrase and key for Secure Messages have been detected.",
)}</p>;
const hackWarning = <p className="warning">{_t(

View file

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

View file

@ -390,15 +390,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
}
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return {left, top, chevronOffset};
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
@ -408,16 +409,52 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom;
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = window.innerHeight - buttonTop;
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
}
return menuOptions;
};
export const useContextMenu = (): [boolean, RefObject<HTMLElement>, () => void, () => void, (val: boolean) => void] => {
const button = useRef<HTMLElement>(null);
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
// and always above elementRect
export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
}
return menuOptions;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
// and always above elementRect
export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonLeft = elementRect.left + window.pageXOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft;
// Align the menu vertically above the menu
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
return menuOptions;
};
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);

View file

@ -45,7 +45,7 @@ class FilePanel extends React.Component {
};
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
if (room.roomId !== this.props.roomId) return;
if (room?.roomId !== this.props?.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
if (ev.isBeingDecrypted()) {

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import TagOrderStore from '../../stores/TagOrderStore';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
import GroupActions from '../../actions/GroupActions';
@ -31,7 +31,7 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
class TagPanel extends React.Component {
class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext;
state = {
@ -44,13 +44,13 @@ class TagPanel extends React.Component {
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
@ -61,8 +61,8 @@ class TagPanel extends React.Component {
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();
}
}
@ -98,7 +98,7 @@ class TagPanel extends React.Component {
return (
<div>
<UserTagTile />
<hr className="mx_TagPanel_divider" />
<hr className="mx_GroupFilterPanel_divider" />
</div>
);
}
@ -117,8 +117,8 @@ class TagPanel extends React.Component {
});
const itemsSelected = this.state.selectedTags.length > 0;
const classes = classNames('mx_TagPanel', {
mx_TagPanel_items_selected: itemsSelected,
const classes = classNames('mx_GroupFilterPanel', {
mx_GroupFilterPanel_items_selected: itemsSelected,
});
let createButton = (
@ -141,7 +141,7 @@ class TagPanel extends React.Component {
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}
@ -152,7 +152,7 @@ class TagPanel extends React.Component {
>
{ (provided, snapshot) => (
<div
className="mx_TagPanel_tagTileContainer"
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
@ -168,4 +168,4 @@ class TagPanel extends React.Component {
</div>;
}
}
export default TagPanel;
export default GroupFilterPanel;

View file

@ -47,7 +47,7 @@ const LONG_DESC_PLACEHOLDER = _td(
some important <a href="foo">links</a>
</p>
<p>
You can even use 'img' tags
You can even add images with Matrix URLs <img src="mxc://url" />
</p>
`);
@ -620,7 +620,7 @@ export default class GroupView extends React.Component {
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) => {
@ -649,7 +649,6 @@ export default class GroupView extends React.Component {
editing: false,
summary: null,
});
dis.dispatch({action: 'panel_disable'});
this._initGroupStore(this.props.groupId);
if (this.state.avatarChanged) {
@ -870,10 +869,7 @@ export default class GroupView extends React.Component {
{ _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>
@ -884,9 +880,7 @@ export default class GroupView extends React.Component {
</div>
{ this.state.groupRoomsLoading ?
<Spinner /> :
<RoomDetailList
rooms={this.state.groupRooms}
className={roomDetailListClassName} />
<RoomDetailList rooms={this.state.groupRooms} />
}
</div>;
}

View file

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

View file

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

View file

@ -177,7 +177,14 @@ export default class InteractiveAuthComponent extends React.Component {
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage != stageType) this._setFocus();
if (oldStage !== stageType) {
this._setFocus();
} else if (
!stageState.error && this._stageComponent.current &&
this._stageComponent.current.attemptFailed
) {
this._stageComponent.current.attemptFailed();
}
});
};

View file

@ -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";
@ -38,6 +38,7 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
interface IProps {
isMinimized: boolean;
@ -46,7 +47,7 @@ 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
@ -60,7 +61,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 +71,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 +79,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 +89,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 +120,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);
}
};
@ -139,7 +143,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
@ -210,10 +214,19 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom;
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
}
if (header.style.bottom) {
header.style.removeProperty('bottom');
}
}
if (style.stickyTop || style.stickyBottom) {
@ -375,9 +388,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>
);
@ -385,7 +398,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={null}
collapsed={false}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
@ -394,7 +406,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 +417,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()}
@ -423,6 +435,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{roomList}
</div>
</div>
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
</aside>
</div>
);

View file

@ -0,0 +1,149 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect, useMemo} from "react";
import {Resizable} from "re-resizable";
import classNames from "classnames";
import AccessibleButton from "../views/elements/AccessibleButton";
import {useRovingTabIndex} from "../../accessibility/RovingTabIndex";
import {Key} from "../../Keyboard";
import {useLocalStorageState} from "../../hooks/useLocalStorageState";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
import {useAccountData} from "../../hooks/useAccountData";
import AppTile from "../views/elements/AppTile";
import {useSettingValue} from "../../hooks/useSettings";
interface IProps {
onResize(): void;
}
const MIN_HEIGHT = 100;
const MAX_HEIGHT = 500; // or 50% of the window height
const INITIAL_HEIGHT = 280;
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const cli = useContext(MatrixClientContext);
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
const leftPanelWidgetId = useSettingValue("Widgets.leftPanel");
const app = useMemo(() => {
if (!mWidgetsEvent || !leftPanelWidgetId) return null;
const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId);
if (!widgetConfig) return null;
return WidgetUtils.makeAppConfig(
widgetConfig.state_key,
widgetConfig.content,
widgetConfig.sender,
null,
widgetConfig.id);
}, [mWidgetsEvent, leftPanelWidgetId]);
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded, onResize]);
const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1;
if (!app) return null;
let content;
if (expanded) {
content = <Resizable
size={{height} as any}
minHeight={MIN_HEIGHT}
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)}
onResize={onResize}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
}}
handleWrapperClass="mx_LeftPanelWidget_resizerHandles"
handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}}
className="mx_LeftPanelWidget_resizeBox"
enable={{ top: true }}
>
<AppTile
app={app}
fullWidth
show
showMenubar={false}
userWidget
userId={cli.getUserId()}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
/>
</Resizable>;
}
return <div className="mx_LeftPanelWidget">
<div
onFocus={onFocus}
className="mx_LeftPanelWidget_headerContainer"
onKeyDown={(ev: React.KeyboardEvent) => {
switch (ev.key) {
case Key.ARROW_LEFT:
ev.stopPropagation();
setExpanded(false);
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
setExpanded(true);
break;
}
}
}}
>
<div className="mx_LeftPanelWidget_stickable">
<AccessibleButton
onFocus={onFocus}
inputRef={ref}
tabIndex={tabIndex}
className="mx_LeftPanelWidget_headerText"
role="treeitem"
aria-expanded={expanded}
aria-level={1}
onClick={() => {
setExpanded(e => !e);
}}
>
<span className={classNames({
"mx_LeftPanelWidget_collapseBtn": true,
"mx_LeftPanelWidget_collapseBtn_collapsed": !expanded,
})} />
<span>{ WidgetUtils.getWidgetName(app) }</span>
</AccessibleButton>
{/* Code for the maximise button for once we have full screen widgets */}
{/*<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={() => {
}}
className="mx_LeftPanelWidget_maximizeButton"
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
title={_t("Maximize")}
/>*/}
</div>
</div>
{ content }
</div>;
};
export default LeftPanelWidget;

View file

@ -21,13 +21,12 @@ import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard';
import PageTypes from '../../PageTypes';
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,10 +40,6 @@ 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,
@ -57,6 +52,9 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
// 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.
@ -76,9 +74,6 @@ interface IProps {
viaServers?: string[];
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
middleDisabled: boolean;
leftDisabled: boolean;
rightDisabled: boolean;
// eslint-disable-next-line camelcase
page_type: string;
autoJoin: boolean;
@ -95,6 +90,7 @@ interface IProps {
currentUserId?: string;
currentGroupId?: string;
currentGroupIsNew?: boolean;
justRegistered?: boolean;
}
interface IUsageLimit {
@ -105,10 +101,6 @@ interface IUsageLimit {
}
interface IState {
mouseDown?: {
x: number;
y: number;
};
syncErrorData?: {
error: {
data: IUsageLimit;
@ -149,16 +141,13 @@ 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 compactLayoutWatcherRef: string;
protected resizer: Resizer;
constructor(props, context) {
super(props, context);
this.state = {
mouseDown: undefined,
syncErrorData: undefined,
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
@ -169,24 +158,6 @@ class LoggedInView extends React.Component<IProps, IState> {
CallMediaHandler.loadDevices();
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);
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
fixupColorFonts();
this._roomView = React.createRef();
@ -194,6 +165,24 @@ class LoggedInView extends React.Component<IProps, IState> {
}
componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false);
this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
// Call `onSync` with the current state as well
this.onSync(
this._matrixClient.getSyncState(),
null,
this._matrixClient.getSyncStateData(),
);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
@ -204,10 +193,7 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
this.resizer.detach();
}
@ -228,46 +214,38 @@ 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",
};
const collapseConfig = {
let size;
let collapsed;
const collapseConfig: ICollapseConfig = {
toggleSize: 260 - 50,
onCollapsed: (collapsed) => {
if (collapsed) {
onCollapsed: (_collapsed) => {
collapsed = _collapsed;
if (_collapsed) {
dis.dispatch({action: "hide_left_panel"}, true);
window.localStorage.setItem("mx_lhs_size", '0');
} else {
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: () => {
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.stopResizing();
},
};
const resizer = new Resizer(
this._resizeContainer.current,
CollapseDistributor,
collapseConfig);
resizer.setClassNames(classNames);
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
});
return resizer;
}
@ -424,6 +402,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
const modKey = isMac ? ev.metaKey : ev.ctrlKey;
switch (ev.key) {
case Key.PAGE_UP:
@ -449,6 +428,14 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true;
}
break;
case Key.F:
if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) {
dis.dispatch({
action: 'focus_search',
});
handled = true;
}
break;
case Key.BACKTICK:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
@ -468,6 +455,16 @@ class LoggedInView extends React.Component<IProps, IState> {
}
break;
case Key.H:
if (ev.altKey && modKey) {
dis.dispatch({
action: 'view_home_page',
});
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
}
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
@ -542,8 +539,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,
@ -574,48 +571,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');
@ -635,7 +590,6 @@ class LoggedInView extends React.Component<IProps, IState> {
oobData={this.props.roomOobData}
viaServers={this.props.viaServers}
key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
resizeNotifier={this.props.resizeNotifier}
/>;
break;
@ -649,7 +603,7 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
case PageTypes.HomePage:
pageElement = <HomePage />;
pageElement = <HomePage justRegistered={this.props.justRegistered} />;
break;
case PageTypes.UserView:
@ -683,8 +637,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}>
@ -697,6 +649,7 @@ class LoggedInView extends React.Component<IProps, IState> {
</div>
<CallContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
</MatrixClientContext.Provider>
);
}

View file

@ -29,11 +29,11 @@ import 'focus-visible';
import 'what-input';
import Analytics from "../../Analytics";
import CountlyAnalytics from "../../CountlyAnalytics";
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";
import dis from "../../dispatcher/dispatcher";
import Notifier from '../../Notifier';
@ -47,7 +47,6 @@ import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions
import '../../stores/LifecycleStore';
import PageTypes from '../../PageTypes';
import { getHomePageUrl } from '../../utils/pages';
import createRoom from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
@ -61,7 +60,7 @@ import DMRoomMap from '../../utils/DMRoomMap';
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { FontWatcher } from '../../settings/watchers/FontWatcher';
import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer, IDeferred } from "../../utils/promise";
import { defer, IDeferred, sleep } from "../../utils/promise";
import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView";
@ -81,42 +80,44 @@ import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityProt
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
/** constants for MatrixChat.state.view */
export enum Views {
// a special initial state which is only used at startup, while we are
// trying to re-animate a matrix client or register as a guest.
LOADING = 0,
LOADING,
// we are showing the welcome view
WELCOME = 1,
WELCOME,
// we are showing the login view
LOGIN = 2,
LOGIN,
// we are showing the registration view
REGISTER = 3,
// completing the registration flow
POST_REGISTRATION = 4,
REGISTER,
// showing the 'forgot password' view
FORGOT_PASSWORD = 5,
FORGOT_PASSWORD,
// showing flow to trust this new device with cross-signing
COMPLETE_SECURITY = 6,
COMPLETE_SECURITY,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP = 7,
E2E_SETUP,
// we are logged in with an active matrix client.
LOGGED_IN = 8,
// we are logged in with an active matrix client. The logged_in state also
// includes guests users as they too are logged in at the client level.
LOGGED_IN,
// We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client.
SOFT_LOGOUT = 9,
SOFT_LOGOUT,
}
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
// Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require
// re-factoring to be included in this list in future.
@ -181,9 +182,6 @@ 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;
@ -202,6 +200,7 @@ interface IState {
roomOobData?: object;
viaServers?: string[];
pendingInitialSync?: boolean;
justRegistered?: boolean;
}
export default class MatrixChat extends React.PureComponent<IProps, IState> {
@ -220,6 +219,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private screenAfterLogin?: IScreen;
private windowWidth: number;
private pageChanging: boolean;
private tokenLogin?: boolean;
private accountPassword?: string;
private accountPasswordTimer?: NodeJS.Timeout;
private focusComposer: boolean;
@ -236,8 +236,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state = {
view: Views.LOADING,
collapseLhs: false,
leftDisabled: false,
middleDisabled: false,
hideToSRUsers: false,
@ -290,7 +288,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;
@ -327,13 +325,21 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Lifecycle.attemptTokenLogin(
this.props.realQueryParams,
this.props.defaultDeviceDisplayName,
).then((loggedIn) => {
if (loggedIn) {
this.getFragmentAfterLogin(),
).then(async (loggedIn) => {
if (this.props.realQueryParams?.loginToken) {
// remove the loginToken from the URL regardless
this.props.onTokenLoginCompleted();
}
// don't do anything else until the page reloads - just stay in
// the 'loading' state.
return;
if (loggedIn) {
this.tokenLogin = true;
// Create and start the client
await Lifecycle.restoreFromLocalStorage({
ignoreGuest: true,
});
return this.postLoginSetup();
}
// if the user has followed a login or register link, don't reanimate
@ -354,6 +360,43 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable();
}
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
private async postLoginSetup() {
const cli = MatrixClientPeg.get();
const cryptoEnabled = cli.isCryptoEnabled();
if (!cryptoEnabled) {
this.onLoggedIn();
}
const promisesList = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account
promisesList.push(cli.downloadKeys([cli.getUserId()]));
}
// Now update the state to say we're waiting for the first sync to complete rather
// than for the login to finish.
this.setState({ pendingInitialSync: true });
await Promise.all(promisesList);
if (!cryptoEnabled) {
this.setState({ pendingInitialSync: false });
return;
}
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
if (crossSigningIsSetUp) {
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
this.setStateForNewView({ view: Views.E2E_SETUP });
} else {
this.onLoggedIn();
}
this.setState({ pendingInitialSync: false });
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
@ -368,6 +411,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.shouldTrackPageChange(prevState, this.state)) {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusComposer);
@ -420,6 +464,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
dis.dispatch({action: "view_welcome_page"});
}
} else if (SettingsStore.getValue("analyticsOptIn")) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
});
// Note we don't catch errors from this: we catch everything within
@ -478,6 +524,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
const newState = {
currentUserId: null,
justRegistered: false,
};
Object.assign(newState, state);
this.setState(newState);
@ -559,11 +606,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
ThemeController.isLogin = true;
this.themeWatcher.recheck();
break;
case 'start_post_registration':
this.setState({
view: Views.POST_REGISTRATION,
});
break;
case 'start_password_recovery':
this.setStateForNewView({
view: Views.FORGOT_PASSWORD,
@ -594,7 +636,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
MatrixClientPeg.get().leave(payload.room_id).then(() => {
modal.close();
if (this.state.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
dis.dispatch({action: 'view_home_page'});
}
}, (err) => {
modal.close();
@ -623,9 +665,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
}
case 'view_next_room':
this.viewNextRoom(1);
break;
case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
@ -650,8 +689,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
case Action.ViewRoomDirectory: {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
'mx_RoomDirectory_dialogWrapper', false, true);
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
@ -668,16 +708,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewWelcome();
break;
case 'view_home_page':
this.viewHome();
break;
case 'view_set_mxid':
this.setMxId(payload);
this.viewHome(payload.justRegistered);
break;
case 'view_start_chat_or_reuse':
this.chatCreateOrReuse(payload.user_id);
break;
case 'view_create_chat':
showStartChatInviteDialog();
showStartChatInviteDialog(payload.initialText || "");
break;
case 'view_invite':
showRoomInviteDialog(payload.roomId);
@ -713,16 +750,13 @@ 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.
});
case Action.OpenDialPad:
Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper");
break;
}
case 'on_logged_in':
if (
// Skip this handling for token login as that always calls onLoggedIn itself
!this.tokenLogin &&
!Lifecycle.isSoftLogout() &&
this.state.view !== Views.LOGIN &&
this.state.view !== Views.REGISTER &&
@ -766,7 +800,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
hideAnalyticsToast();
Analytics.enable();
if (Analytics.canEnable()) {
Analytics.enable();
}
if (CountlyAnalytics.instance.canEnable()) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
break;
case 'reject_cookies':
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
@ -810,35 +849,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.notifyNewScreen('register');
}
// TODO: Move to RoomViewStore
private viewNextRoom(roomIndexDelta: number) {
const allRooms = RoomListSorter.mostRecentActivityFirst(
MatrixClientPeg.get().getRooms(),
);
// If there are 0 rooms or 1 room, view the home page because otherwise
// if there are 0, we end up trying to index into an empty array, and
// if there is 1, we end up viewing the same room.
if (allRooms.length < 2) {
dis.dispatch({
action: 'view_home_page',
});
return;
}
let roomIndex = -1;
for (let i = 0; i < allRooms.length; ++i) {
if (allRooms[i].roomId === this.state.currentRoomId) {
roomIndex = i;
break;
}
}
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
if (roomIndex < 0) roomIndex = allRooms.length - 1;
dis.dispatch({
action: 'view_room',
room_id: allRooms[roomIndex].roomId,
});
}
// switch view to the given room
//
// @param {Object} roomInfo Object containing data about the room to be joined
@ -958,10 +968,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.themeWatcher.recheck();
}
private viewHome() {
private viewHome(justRegistered = false) {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
view: Views.LOGGED_IN,
justRegistered,
});
this.setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
@ -985,36 +996,6 @@ 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) {
@ -1134,9 +1115,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private forgetRoom(roomId: string) {
MatrixClientPeg.get().forget(roomId).then(() => {
// Switch to another room view if we're currently viewing the historical room
// Switch to home page if we're currently viewing the forgotten room
if (this.state.currentRoomId === roomId) {
dis.dispatch({ action: "view_next_room" });
dis.dispatch({ action: "view_home_page" });
}
}).catch((err) => {
const errCode = err.errcode || _td("unknown error code");
@ -1225,7 +1206,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (welcomeUserRoom === null) {
// We didn't redirect to the welcome user room, so show
// the homepage.
dis.dispatch({action: 'view_home_page'});
dis.dispatch({action: 'view_home_page', justRegistered: true});
}
} else if (ThreepidInviteStore.instance.pickBestInvite()) {
// The user has a 3pid invite pending - show them that
@ -1238,7 +1219,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
// The user has just logged in after registering,
// so show the homepage.
dis.dispatch({action: 'view_home_page'});
dis.dispatch({action: 'view_home_page', justRegistered: true});
}
} else {
this.showScreenAfterLogin();
@ -1246,9 +1227,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
StorageManager.tryPersistStorage();
if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) {
// defer the following actions by 30 seconds to not throw them at the user immediately
await sleep(30);
if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) {
showAnalyticsToast(this.props.config.piwik?.policyUrl);
}
if (SdkConfig.get().mobileGuideToast) {
// The toast contains further logic to detect mobile platforms,
// check if it has been dismissed before, etc.
showMobileGuideToast();
}
}
private showScreenAfterLogin() {
@ -1266,12 +1256,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_welcome_page'});
} else if (getHomePageUrl(this.props.config)) {
dis.dispatch({action: 'view_home_page'});
} else {
this.firstSyncPromise.promise.then(() => {
dis.dispatch({action: 'view_next_room'});
});
dis.dispatch({action: 'view_home_page'});
}
}
}
@ -1376,8 +1362,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.firstSyncComplete = true;
this.firstSyncPromise.resolve();
if (Notifier.shouldShowPrompt()) {
showNotificationsToast();
if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) {
showNotificationsToast(false);
}
dis.fire(Action.FocusComposer);
@ -1386,21 +1372,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
});
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;
// A modal might have been open when we were logged out by the server
Modal.closeCurrentModal('Session.logged_out');
if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) {
console.warn("Soft logout issued by server - avoiding data deletion");
Lifecycle.softLogout();
@ -1411,6 +1388,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
title: _t('Signed Out'),
description: _t('For security, this session has been signed out. Please sign in again.'),
});
dis.dispatch({
action: 'logout',
});
@ -1440,6 +1418,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
@ -1581,6 +1560,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
showScreen(screen: string, params?: {[key: string]: any}) {
const cli = MatrixClientPeg.get();
const isLoggedOutOrGuest = !cli || cli.isGuest();
if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) {
// user is logged in and landing on an auth page which will uproot their session, redirect them home instead
dis.dispatch({ action: "view_home_page" });
return;
}
if (screen === 'register') {
dis.dispatch({
action: 'start_registration',
@ -1597,7 +1584,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
params: params,
});
} else if (screen === 'soft_logout') {
if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) {
if (cli.getUserId() && !Lifecycle.isSoftLogout()) {
// Logged in - visit a room
this.viewLastRoom();
} else {
@ -1627,6 +1614,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'require_registration',
});
} else if (screen === 'directory') {
if (this.state.view === Views.WELCOME) {
CountlyAnalytics.instance.track("onboarding_room_directory");
}
dis.fire(Action.ViewRoomDirectory);
} else if (screen === "start_sso" || screen === "start_cas") {
// TODO if logged in, skip SSO
@ -1645,14 +1635,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.dispatch({
action: 'view_my_groups',
});
} else if (screen === 'complete_security') {
dis.dispatch({
action: 'start_complete_security',
});
} else if (screen === 'post_registration') {
dis.dispatch({
action: 'start_post_registration',
});
} else if (screen.indexOf('room/') === 0) {
// Rooms can have the following formats:
// #room_alias:domain or !opaque_id:domain
@ -1676,10 +1658,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149
let threepidInvite: IThreepidInvite;
// if we landed here from a 3PID invite, persist it
if (params.signurl && params.email) {
threepidInvite = ThreepidInviteStore.instance
.storeInvite(roomString, params as IThreepidInviteWireFormat);
}
// otherwise check that this room doesn't already have a known invite
if (!threepidInvite) {
const invites = ThreepidInviteStore.instance.getInvites();
threepidInvite = invites.find(invite => invite.roomId === roomString);
}
// 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
@ -1814,23 +1802,15 @@ 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);
}
onFinishPostRegistration = () => {
// Don't confuse this with "PageType" which is the middle window to show
this.setState({
view: Views.LOGGED_IN,
});
this.showScreen("settings");
};
onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get();
if (!cli) {
@ -1905,7 +1885,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* 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: object, password: string) => {
onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string) => {
this.accountPassword = password;
// self-destruct the password after 5mins
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
@ -1916,40 +1896,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
const cli = MatrixClientPeg.get();
const cryptoEnabled = cli.isCryptoEnabled();
if (!cryptoEnabled) {
this.onLoggedIn();
}
const promisesList = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account
promisesList.push(cli.downloadKeys([cli.getUserId()]));
}
// Now update the state to say we're waiting for the first sync to complete rather
// than for the login to finish.
this.setState({ pendingInitialSync: true });
await Promise.all(promisesList);
if (!cryptoEnabled) {
this.setState({ pendingInitialSync: false });
return;
}
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
if (crossSigningIsSetUp) {
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
this.setStateForNewView({ view: Views.E2E_SETUP });
} else {
this.onLoggedIn();
}
this.setState({ pendingInitialSync: false });
await this.postLoginSetup();
};
// complete security / e2e setup has finished
@ -1993,15 +1940,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
accountPassword={this.accountPassword}
tokenLogin={!!this.tokenLogin}
/>
);
} else if (this.state.view === Views.POST_REGISTRATION) {
// needs to be before normal PageTypes as you are logged in technically
const PostRegistration = sdk.getComponent('structures.auth.PostRegistration');
view = (
<PostRegistration
onComplete={this.onFinishPostRegistration} />
);
} else if (this.state.view === Views.LOGGED_IN) {
// store errors stop the client syncing and require user intervention, so we'll
// be showing a dialog. Don't show anything else.
@ -2065,6 +2006,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
fragmentAfterLogin={fragmentAfterLogin}
{...this.getServerProperties()}
/>
);

View file

@ -23,13 +23,17 @@ import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import * as sdk from '../../index';
import dis from "../../dispatcher/dispatcher";
import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import {Layout, LayoutPropType} from "../../settings/Layout";
import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@ -133,14 +137,13 @@ export default class MessagePanel extends React.Component {
// whether to show reactions for an event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
// which layout to use
layout: LayoutPropType,
// whether or not to show flair at all
enableFlair: PropTypes.bool,
};
// Force props to be loaded for useIRCLayout
constructor(props) {
super(props);
@ -205,11 +208,13 @@ export default class MessagePanel extends React.Component {
componentDidMount() {
this._isMounted = true;
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
this._isMounted = false;
SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
dis.unregister(this.dispatcherRef);
}
componentDidUpdate(prevProps, prevState) {
@ -222,6 +227,14 @@ export default class MessagePanel extends React.Component {
}
}
onAction = (payload) => {
switch (payload.action) {
case "scroll_to_bottom":
this.scrollToBottom();
break;
}
}
onShowTypingNotificationsChange = () => {
this.setState({
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
@ -610,7 +623,7 @@ export default class MessagePanel extends React.Component {
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
useIRCLayout={this.props.useIRCLayout}
layout={this.props.layout}
enableFlair={this.props.enableFlair}
/>
</TileErrorBoundary>
@ -808,7 +821,7 @@ export default class MessagePanel extends React.Component {
}
let ircResizer = null;
if (this.props.useIRCLayout) {
if (this.props.layout == Layout.IRC) {
ircResizer = <IRCTimelineProfileResizer
minWidth={20}
maxWidth={600}
@ -952,15 +965,25 @@ class CreationGrouper {
}).reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1];
let summaryText;
const roomId = ev.getRoomId();
const creator = ev.sender ? ev.sender.name : ev.getSender();
if (DMRoomMap.shared().getUserIdForRoomId(roomId)) {
summaryText = _t("%(creator)s created this DM.", { creator });
} else {
summaryText = _t("%(creator)s created and configured the room.", { creator });
}
ret.push(<NewRoomIntro key="newroomintro" />);
ret.push(
<EventListSummary
key="roomcreationsummary"
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={_t("%(creator)s created and configured the room.", {
creator: ev.sender ? ev.sender.name : ev.getSender(),
})}
summaryText={summaryText}
>
{ eventTiles }
</EventListSummary>,

View file

@ -39,7 +39,7 @@ class NotificationPanel extends React.Component {
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t('Youre all caught up')}</h2>
<p>{_t('You have no visible notifications in this room.')}</p>
<p>{_t('You have no visible notifications.')}</p>
</div>);
let content;

View file

@ -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,6 @@ 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';
@ -34,7 +30,6 @@ 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() {
@ -162,7 +157,7 @@ export default class RightPanel extends React.Component {
}
onRoomStateMember(ev, state, member) {
if (member.roomId !== this.props.room.roomId) {
if (!this.props.room || member.roomId !== this.props.room.roomId) {
return;
}
// redraw the badge on the membership list
@ -190,7 +185,7 @@ export default class RightPanel extends React.Component {
}
}
onCloseUserInfo = () => {
onClose = () => {
// XXX: There are three different ways of 'closing' this panel depending on what state
// things are in... this knows far more than it should do about the state of the rest
// of the app and is generally a bit silly.
@ -202,25 +197,21 @@ 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.
// the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
dis.dispatch({
action: Action.ViewUser,
member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null,
action: Action.ToggleRightPanel,
type: this.props.groupId ? "group" : "room",
});
}
};
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');
@ -258,7 +249,7 @@ export default class RightPanel extends React.Component {
user={this.state.member}
room={this.props.room}
key={roomId || this.state.member.userId}
onClose={this.onCloseUserInfo}
onClose={this.onClose}
phase={this.state.phase}
verificationRequest={this.state.verificationRequest}
verificationRequestPromise={this.state.verificationRequestPromise}
@ -274,7 +265,7 @@ export default class RightPanel extends React.Component {
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.userId}
onClose={this.onCloseUserInfo} />;
onClose={this.onClose} />;
break;
case RightPanelPhases.GroupRoomInfo:
@ -301,14 +292,8 @@ export default class RightPanel extends React.Component {
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>
);

View file

@ -30,12 +30,13 @@ 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 TagOrderStore from "../../stores/TagOrderStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160;
const MAX_TOPIC_LENGTH = 800;
function track(action) {
Analytics.trackEvent('RoomDirectory', action);
@ -43,13 +44,17 @@ function track(action) {
export default class RoomDirectory extends React.Component {
static propTypes = {
initialText: PropTypes.string,
onFinished: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
const selectedCommunityId = TagOrderStore.getSelectedTags()[0];
CountlyAnalytics.instance.trackRoomDirectoryBegin();
this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = {
publicRooms: [],
loading: true,
@ -57,7 +62,7 @@ export default class RoomDirectory extends React.Component {
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: null,
filterString: this.props.initialText || "",
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
@ -198,6 +203,11 @@ export default class RoomDirectory extends React.Component {
return;
}
if (this.state.filterString) {
const count = data.total_room_count_estimate || data.chunk.length;
CountlyAnalytics.instance.trackRoomDirectorySearch(count, this.state.filterString);
}
this.nextBatch = data.next_batch;
this.setState((s) => {
s.publicRooms.push(...(data.chunk || []));
@ -407,7 +417,7 @@ export default class RoomDirectory extends React.Component {
};
onCreateRoomClick = room => {
this.props.onFinished();
this.onFinished();
dis.dispatch({
action: 'view_create_room',
public: true,
@ -419,11 +429,12 @@ export default class RoomDirectory extends React.Component {
}
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
this.props.onFinished();
this.onFinished();
const payload = {
action: 'view_room',
auto_join: autoJoin,
should_peek: shouldPeek,
_type: "room_directory", // instrumentation
};
if (room) {
// Don't let the user view a room they won't be able to either
@ -466,7 +477,7 @@ export default class RoomDirectory extends React.Component {
dis.dispatch(payload);
}
getRow(room) {
createRoomCells(room) {
const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
@ -476,7 +487,11 @@ export default class RoomDirectory extends React.Component {
let previewButton;
let joinOrViewButton;
if (room.world_readable && !hasJoinedRoom) {
// Element Web currently does not allow guests to join rooms, so we
// instead show them preview buttons for all rooms. If the room is not
// world readable, a modal will appear asking you to register first. If
// it is readable, the preview appears as normal.
if (!hasJoinedRoom && (room.world_readable || isGuest)) {
previewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
);
@ -485,7 +500,7 @@ export default class RoomDirectory extends React.Component {
joinOrViewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
);
} else if (!isGuest || room.guest_can_join) {
} else if (!isGuest) {
joinOrViewButton = (
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
);
@ -497,6 +512,9 @@ export default class RoomDirectory extends React.Component {
}
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)}...`;
}
@ -505,31 +523,56 @@ export default class RoomDirectory extends React.Component {
MatrixClientPeg.get().getHomeserverUrl(),
room.avatar_url, 32, 32, "crop",
);
return (
<tr key={ room.room_id }
return [
<div key={ `${room.room_id}_avatar` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomAvatar"
>
<td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={32} height={32} resizeMethod='crop'
name={ name } idName={ name }
url={ avatarUrl } />
</td>
<td className="mx_RoomDirectory_roomDescription">
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }} />
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
</td>
<td className="mx_RoomDirectory_roomMemberCount">
{ room.num_joined_members }
</td>
<td className="mx_RoomDirectory_preview">{previewButton}</td>
<td className="mx_RoomDirectory_join">{joinOrViewButton}</td>
</tr>
);
<BaseAvatar width={32} height={32} resizeMethod='crop'
name={ name } idName={ name }
url={ avatarUrl }
/>
</div>,
<div key={ `${room.room_id}_description` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomDescription"
>
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }}
/>
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
</div>,
<div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomMemberCount"
>
{ room.num_joined_members }
</div>,
<div key={ `${room.room_id}_preview` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_preview"
>
{previewButton}
</div>,
<div key={ `${room.room_id}_join` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_join"
>
{joinOrViewButton}
</div>,
];
}
collectScrollPanel = (element) => {
@ -572,6 +615,11 @@ export default class RoomDirectory extends React.Component {
}
};
onFinished = () => {
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
this.props.onFinished();
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -583,7 +631,8 @@ export default class RoomDirectory extends React.Component {
} else if (this.state.protocolsLoading) {
content = <Loader />;
} else {
const rows = (this.state.publicRooms || []).map(room => this.getRow(room));
const cells = (this.state.publicRooms || [])
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],);
// we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one
@ -594,14 +643,12 @@ export default class RoomDirectory extends React.Component {
}
let scrollpanel_content;
if (rows.length === 0 && !this.state.loading) {
if (cells.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
} else {
scrollpanel_content = <table className="mx_RoomDirectory_table">
<tbody>
{ rows }
</tbody>
</table>;
scrollpanel_content = <div className="mx_RoomDirectory_table">
{ cells }
</div>;
}
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
content = <ScrollPanel ref={this.collectScrollPanel}
@ -668,6 +715,7 @@ export default class RoomDirectory extends React.Component {
onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder}
showJoinButton={showJoinButton}
initialText={this.props.initialText}
/>
{dropdown}
</div>;
@ -690,7 +738,7 @@ export default class RoomDirectory extends React.Component {
<BaseDialog
className={'mx_RoomDirectory_dialog'}
hasCancel={true}
onFinished={this.props.onFinished}
onFinished={this.onFinished}
title={title}
>
<div className="mx_RoomDirectory">

View file

@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={_t("Search")}
placeholder={_t("Filter")}
autoComplete="off"
/>
);
@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
if (this.props.isMinimized) {
icon = (
<AccessibleButton
title={_t("Search rooms")}
title={_t("Filter rooms and people")}
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
onClick={this.openSearch}
/>

View file

@ -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.
@ -20,7 +18,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
@ -42,14 +39,6 @@ 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,
// 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.
@ -67,10 +56,6 @@ export default class RoomStatusBar extends React.Component {
// 'you are alone' bar
onInviteClick: PropTypes.func,
// callback for when the user clicks on the 'stop warning me' button in the
// 'you are alone' bar
onStopWarningClick: PropTypes.func,
// callback for when we do something that changes the size of the
// status bar. This is used to trigger a re-layout in the parent
// component.
@ -152,10 +137,7 @@ export default class RoomStatusBar extends React.Component {
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize() {
if (this._shouldShowConnectionError() ||
this.props.hasActiveCall ||
this.props.sentMessageAndIsAlone
) {
if (this._shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE;
@ -163,22 +145,6 @@ export default class RoomStatusBar extends React.Component {
return STATUS_BAR_HIDDEN;
}
// return suitable content for the image on the left of the status bar.
_getIndicator() {
if (this.props.hasActiveCall) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
);
}
if (this._shouldShowConnectionError()) {
return null;
}
return null;
}
_shouldShowConnectionError() {
// no conn bar trumps the "some not sent" msg since you can't resend without
// a connection!
@ -291,44 +257,14 @@ export default class RoomStatusBar extends React.Component {
return this._getUnsentMessageContent();
}
if (this.props.hasActiveCall) {
return (
<div className="mx_RoomStatusBar_callBar">
<b>{ _t('Active call') }</b>
</div>
);
}
// If you're alone in the room, and have sent a message, suggest to invite someone
if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) {
return (
<div className="mx_RoomStatusBar_isAlone">
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
"or <nowarnText>stop warning about the empty room</nowarnText>?",
{},
{
'inviteText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
'nowarnText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
},
) }
</div>
);
}
return null;
}
render() {
const content = this._getContent();
const indicator = this._getIndicator();
return (
<div className="mx_RoomStatusBar">
<div className="mx_RoomStatusBar_indicator">
{ indicator }
</div>
<div role="alert">
{ content }
</div>

View file

@ -21,15 +21,15 @@ limitations under the License.
// - Search results component
// - Drag and drop
import React, {createRef} from 'react';
import React, { createRef } from 'react';
import classNames from 'classnames';
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {EventSubscription} from "fbemitter";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventSubscription } from "fbemitter";
import shouldHideEvent from '../../shouldHideEvent';
import {_t} from '../../languageHandler';
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
import { _t } from '../../languageHandler';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import ResizeNotifier from '../../utils/ResizeNotifier';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
@ -38,26 +38,25 @@ import CallHandler from '../../CallHandler';
import dis from '../../dispatcher/dispatcher';
import Tinter from '../../Tinter';
import rateLimitedFunc from '../../ratelimitedfunc';
import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch, {searchPagination} from '../../Searching';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import eventSearch, { searchPagination } from '../../Searching';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import {Layout} from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import {haveTileForEvent} from "../views/rooms/EventTile";
import { haveTileForEvent } from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils';
import {Action} from "../../dispatcher/actions";
import {SettingLevel} from "../../settings/SettingLevel";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {IMatrixClientCreds} from "../../MatrixClientPeg";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions";
import { SettingLevel } from "../../settings/SettingLevel";
import { IMatrixClientCreds } from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary";
@ -68,9 +67,19 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg";
import {XOR} from "../../@types/common";
import { XOR } from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects';
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { objectHasDiff } from "../../utils/objects";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -104,7 +113,6 @@ interface IProps {
viaServers?: string[];
autoJoin?: boolean;
disabled?: boolean;
resizeNotifier: ResizeNotifier;
// Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU)
@ -127,6 +135,7 @@ export interface IState {
initialEventPixelOffset?: number;
// Whether to highlight the event scrolled to
isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent;
forwardingEvent?: MatrixEvent;
numUnreadMessages: number;
draggingFile: boolean;
@ -141,11 +150,10 @@ export interface IState {
}>;
searchHighlights?: string[];
searchInProgress?: boolean;
callState?: string;
callState?: CallState;
guestsCanJoin: boolean;
canPeek: boolean;
showApps: boolean;
isAlone: boolean;
isPeeking: boolean;
showingPinned: boolean;
showReadReceipts: boolean;
@ -174,12 +182,13 @@ export interface IState {
};
canReact: boolean;
canReply: boolean;
useIRCLayout: boolean;
layout: Layout;
matrixClientIsReady: boolean;
showUrlPreview?: boolean;
e2eStatus?: E2EStatus;
rejecting?: boolean;
rejectError?: Error;
hasPinnedWidgets?: boolean;
}
export default class RoomView extends React.Component<IProps, IState> {
@ -217,7 +226,6 @@ export default class RoomView extends React.Component<IProps, IState> {
guestsCanJoin: false,
canPeek: false,
showApps: false,
isAlone: false,
isPeeking: false,
showingPinned: false,
showReadReceipts: true,
@ -229,7 +237,7 @@ export default class RoomView extends React.Component<IProps, IState> {
statusBarVisible: false,
canReact: false,
canReply: false,
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
layout: SettingsStore.getValue("layout"),
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
};
@ -246,22 +254,33 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.on("Event.decrypted", this.onEventDecrypted);
this.context.on("event", this.onEvent);
// Start listening for RoomViewStore updates
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate);
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this.onReadReceiptsChange);
this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange);
}
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this.onRoomViewStoreUpdate(true);
private onWidgetStoreUpdate = () => {
if (this.state.room) {
this.checkWidgets(this.state.room);
}
}
private checkWidgets = (room) => {
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0,
showApps: this.shouldShowApps(room),
});
};
private onReadReceiptsChange = () => {
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
@ -298,6 +317,7 @@ export default class RoomView extends React.Component<IProps, IState> {
joining: RoomViewStore.isJoining(),
initialEventId: RoomViewStore.getInitialEventId(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
replyToEvent: RoomViewStore.getQuotingEvent(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
// we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
@ -395,11 +415,17 @@ export default class RoomView extends React.Component<IProps, IState> {
}
private onWidgetEchoStoreUpdate = () => {
if (!this.state.room) return;
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0,
showApps: this.shouldShowApps(this.state.room),
});
};
private onWidgetLayoutChange = () => {
this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters
};
private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) {
// if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek)
@ -465,7 +491,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}
private shouldShowApps(room: Room) {
if (!BROWSER_SUPPORTS_SANDBOX) return false;
if (!BROWSER_SUPPORTS_SANDBOX || !room) return false;
// Check if user has previously chosen to hide the app drawer for this
// room. If so, do not show apps
@ -474,12 +500,17 @@ export default class RoomView extends React.Component<IProps, IState> {
// This is confusing, but it means to say that we default to the tray being
// hidden unless the user clicked to open it.
return hideWidgetDrawer === "false";
const isManuallyShown = hideWidgetDrawer === "false";
const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
return widgets.length > 0 || isManuallyShown;
}
componentDidMount() {
this.onRoomViewStoreUpdate(true);
const call = this.getCallForRoom();
const callState = call ? call.call_state : "ended";
const callState = call ? call.state : null;
this.setState({
callState: callState,
});
@ -489,13 +520,10 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.onResize();
document.addEventListener("keydown", this.onNativeKeyDown);
}
shouldComponentUpdate(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState));
}
componentDidUpdate() {
@ -566,6 +594,8 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.removeListener("Event.decrypted", this.onEventDecrypted);
this.context.removeListener("event", this.onEvent);
}
window.removeEventListener('beforeunload', this.onPageUnload);
@ -573,8 +603,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
document.removeEventListener("keydown", this.onNativeKeyDown);
// Remove RoomStore listener
if (this.roomStoreToken) {
this.roomStoreToken.remove();
@ -584,7 +612,15 @@ export default class RoomView extends React.Component<IProps, IState> {
this.rightPanelStoreToken.remove();
}
WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate);
WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate);
if (this.state.room) {
WidgetLayoutStore.instance.off(
WidgetLayoutStore.emissionForRoom(this.state.room),
this.onWidgetLayoutChange,
);
}
if (this.showReadReceiptsWatchRef) {
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
@ -602,7 +638,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private onLayoutChange = () => {
this.setState({
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
layout: SettingsStore.getValue("layout"),
});
};
@ -622,33 +658,6 @@ export default class RoomView extends React.Component<IProps, IState> {
}
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
private onNativeKeyDown = ev => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMuteAudioClick();
handled = true;
}
break;
case Key.E:
if (ctrlCmdOnly) {
this.onMuteVideoClick();
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onReactKeyDown = ev => {
let handled = false;
@ -683,9 +692,8 @@ export default class RoomView extends React.Component<IProps, IState> {
private onAction = payload => {
switch (payload.action) {
case 'message_send_failed':
case 'message_sent':
this.checkIfAlone(this.state.room);
this.checkDesktopNotifications();
break;
case 'post_sticker_message':
this.injectSticker(
@ -712,14 +720,9 @@ export default class RoomView extends React.Component<IProps, IState> {
}
const call = this.getCallForRoom();
let callState = "ended";
if (call) {
callState = call.call_state;
}
this.setState({
callState: callState,
callState: call ? call.state : null,
});
break;
}
@ -759,6 +762,9 @@ export default class RoomView extends React.Component<IProps, IState> {
});
}
break;
case 'focus_search':
this.onSearchClick();
break;
}
};
@ -800,6 +806,30 @@ export default class RoomView extends React.Component<IProps, IState> {
}
};
private onEventDecrypted = (ev) => {
if (ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private onEvent = (ev) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private handleEffects = (ev) => {
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
if (!notifState.isUnread) return;
CHAT_EFFECTS.forEach(effect => {
if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) {
dis.dispatch({action: `effects.${effect.command}`});
}
});
};
private onRoomName = (room: Room) => {
if (this.state.room && room.roomId == this.state.room.roomId) {
this.forceUpdate();
@ -822,12 +852,17 @@ export default class RoomView extends React.Component<IProps, IState> {
// called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room).
private onRoomLoaded = (room: Room) => {
// Attach a widget store listener only when we get a room
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
this.onWidgetLayoutChange(); // provoke an update
this.calculatePeekRules(room);
this.updatePreviewUrlVisibility(room);
this.loadMembersIfJoined(room);
this.calculateRecommendedVersion(room);
this.updateE2EStatus(room);
this.updatePermissions(room);
this.checkWidgets(room);
};
private async calculateRecommendedVersion(room: Room) {
@ -883,6 +918,15 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!room || room.roomId !== this.state.roomId) {
return;
}
// Detach the listener if the room is changing for some reason
if (this.state.room) {
WidgetLayoutStore.instance.off(
WidgetLayoutStore.emissionForRoom(this.state.room),
this.onWidgetLayoutChange,
);
}
this.setState({
room: room,
}, () => {
@ -1007,33 +1051,17 @@ export default class RoomView extends React.Component<IProps, IState> {
}
// rate limited because a power level change will emit an event for every member in the room.
private updateRoomMembers = rateLimitedFunc((dueToMember) => {
private updateRoomMembers = rateLimitedFunc(() => {
this.updateDMState();
let memberCountInfluence = 0;
if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) {
// A member got invited, but the room hasn't detected that change yet. Influence the member
// count by 1 to counteract this.
memberCountInfluence = 1;
}
this.checkIfAlone(this.state.room, memberCountInfluence);
this.updateE2EStatus(this.state.room);
}, 500);
private checkIfAlone(room: Room, countInfluence?: number) {
let warnedAboutLonelyRoom = false;
if (localStorage) {
warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId));
private checkDesktopNotifications() {
const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount();
// if they are not alone prompt the user about notifications so they don't miss replies
if (memberCount > 1 && Notifier.shouldShowPrompt()) {
showNotificationsToast(true);
}
if (warnedAboutLonelyRoom) {
if (this.state.isAlone) this.setState({isAlone: false});
return;
}
let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
if (countInfluence) joinedOrInvitedMemberCount += countInfluence;
this.setState({isAlone: joinedOrInvitedMemberCount === 1});
}
private updateDMState() {
@ -1068,14 +1096,6 @@ export default class RoomView extends React.Component<IProps, IState> {
action: 'view_invite',
roomId: this.state.room.roomId,
});
this.setState({isAlone: false}); // there's a good chance they'll invite someone
};
private onStopAloneWarningClick = () => {
if (localStorage) {
localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true));
}
this.setState({isAlone: false});
};
private onJoinButtonClicked = () => {
@ -1090,48 +1110,14 @@ export default class RoomView extends React.Component<IProps, IState> {
room_id: this.getRoomId(),
},
});
// Don't peek whilst registering otherwise getPendingEventList complains
// Do this by indicating our intention to join
// XXX: ILAG is disabled for now,
// see https://github.com/vector-im/element-web/issues/8222
dis.dispatch({action: 'require_registration'});
// dis.dispatch({
// action: 'will_join',
// });
// const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
// const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
// homeserverUrl: cli.getHomeserverUrl(),
// onFinished: (submitted, credentials) => {
// if (submitted) {
// this.props.onRegistered(credentials);
// } else {
// dis.dispatch({
// action: 'cancel_after_sync_prepared',
// });
// dis.dispatch({
// action: 'cancel_join',
// });
// }
// },
// onDifferentServerClicked: (ev) => {
// dis.dispatch({action: 'start_registration'});
// close();
// },
// onLoginClick: (ev) => {
// dis.dispatch({action: 'start_login'});
// close();
// },
// }).close;
// return;
} else {
Promise.resolve().then(() => {
const signUrl = this.props.threepidInvite?.signUrl;
dis.dispatch({
action: 'join_room',
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
_type: "unknown", // TODO: instrumentation
});
return Promise.resolve();
});
@ -1158,16 +1144,9 @@ export default class RoomView extends React.Component<IProps, IState> {
ev.dataTransfer.dropEffect = 'none';
const items = [...ev.dataTransfer.items];
if (items.length >= 1) {
const isDraggingFiles = items.every(function(item) {
return item.kind == 'file';
});
if (isDraggingFiles) {
this.setState({ draggingFile: true });
ev.dataTransfer.dropEffect = 'copy';
}
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
this.setState({ draggingFile: true });
ev.dataTransfer.dropEffect = 'copy';
}
};
@ -1298,7 +1277,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}
if (!this.state.searchResults.next_batch) {
if (this.state.searchResults.results.length == 0) {
if (!this.state.searchResults?.results?.length) {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
</li>,
@ -1322,7 +1301,7 @@ export default class RoomView extends React.Component<IProps, IState> {
let lastRoomId;
for (let i = this.state.searchResults.results.length - 1; i >= 0; i--) {
for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) {
const result = this.state.searchResults.results[i];
const mxEv = result.context.getEvent();
@ -1374,10 +1353,7 @@ export default class RoomView extends React.Component<IProps, IState> {
};
private onSettingsClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
});
dis.dispatch({ action: "open_room_settings" });
};
private onCancelClick = () => {
@ -1392,6 +1368,13 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.fire(Action.FocusComposer);
};
private onAppsClick = () => {
dis.dispatch({
action: "appsDrawer",
show: !this.state.showApps,
});
};
private onLeaveClick = () => {
dis.dispatch({
action: 'leave_room',
@ -1411,7 +1394,7 @@ export default class RoomView extends React.Component<IProps, IState> {
rejecting: true,
});
this.context.leave(this.state.roomId).then(() => {
dis.dispatch({ action: 'view_next_room' });
dis.dispatch({ action: 'view_home_page' });
this.setState({
rejecting: false,
});
@ -1445,7 +1428,7 @@ export default class RoomView extends React.Component<IProps, IState> {
await this.context.setIgnoredUsers(ignoredUsers);
await this.context.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' });
dis.dispatch({ action: 'view_home_page' });
this.setState({
rejecting: false,
});
@ -1640,7 +1623,7 @@ export default class RoomView extends React.Component<IProps, IState> {
/**
* get any current call for this room
*/
private getCallForRoom() {
private getCallForRoom(): MatrixCall {
if (!this.state.room) {
return null;
}
@ -1777,10 +1760,13 @@ export default class RoomView extends React.Component<IProps, IState> {
// We have successfully loaded this room, and are not previewing.
// Display the "normal" room view.
const call = this.getCallForRoom();
let inCall = false;
if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) {
inCall = true;
let activeCall = null;
{
// New block because this variable doesn't need to hang around for the rest of the function
const call = this.getCallForRoom();
if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) {
activeCall = call;
}
}
const scrollheaderClasses = classNames({
@ -1798,11 +1784,8 @@ export default class RoomView extends React.Component<IProps, IState> {
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar
room={this.state.room}
sentMessageAndIsAlone={this.state.isAlone}
hasActiveCall={inCall}
isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
/>;
@ -1820,7 +1803,6 @@ export default class RoomView extends React.Component<IProps, IState> {
let aux = null;
let previewBar;
let hideCancel = false;
let forceHideRightPanel = false;
if (this.state.forwardingEvent) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
@ -1865,8 +1847,6 @@ export default class RoomView extends React.Component<IProps, IState> {
{ previewBar }
</div>
);
} else {
forceHideRightPanel = true;
}
} else if (hiddenHighlightCount > 0) {
aux = (
@ -1891,7 +1871,6 @@ export default class RoomView extends React.Component<IProps, IState> {
draggingFile={this.state.draggingFile}
maxHeight={this.state.auxPanelMaxHeight}
showApps={this.state.showApps}
hideAppsDrawer={false}
onResize={this.onResize}
resizeNotifier={this.props.resizeNotifier}
>
@ -1910,10 +1889,10 @@ export default class RoomView extends React.Component<IProps, IState> {
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
/>;
}
@ -1928,55 +1907,6 @@ export default class RoomView extends React.Component<IProps, IState> {
};
}
if (inCall) {
let zoomButton; let videoMuteButton;
if (call.type === "video") {
zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg
src={require("../../../res/img/element-icons/call/fullscreen.svg")}
width="29"
height="22"
style={{ marginTop: 1, marginRight: 4 }}
/>
</div>
);
videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg
src={call.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")}
alt={call.isLocalVideoMuted() ? _t("Click to unmute video") : _t("Click to mute video")}
width=""
height="27"
/>
</div>;
}
const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg
src={call.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={call.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21"
height="26"
/>
</div>;
// wrap the existing status bar into a 'callStatusBar' which adds more knobs.
statusBar =
<div className="mx_RoomView_callStatusBar">
{ voiceMuteButton }
{ videoMuteButton }
{ zoomButton }
{ statusBar }
</div>;
}
// if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it.
let searchResultsPanel;
@ -1984,7 +1914,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (this.state.searchResults) {
// show searching spinner
if (this.state.searchResults.results === undefined) {
if (this.state.searchResults.count === undefined) {
searchResultsPanel = (
<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />
);
@ -2015,8 +1945,8 @@ export default class RoomView extends React.Component<IProps, IState> {
const messagePanelClassNames = classNames(
"mx_RoomView_messagePanel",
{
"mx_IRCLayout": this.state.useIRCLayout,
"mx_GroupLayout": !this.state.useIRCLayout,
"mx_IRCLayout": this.state.layout == Layout.IRC,
"mx_GroupLayout": this.state.layout == Layout.Group,
});
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
@ -2039,7 +1969,7 @@ export default class RoomView extends React.Component<IProps, IState> {
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
resizeNotifier={this.props.resizeNotifier}
showReactions={true}
useIRCLayout={this.state.useIRCLayout}
layout={this.state.layout}
/>);
let topUnreadMessagesBar = null;
@ -2065,11 +1995,7 @@ export default class RoomView extends React.Component<IProps, IState> {
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
});
const fadableSectionClasses = classNames("mx_RoomView_body", "mx_fadable", {
"mx_fadable_faded": this.props.disabled,
});
const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel;
const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
: null;
@ -2079,12 +2005,17 @@ export default class RoomView extends React.Component<IProps, IState> {
});
const mainClasses = classNames("mx_RoomView", {
mx_RoomView_inCall: inCall,
mx_RoomView_inCall: Boolean(activeCall),
});
const showChatEffects = SettingsStore.getValue('showChatEffects');
return (
<RoomContext.Provider value={this.state}>
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
{showChatEffects && this.roomView.current &&
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
}
<ErrorBoundary>
<RoomHeader
room={this.state.room}
@ -2098,9 +2029,11 @@ export default class RoomView extends React.Component<IProps, IState> {
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null}
appsShown={this.state.showApps}
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className={fadableSectionClasses}>
<div className="mx_RoomView_body">
{auxPanel}
<div className={timelineClasses}>
{topUnreadMessagesBar}

View file

@ -704,7 +704,7 @@ export default class ScrollPanel extends React.Component {
if (itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight;
}
if (sn.scrollTop !== sn.scrollHeight){
if (sn.scrollTop !== sn.scrollHeight) {
sn.scrollTop = sn.scrollHeight;
}
debuglog("updateHeight to", newHeight);

View file

@ -20,7 +20,6 @@ import * as React from "react";
import {_t} from '../../languageHandler';
import * as sdk from "../../index";
import AutoHideScrollbar from './AutoHideScrollbar';
import { ReactNode } from "react";
/**
* Represents a tab for the TabbedView.

View file

@ -18,6 +18,7 @@ limitations under the License.
*/
import SettingsStore from "../../settings/SettingsStore";
import {LayoutPropType} from "../../settings/Layout";
import React, {createRef} from 'react';
import ReactDOM from "react-dom";
import PropTypes from 'prop-types';
@ -25,7 +26,6 @@ import {EventTimeline} from "matrix-js-sdk";
import * as Matrix from "matrix-js-sdk";
import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as ObjectUtils from "../../ObjectUtils";
import UserActivity from "../../UserActivity";
import Modal from "../../Modal";
import dis from "../../dispatcher/dispatcher";
@ -36,6 +36,7 @@ import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import {haveTileForEvent} from "../views/rooms/EventTile";
import {UIFeature} from "../../settings/UIFeature";
import {objectHasDiff} from "../../utils/objects";
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
@ -111,8 +112,8 @@ class TimelinePanel extends React.Component {
// whether to show reactions for an event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
// which layout to use
layout: LayoutPropType,
}
// a map from room id to read marker event timestamp
@ -260,7 +261,7 @@ class TimelinePanel extends React.Component {
}
shouldComponentUpdate(nextProps, nextState) {
if (!ObjectUtils.shallowEqual(this.props, nextProps)) {
if (objectHasDiff(this.props, nextProps)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: props change");
console.log("props before:", this.props);
@ -270,7 +271,7 @@ class TimelinePanel extends React.Component {
return true;
}
if (!ObjectUtils.shallowEqual(this.state, nextState)) {
if (objectHasDiff(this.state, nextState)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: state change");
console.log("state before:", this.state);
@ -715,26 +716,22 @@ class TimelinePanel extends React.Component {
}
this.lastRMSentEventId = this.state.readMarkerEventId;
const roomId = this.props.timelineSet.room.roomId;
const hiddenRR = !SettingsStore.getValue("sendReadReceipts", roomId);
debuglog('TimelinePanel: Sending Read Markers for ',
this.props.timelineSet.room.roomId,
'rm', this.state.readMarkerEventId,
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
' hidden:' + hiddenRR,
);
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
this.state.readMarkerEventId,
lastReadEvent, // Could be null, in which case no RR is sent
{hidden: hiddenRR},
{},
).catch((e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
return MatrixClientPeg.get().sendReadReceipt(
lastReadEvent,
{hidden: hiddenRR},
{},
).catch((e) => {
console.error(e);
this.lastRRSentEventId = undefined;
@ -1446,7 +1443,7 @@ class TimelinePanel extends React.Component {
getRelationsForEvent={this.getRelationsForEvent}
editState={this.state.editState}
showReactions={this.props.showReactions}
useIRCLayout={this.props.useIRCLayout}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
/>
);

View file

@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> {
let toast;
if (totalCount !== 0) {
const topToast = this.state.toasts[0];
const {title, icon, key, component, props} = topToast;
const {title, icon, key, component, className, props} = topToast;
const toastClasses = classNames("mx_Toast_toast", {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
});
}, className);
let countIndicator;
if (isStacked || this.state.countSeen > 0) {

View file

@ -86,7 +86,9 @@ export default class UploadBar extends React.Component {
}
// MUST use var name 'count' for pluralization to kick in
const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)});
const uploadText = _t(
"Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)},
);
return (
<div className="mx_UploadBar">

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -23,13 +23,12 @@ import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink";
import {ButtonEvent} from "../views/elements/AccessibleButton";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
@ -44,13 +43,15 @@ import IconizedContextMenu, {
} from "../views/context_menus/IconizedContextMenu";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import * as fbEmitter from "fbemitter";
import TagOrderStore from "../../stores/TagOrderStore";
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";
import HostSignupAction from "./HostSignupAction";
import {IHostSignupConfig} from "../views/dialogs/HostSignupDialogTypes";
interface IProps {
isMinimized: boolean;
@ -87,7 +88,7 @@ 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 = TagOrderStore.addListener(this.onTagStoreUpdate);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
}
public componentWillUnmount() {
@ -186,15 +187,32 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onSignOutClick = (ev: ButtonEvent) => {
private onSignOutClick = async (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
const cli = MatrixClientPeg.get();
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
// log out without user prompt if they have no local megolm sessions
dis.dispatch({action: 'logout'});
} else {
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
}
this.setState({contextMenuPosition: null}); // also close the menu
};
private onSignInClick = () => {
dis.dispatch({ action: 'start_login' });
this.setState({contextMenuPosition: null}); // also close the menu
};
private onRegisterClick = () => {
dis.dispatch({ action: 'start_registration' });
this.setState({contextMenuPosition: null}); // also close the menu
};
@ -203,6 +221,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation();
defaultDispatcher.dispatch({action: 'view_home_page'});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onCommunitySettingsClick = (ev: ButtonEvent) => {
@ -253,26 +272,40 @@ export default class UserMenu extends React.Component<IProps, IState> {
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let hostingLink;
const signupLink = getHostingLink("user-context-menu");
if (signupLink) {
hostingLink = (
<div className="mx_UserMenu_contextMenu_header">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
let topSection;
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup;
if (MatrixClientPeg.get().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
{_t("Got an account? <a>Sign in</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onSignInClick}>
{sub}
</AccessibleButton>
),
})}
{_t("New here? <a>Create an account</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onRegisterClick}>
{sub}
</AccessibleButton>
),
})}
</div>
);
)
} else if (hostSignupConfig) {
if (hostSignupConfig && hostSignupConfig.url) {
// If hostSignup.domains is set to a non-empty array, only show
// dialog if the user is on the domain or a subdomain.
const hostSignupDomains = hostSignupConfig.domains || [];
const mxDomain = MatrixClientPeg.get().getDomain();
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
if (!hostSignupConfig.domains || validDomains.length > 0) {
topSection = <div onClick={this.onCloseMenu}>
<HostSignupAction />
</div>;
}
}
}
let homeButton = null;
@ -414,6 +447,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
</IconizedContextMenuOptionList>
</React.Fragment>
)
} else if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
</IconizedContextMenuOptionList>
</React.Fragment>
);
}
const classes = classNames({
@ -443,7 +490,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</AccessibleTooltipButton>
</div>
{hostingLink}
{topSection}
{primaryOptionList}
{secondarySection}
</IconizedContextMenu>;
@ -452,7 +499,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
public render() {
const avatarSize = 32; // should match border-radius of the avatar
const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId();
const userId = MatrixClientPeg.get().getUserId();
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
@ -507,7 +555,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
<div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar
idName={displayName}
idName={userId}
name={displayName}
url={avatarUrl}
width={avatarSize}

View file

@ -24,6 +24,7 @@ export default class E2eSetup extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
accountPassword: PropTypes.string,
tokenLogin: PropTypes.bool,
};
render() {
@ -33,6 +34,7 @@ export default class E2eSetup extends React.Component {
<CreateCrossSigningDialog
onFinished={this.props.onFinished}
accountPassword={this.props.accountPassword}
tokenLogin={this.props.tokenLogin}
/>
</CompleteSecurityBody>
</AuthPage>

View file

@ -21,15 +21,14 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
import ServerPicker from "../../views/elements/ServerPicker";
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the forgot password inputs
const PHASE_FORGOT = 1;
// Email is in the process of being sent
@ -61,9 +60,14 @@ export default class ForgotPassword extends React.Component {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
serverRequiresIdServer: null,
};
constructor(props) {
super(props);
CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
}
componentDidMount() {
this.reset = null;
this._checkServerLiveliness(this.props.serverConfig);
@ -86,12 +90,8 @@ export default class ForgotPassword extends React.Component {
serverConfig.isUrl,
);
const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl);
const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam();
this.setState({
serverIsAlive: true,
serverRequiresIdServer,
});
} catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
@ -170,20 +170,6 @@ export default class ForgotPassword extends React.Component {
});
};
onServerDetailsNextPhaseClick = async () => {
this.setState({
phase: PHASE_FORGOT,
});
};
onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
};
onLoginClick = ev => {
ev.preventDefault();
ev.stopPropagation();
@ -198,24 +184,6 @@ export default class ForgotPassword extends React.Component {
});
}
renderServerDetails() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={0}
showIdentityServerIfRequiredByHomeserver={true}
onAfterSubmit={this.onServerDetailsNextPhaseClick}
submitText={_t("Next")}
submitClass="mx_Login_submit"
/>;
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
@ -239,57 +207,13 @@ export default class ForgotPassword extends React.Component {
);
}
let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
// If custom URLs are allowed, wire up the server details edit link.
let editLink = null;
if (!SdkConfig.get()['disable_custom_urls']) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) {
return <div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
{_t(
"No identity server is configured: " +
"add one in server settings to reset your password.",
)}
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}
</a>
</div>;
}
return <div>
{errorText}
{serverDeadSection}
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
/>
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<Field
@ -299,15 +223,20 @@ export default class ForgotPassword extends React.Component {
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
autoFocus
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
/>
</div>
<div className="mx_AuthBody_fieldRow">
<Field
name="reset_password"
type="password"
label={_t('Password')}
label={_t('New Password')}
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
autoComplete="new-password"
/>
<Field
name="reset_password_confirm"
@ -315,6 +244,9 @@ export default class ForgotPassword extends React.Component {
label={_t('Confirm')}
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
autoComplete="new-password"
/>
</div>
<span>{_t(
@ -367,9 +299,6 @@ export default class ForgotPassword extends React.Component {
let resetPasswordJsx;
switch (this.state.phase) {
case PHASE_SERVER_DETAILS:
resetPasswordJsx = this.renderServerDetails();
break;
case PHASE_FORGOT:
resetPasswordJsx = this.renderForgot();
break;

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2015, 2016, 2017, 2018, 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.
@ -16,32 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, {ReactNode} from 'react';
import {MatrixError} from "matrix-js-sdk/src/http-api";
import {_t, _td} from '../../../languageHandler';
import * as sdk from '../../../index';
import Login from '../../../Login';
import Login, {ISSOFlow, LoginFlow} from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
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]*$/;
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
// Enable phases for login
const PHASES_ENABLED = true;
import CountlyAnalytics from "../../../CountlyAnalytics";
import {IMatrixClientCreds} from "../../../MatrixClientPeg";
import PasswordLogin from "../../views/auth/PasswordLogin";
import InlineSpinner from "../../views/elements/InlineSpinner";
import Spinner from "../../views/elements/Spinner";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
@ -54,64 +47,80 @@ _td("Invalid base_url for m.identity_server");
_td("Identity server URL does not appear to be a valid identity server");
_td("General failure");
interface IProps {
serverConfig: ValidatedServerConfig;
// If true, the component will consider itself busy.
busy?: boolean;
isSyncing?: boolean;
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl?: string;
defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string;
// 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
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(data: IMatrixClientCreds, password: string): void;
// login shouldn't know or care how registration, password recovery, etc is done.
onRegisterClick(): void;
onForgotPasswordClick?(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
interface IState {
busy: boolean;
busyLoggingIn?: boolean;
errorText?: ReactNode;
loginIncorrect: boolean;
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
flows?: LoginFlow[];
// used for preserving form values when changing homeserver
username: string;
phoneCountry?: string;
phoneNumber: string;
// 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: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
}
/*
* A wire component which glues together login UI components and Login logic
*/
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
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
export default class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic: Login;
// If true, the component will consider itself busy.
busy: PropTypes.bool,
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration, password recovery,
// etc is done.
onRegisterClick: PropTypes.func.isRequired,
onForgotPasswordClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
isSyncing: PropTypes.bool,
};
private readonly stepRendererMap: Record<string, () => ReactNode>;
constructor(props) {
super(props);
this._unmounted = false;
this.state = {
busy: false,
busyLoggingIn: null,
errorText: null,
loginIncorrect: false,
canTryLogin: true, // can we attempt to log in or are there validation errors?
canTryLogin: true,
flows: null,
// used for preserving form values when changing homeserver
username: "",
phoneCountry: null,
phoneNumber: "",
// Phase of the overall login dialog.
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: null, // we need to load the flows from the server
// 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: "",
@ -119,23 +128,25 @@ export default class LoginComponent extends React.Component {
// map from login step type to a function which will render a control
// letting you do that login type
this._stepRendererMap = {
'm.login.password': this._renderPasswordStep,
this.stepRendererMap = {
'm.login.password': this.renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to
'm.login.cas': () => this._renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep("sso"),
'm.login.cas': () => this.renderSsoStep("cas"),
'm.login.sso': () => this.renderSsoStep("sso"),
};
CountlyAnalytics.instance.track("onboarding_login_begin");
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this._initLoginLogic();
this.initLoginLogic(this.props.serverConfig);
}
componentWillUnmount() {
this._unmounted = true;
this.unmounted = true;
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -145,16 +156,9 @@ export default class LoginComponent extends React.Component {
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);
this.initLoginLogic(newProps.serverConfig);
}
onPasswordLoginError = errorText => {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
};
isBusy = () => this.state.busy || this.props.busy;
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
@ -191,13 +195,13 @@ export default class LoginComponent extends React.Component {
loginIncorrect: false,
});
this._loginLogic.loginViaPassword(
this.loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data, password);
}, (error) => {
if (this._unmounted) {
if (this.unmounted) {
return;
}
let errorText;
@ -209,21 +213,23 @@ export default class LoginComponent extends React.Component {
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
error.data.admin_contact,
{
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
},
);
const errorDetail = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
error.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
errorText = (
<div>
<div>{errorTop}</div>
@ -250,7 +256,7 @@ export default class LoginComponent extends React.Component {
}
} else {
// other errors, not specific to doing a password login
errorText = this._errorTextFromError(error);
errorText = this.errorTextFromError(error);
}
this.setState({
@ -288,7 +294,7 @@ export default class LoginComponent extends React.Component {
// the busy state. In the case of a full MXID that resolves to the same
// HS as Element's default HS though, there may not be any server change.
// To avoid this trap, we clear busy here. For cases where the server
// actually has changed, `_initLoginLogic` will be called and manages
// actually has changed, `initLoginLogic` will be called and manages
// busy state for its own liveness check.
this.setState({
busy: false,
@ -301,7 +307,7 @@ export default class LoginComponent extends React.Component {
message = e.translatedMessage;
}
let errorText = message;
let errorText: ReactNode = message;
let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText;
@ -327,21 +333,6 @@ export default class LoginComponent extends React.Component {
});
};
onPhoneNumberBlur = phoneNumber => {
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({
errorText: _t('The phone number entered looks invalid'),
canTryLogin: false,
});
} else {
this.setState({
errorText: null,
canTryLogin: true,
});
}
};
onRegisterClick = ev => {
ev.preventDefault();
ev.stopPropagation();
@ -349,14 +340,16 @@ export default class LoginComponent extends React.Component {
};
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,
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
const hasPasswordFlow = this.state.flows?.find(flow => flow.type === "m.login.password");
const ssoFlow = this.state.flows?.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
// TODO: instead hide the Register button if registration is disabled by checking with the server,
// has no specific errCode currently and uses M_FORBIDDEN.
if (ssoFlow && !hasPasswordFlow) {
ev.preventDefault();
ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind,
const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
this.props.fragmentAfterLogin);
} else {
// Don't intercept - just go through to the register page
@ -364,24 +357,7 @@ export default class LoginComponent extends React.Component {
}
};
onServerDetailsNextPhaseClick = () => {
this.setState({
phase: PHASE_LOGIN,
});
};
onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
};
async _initLoginLogic(hsUrl, isUrl) {
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) {
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
@ -394,11 +370,10 @@ export default class LoginComponent extends React.Component {
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
this._loginLogic = loginLogic;
this.loginLogic = loginLogic;
this.setState({
busy: true,
currentFlow: null, // reset flow
loginIncorrect: false,
});
@ -422,42 +397,26 @@ export default class LoginComponent extends React.Component {
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e),
});
if (this.state.serverErrorIsFatal) {
// Server is dead: show server details prompt instead
this.setState({
phase: PHASE_SERVER_DETAILS,
});
return;
}
}
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) {
if (!this._isSupportedFlow(flows[i])) {
continue;
}
const supportedFlows = flows.filter(this.isSupportedFlow);
// we just pick the first flow where we support all the
// steps. (we don't have a UI for multiple logins so let's skip
// that for now).
loginLogic.chooseFlow(i);
if (supportedFlows.length > 0) {
this.setState({
currentFlow: this._getCurrentFlowStep(),
flows: supportedFlows,
});
return;
}
// we got to the end of the list without finding a suitable
// flow.
// we got to the end of the list without finding a suitable flow.
this.setState({
errorText: _t(
"This homeserver doesn't offer any login flows which are " +
"supported by this client.",
),
errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."),
});
}, (err) => {
this.setState({
errorText: this._errorTextFromError(err),
errorText: this.errorTextFromError(err),
loginIncorrect: false,
canTryLogin: false,
});
@ -468,28 +427,24 @@ export default class LoginComponent extends React.Component {
});
}
_isSupportedFlow(flow) {
private isSupportedFlow = (flow: LoginFlow): boolean => {
// 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]) {
if (!this.stepRendererMap[flow.type]) {
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false;
}
return true;
}
};
_getCurrentFlowStep() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
}
_errorTextFromError(err) {
private errorTextFromError(err: MatrixError): ReactNode {
let errCode = err.errcode;
if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus;
}
let errorText = _t("Error: Problem communicating with the given homeserver.") +
(errCode ? " (" + errCode + ")" : "");
let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " +
"please try again later.") + (errCode ? " (" + errCode + ")" : "");
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
@ -499,29 +454,27 @@ export default class LoginComponent extends React.Component {
errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
) }
}) }
</span>;
} else {
errorText = <span>
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
"is not blocking requests.", {},
{
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>,
},
) }
{
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>,
}) }
</span>;
}
}
@ -529,121 +482,63 @@ export default class LoginComponent extends React.Component {
return errorText;
}
renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
renderLoginComponentForFlows() {
if (!this.state.flows) return null;
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
// this is the ideal order we want to show the flows in
const order = [
"m.login.password",
"m.login.sso",
];
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
return null;
}
const serverDetailsProps = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean);
return <React.Fragment>
{ flows.map(flow => {
const stepRenderer = this.stepRendererMap[flow.type];
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>
}) }
</React.Fragment>
}
renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) {
return null;
}
const step = this.state.currentFlow;
if (!step) {
return null;
}
const stepRenderer = this._stepRendererMap[step];
if (stepRenderer) {
return stepRenderer();
}
return null;
}
_renderPasswordStep = () => {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
private renderPasswordStep = () => {
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
onEditServerDetailsClick={onEditServerDetailsClick}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
onSubmit={this.onPasswordLogin}
username={this.state.username}
phoneCountry={this.state.phoneCountry}
phoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
/>
);
};
_renderSsoStep = loginType => {
const SignInToText = sdk.getComponent('views.auth.SignInToText');
private renderSsoStep = loginType => {
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
// redirecting the user back to a URI once they're logged in. On the web, this means
// we use the same window and redirect back to Element. On Electron, this actually
// opens the SSO page in the Electron app itself due to
// https://github.com/electron/electron/issues/8841 and so happens to work.
// If this bug gets fixed, it will break SSO since it will open the SSO page in the
// user's browser, let them log into their SSO provider, then redirect their browser
// to vector://vector which, of course, will not work.
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={onEditServerDetailsClick} />
<SSOButton
className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
</div>
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
/>
);
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const InlineSpinner = sdk.getComponent("elements.InlineSpinner");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Loader /></div> : null;
<div className="mx_Login_loader"><Spinner /></div> : null;
const errorText = this.state.errorText;
@ -683,9 +578,11 @@ export default class LoginComponent extends React.Component {
</div>;
} else if (SettingsStore.getValue(UIFeature.Registration)) {
footer = (
<a className="mx_AuthBody_changeFlow" onClick={this.onTryRegisterClick} href="#">
{ _t('Create account') }
</a>
<span className="mx_AuthBody_changeFlow">
{_t("New? <a>Create account</a>", {}, {
a: sub => <a onClick={this.onTryRegisterClick} href="#">{ sub }</a>,
})}
</span>
);
}
@ -699,8 +596,11 @@ export default class LoginComponent extends React.Component {
</h2>
{ errorTextSection }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
/>
{ this.renderLoginComponentForFlows() }
{ footer }
</AuthBody>
</AuthPage>

View file

@ -1,77 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import 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 class PostRegistration extends React.Component {
static propTypes = {
onComplete: PropTypes.func.isRequired,
};
state = {
avatarUrl: null,
errorString: null,
busy: false,
};
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).
const cli = MatrixClientPeg.get();
this.setState({busy: true});
const self = this;
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
self.setState({
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
busy: false,
});
}, function(error) {
self.setState({
errorString: _t("Failed to fetch avatar URL"),
busy: false,
});
});
}
render() {
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<div className="mx_Login_profile">
{ _t('Set a display name:') }
<ChangeDisplayName />
{ _t('Upload an avatar:') }
<ChangeAvatar
initialAvatarUrl={this.state.avatarUrl} />
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
{ this.state.errorString }
</div>
</AuthBody>
</AuthPage>
);
}
}

View file

@ -1,8 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2017, 2018, 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.
@ -18,109 +15,128 @@ limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import React from 'react';
import PropTypes from 'prop-types';
import React, {ReactNode} from 'react';
import {MatrixClient} from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login from "../../../Login";
import Login, {ISSOFlow} from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from '../../views/elements/ServerPicker';
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate registration flow(s) for the server
const PHASE_REGISTRATION = 1;
interface IProps {
serverConfig: ValidatedServerConfig;
defaultDeviceDisplayName: string;
email?: string;
brand?: string;
clientSecret?: string;
sessionId?: string;
idSid?: string;
fragmentAfterLogin?: string;
// Enable phases for registration
const PHASES_ENABLED = true;
// 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
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(params: {
userId: string;
deviceId: string
homeserverUrl: string;
identityServerUrl?: string;
accessToken: string;
}, password: string): void;
makeRegistrationUrl(params: {
/* eslint-disable camelcase */
client_secret: string;
hs_url: string;
is_url?: string;
session_id: string;
/* eslint-enable camelcase */
}): void;
// registration shouldn't know or care how login is done.
onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
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
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
interface IState {
busy: boolean;
errorText?: ReactNode;
// true if we're waiting for the user to complete
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: Record<string, string>;
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: boolean;
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: boolean;
flows: {
stages: string[];
}[];
// 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: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
clientSecret: PropTypes.string,
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
brand: PropTypes.string,
email: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired,
onServerConfigChange: PropTypes.func.isRequired,
defaultDeviceDisplayName: PropTypes.string,
};
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient?: MatrixClient;
// The user ID we've just registered
registeredUsername?: string;
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId?: string;
// the SSO flow definition, this is fetched from /login as that's the only
// place it is exposed.
ssoFlow?: ISSOFlow;
}
export default class Registration extends React.Component<IProps, IState> {
loginLogic: Login;
constructor(props) {
super(props);
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
this.state = {
busy: false,
errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: {
email: this.props.email,
},
// true if we're waiting for the user to complete
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
serverType,
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
flows: null,
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: false,
// 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: "",
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient: null,
// whether the HS requires an ID server to register with a threepid
serverRequiresIdServer: null,
// The user ID we've just registered
registeredUsername: null,
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId: null,
};
const {hsUrl, isUrl} = this.props.serverConfig;
this.loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
});
}
componentDidMount() {
this._unmounted = false;
this._replaceClient();
this.replaceClient(this.props.serverConfig);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -129,63 +145,10 @@ export default class Registration extends React.Component {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
this._replaceClient(newProps.serverConfig);
// Handle cases where the user enters "https://matrix.org" for their server
// from the advanced option - we should default to FREE at that point.
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
if (serverType !== this.state.serverType) {
// Reset the phase to default phase for the server type.
this.setState({
serverType,
phase: this.getDefaultPhaseForServerType(serverType),
});
}
this.replaceClient(newProps.serverConfig);
}
getDefaultPhaseForServerType(type) {
switch (type) {
case ServerType.FREE: {
// Move directly to the registration phase since the server
// details are fixed.
return PHASE_REGISTRATION;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
return PHASE_SERVER_DETAILS;
}
}
onServerTypeChange = type => {
this.setState({
serverType: type,
});
// When changing server types, set the HS / IS URLs to reasonable defaults for the
// the new type.
switch (type) {
case ServerType.FREE: {
const { serverConfig } = ServerType.TYPES.FREE;
this.props.onServerConfigChange(serverConfig);
break;
}
case ServerType.PREMIUM:
// We can accept whatever server config was the default here as this essentially
// acts as a slightly different "custom server"/ADVANCED option.
break;
case ServerType.ADVANCED:
// Use the default config from the config
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
break;
}
// Reset the phase to default phase for the server type.
this.setState({
phase: this.getDefaultPhaseForServerType(type),
});
};
async _replaceClient(serverConfig) {
private async replaceClient(serverConfig: ValidatedServerConfig) {
this.setState({
errorText: null,
serverDeadError: null,
@ -194,7 +157,6 @@ export default class Registration extends React.Component {
// the UI auth component while we don't have a matrix client)
busy: true,
});
if (!serverConfig) serverConfig = this.props.serverConfig;
// Do a liveliness check on the URLs
try {
@ -222,16 +184,20 @@ export default class Registration extends React.Component {
idBaseUrl: isUrl,
});
let serverRequiresIdServer = true;
this.loginLogic.setHomeserverUrl(hsUrl);
this.loginLogic.setIdentityServerUrl(isUrl);
let ssoFlow: ISSOFlow;
try {
serverRequiresIdServer = await cli.doesServerRequireIdServerParam();
const loginFlows = await this.loginLogic.getFlows();
ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow;
} catch (e) {
console.log("Unable to determine is server needs id_server param", e);
console.error("Failed to get login flows to check for SSO support", e);
}
this.setState({
matrixClient: cli,
serverRequiresIdServer,
ssoFlow,
busy: false,
});
const showGenericError = (e) => {
@ -246,7 +212,7 @@ export default class Registration extends React.Component {
// do SSO instead. If we've already started the UI Auth process though, we don't
// need to.
if (!this.state.doingUIAuth) {
await this._makeRegisterRequest(null);
await this.makeRegisterRequest(null);
// This should never succeed since we specified no auth object.
console.log("Expecting 401 from register request but got success!");
}
@ -259,26 +225,16 @@ export default class Registration extends React.Component {
// At this point registration is pretty much disabled, but before we do that let's
// quickly check to see if the server supports SSO instead. If it does, we'll send
// the user off to the login page to figure their account out.
try {
const loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
if (ssoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({action: 'start_login'});
} else {
this.setState({
serverErrorIsFatal: true, // fatal because user cannot continue on this server
errorText: _t("Registration has been disabled on this homeserver."),
// add empty flows array to get rid of spinner
flows: [],
});
const flows = await loginLogic.getFlows();
const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas');
if (hasSsoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({action: 'start_login'});
} else {
this.setState({
serverErrorIsFatal: true, // fatal because user cannot continue on this server
errorText: _t("Registration has been disabled on this homeserver."),
// add empty flows array to get rid of spinner
flows: [],
});
}
} catch (e) {
console.error("Failed to get login flows to check for SSO support", e);
showGenericError(e);
}
} else {
console.log("Unable to query for supported registration methods.", e);
@ -287,7 +243,7 @@ export default class Registration extends React.Component {
}
}
onFormSubmit = formVals => {
private onFormSubmit = formVals => {
this.setState({
errorText: "",
busy: true,
@ -296,7 +252,7 @@ export default class Registration extends React.Component {
});
};
_requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
return this.state.matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
@ -310,28 +266,26 @@ export default class Registration extends React.Component {
);
}
_onUIAuthFinished = async (success, response, extra) => {
private onUIAuthFinished = async (success, response, extra) => {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
response.data.admin_contact,
{
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
'': _td("This homeserver has exceeded one of its resource limits."),
},
);
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
response.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
@ -339,11 +293,13 @@ export default class Registration extends React.Component {
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn');
}
if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.');
}
} else if (response.errcode === "M_USER_IN_USE") {
msg = _t("That username already exists, please try another.");
}
this.setState({
busy: false,
@ -358,6 +314,10 @@ export default class Registration extends React.Component {
const newState = {
doingUIAuth: false,
registeredUsername: response.user_id,
differentLoggedInUserId: null,
completedNoSignin: false,
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
};
// The user came in through an email validation link. To avoid overwriting
@ -365,15 +325,12 @@ export default class Registration extends React.Component {
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const sessionOwner = Lifecycle.getStoredSessionOwner();
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
console.log(
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
);
newState.differentLoggedInUserId = sessionOwner;
} else {
newState.differentLoggedInUserId = null;
}
if (response.access_token) {
@ -385,9 +342,7 @@ export default class Registration extends React.Component {
accessToken: response.access_token,
}, this.state.formVals.password);
this._setupPushers();
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
this.setupPushers();
} else {
newState.busy = false;
newState.completedNoSignin = true;
@ -396,7 +351,7 @@ export default class Registration extends React.Component {
this.setState(newState);
};
_setupPushers() {
private setupPushers() {
if (!this.props.brand) {
return Promise.resolve();
}
@ -419,38 +374,23 @@ export default class Registration extends React.Component {
});
}
onLoginClick = ev => {
private onLoginClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
onGoToFormClicked = ev => {
private onGoToFormClicked = ev => {
ev.preventDefault();
ev.stopPropagation();
this._replaceClient();
this.replaceClient(this.props.serverConfig);
this.setState({
busy: false,
doingUIAuth: false,
phase: PHASE_REGISTRATION,
});
};
onServerDetailsNextPhaseClick = async () => {
this.setState({
phase: PHASE_REGISTRATION,
});
};
onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
};
_makeRegisterRequest = auth => {
private 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
@ -466,13 +406,15 @@ export default class Registration extends React.Component {
username: this.state.formVals.username,
password: this.state.formVals.password,
initial_device_display_name: this.props.defaultDeviceDisplayName,
auth: undefined,
inhibit_login: undefined,
};
if (auth) registerParams.auth = auth;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
return this.state.matrixClient.registerRequest(registerParams);
};
_getUIAuthInputs() {
private getUIAuthInputs() {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
@ -483,7 +425,7 @@ export default class Registration extends React.Component {
// 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 ev => {
private onLoginClickWithCheck = async ev => {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
@ -493,72 +435,7 @@ export default class Registration extends React.Component {
}
};
renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
// If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all.
// (if there's a fatal server error, we need to show the full server
// config as the user may need to change servers to resolve the error).
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
return <div>
<ServerTypeSelector
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
</div>;
}
const serverDetailsProps = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
let serverDetails = null;
switch (this.state.serverType) {
case ServerType.FREE:
break;
case ServerType.PREMIUM:
serverDetails = <ModularServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
break;
case ServerType.ADVANCED:
serverDetails = <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
showIdentityServerIfRequiredByHomeserver={true}
{...serverDetailsProps}
/>;
break;
}
return <div>
<ServerTypeSelector
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
{serverDetails}
</div>;
}
renderRegisterComponent() {
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) {
return null;
}
private renderRegisterComponent() {
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent('elements.Spinner');
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
@ -566,10 +443,10 @@ export default class Registration extends React.Component {
if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this.state.matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
requestEmailToken={this._requestEmailToken}
makeRequest={this.makeRegisterRequest}
onAuthFinished={this.onUIAuthFinished}
inputs={this.getUIAuthInputs()}
requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
@ -582,30 +459,47 @@ export default class Registration extends React.Component {
<Spinner />
</div>;
} else if (this.state.flows.length) {
let onEditServerDetailsClick = null;
// If custom URLs are allowed and we haven't selected the Free server type, wire
// up the server details edit link.
if (
PHASES_ENABLED &&
!SdkConfig.get()['disable_custom_urls'] &&
this.state.serverType !== ServerType.FREE
) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
let ssoSection;
if (this.state.ssoFlow) {
let continueWithSection;
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context
continueWithSection = <h3 className="mx_AuthBody_centered">
{ _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() }
</h3>;
}
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
ssoSection = <React.Fragment>
{ continueWithSection }
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={this.state.ssoFlow}
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
<h3 className="mx_AuthBody_centered">
{ _t("%(ssoButtons)s Or %(usernamePassword)s", { ssoButtons: "", usernamePassword: ""}).trim() }
</h3>
</React.Fragment>;
}
return <RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
serverRequiresIdServer={this.state.serverRequiresIdServer}
/>;
return <React.Fragment>
{ ssoSection }
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
/>
</React.Fragment>;
}
}
@ -634,13 +528,15 @@ export default class Registration extends React.Component {
);
}
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>;
const signIn = <span className="mx_AuthBody_changeFlow">
{_t("Already have an account? <a>Sign in here</a>", {}, {
a: sub => <a onClick={this.onLoginClick} href="#">{ sub }</a>,
})}
</span>;
// Only show the 'go back' button if you're not looking at the form
let goBack;
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) {
if (this.state.doingUIAuth) {
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
{ _t('Go back') }
</a>;
@ -658,7 +554,7 @@ export default class Registration extends React.Component {
loggedInUserId: this.state.differentLoggedInUserId,
},
)}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
{_t("Continue with previous account")}
</AccessibleButton></p>
</div>;
@ -667,7 +563,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
} else {
@ -677,7 +573,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
}
@ -687,10 +583,15 @@ export default class Registration extends React.Component {
</div>;
} else {
body = <div>
<h2>{ _t('Create your account') }</h2>
<h2>{ _t('Create account') }</h2>
{ errorText }
{ serverDeadSection }
{ this.renderServerComponent() }
<ServerPicker
title={_t("Host account on")}
dialogTitle={_t("Decide where your account is hosted")}
serverConfig={this.props.serverConfig}
onServerConfigChange={this.state.doingUIAuth ? undefined : this.props.onServerConfigChange}
/>
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }

View file

@ -120,9 +120,9 @@ export default class SetupEncryptionBody extends React.Component {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Use Recovery Key or Passphrase");
recoveryKeyPrompt = _t("Use Security Key or Phrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("Use Recovery Key");
recoveryKeyPrompt = _t("Use Security Key");
}
let useRecoveryKeyButton;

View file

@ -24,8 +24,8 @@ import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
import SSOButtons from "../../views/elements/SSOButtons";
const LOGIN_VIEW = {
LOADING: 1,
@ -72,9 +72,12 @@ export default class SoftLogout extends React.Component {
this._initLogin();
MatrixClientPeg.get().countSessionsNeedingBackup().then(remaining => {
this.setState({keyBackupNeeded: remaining > 0});
});
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled()) {
cli.countSessionsNeedingBackup().then(remaining => {
this.setState({ keyBackupNeeded: remaining > 0 });
});
}
}
onClearAll = () => {
@ -101,10 +104,11 @@ export default class SoftLogout extends React.Component {
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
// care about login flows here, unless it is the single flow we support.
const client = MatrixClientPeg.get();
const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]);
const flows = (await client.loginFlows()).flows;
const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]);
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
this.setState({loginView: chosenView});
this.setState({ flows, loginView: chosenView });
}
onPasswordChange = (ev) => {
@ -240,13 +244,18 @@ export default class SoftLogout extends React.Component {
introText = _t("Sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType);
return (
<div>
<p>{introText}</p>
<SSOButton
<SSOButtons
matrixClient={MatrixClientPeg.get()}
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
/>
</div>
);

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CountlyAnalytics from "../../../CountlyAnalytics";
const DIV_ID = 'mx_recaptcha';
@ -45,6 +46,8 @@ export default class CaptchaForm extends React.Component {
this._captchaWidgetId = null;
this._recaptchaContainer = createRef();
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
}
componentDidMount() {
@ -99,10 +102,16 @@ export default class CaptchaForm extends React.Component {
console.log("Loaded recaptcha script.");
try {
this._renderRecaptcha(DIV_ID);
// clear error if re-rendered
this.setState({
errorText: null,
});
CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded");
} catch (e) {
this.setState({
errorText: e.toString(),
});
CountlyAnalytics.instance.track("onboarding_grecaptcha_error", { error: e.toString() });
}
}

View file

@ -123,7 +123,7 @@ export default class CountryDropdown extends React.Component {
const options = displayedCountries.map((country) => {
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this._flagImgForIso2(country.iso2) }
{ country.name } (+{ country.prefix })
{ _t(country.name) } (+{ country.prefix })
</div>;
});

View file

@ -1,47 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default class CustomServerDialog extends React.Component {
render() {
const brand = SdkConfig.get().brand;
return (
<div className="mx_ErrorDialog">
<div className="mx_Dialog_title">
{ _t("Custom Server Options") }
</div>
<div className="mx_Dialog_content">
<p>{_t(
"You can use the custom server options to sign into other " +
"Matrix servers by specifying a different homeserver URL. This " +
"allows you to use %(brand)s with an existing Matrix account on a " +
"different homeserver.",
{ brand },
)}</p>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={true}>
{ _t("Dismiss") }
</button>
</div>
</div>
);
}
}

View file

@ -18,7 +18,6 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import url from 'url';
import classnames from 'classnames';
import * as sdk from '../../../index';
@ -26,6 +25,7 @@ import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics";
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -189,6 +189,7 @@ export class RecaptchaAuthEntry extends React.Component {
}
_onCaptchaResponse = response => {
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE,
response: response,
@ -297,6 +298,8 @@ export class TermsAuthEntry extends React.Component {
toggledPolicies: initToggles,
policies: pickedPolicies,
};
CountlyAnalytics.instance.track("onboarding_terms_begin");
}
@ -326,8 +329,12 @@ export class TermsAuthEntry extends React.Component {
allChecked = allChecked && checked;
}
if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
if (allChecked) {
this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
CountlyAnalytics.instance.track("onboarding_terms_complete");
} else {
this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
}
};
render() {
@ -413,12 +420,12 @@ export class EmailIdentityAuthEntry extends React.Component {
return <Spinner />;
} else {
return (
<div>
<p>{ _t("An email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <i>{ this.props.inputs.emailAddress }</i> },
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> },
) }
</p>
<p>{ _t("Please check your email to continue registration.") }</p>
<p>{ _t("Open the link in the email to continue registration.") }</p>
</div>
);
}
@ -492,17 +499,11 @@ export class MsisdnAuthEntry extends React.Component {
});
try {
const requiresIdServerParam =
await this.props.matrixClient.doesServerRequireIdServerParam();
let result;
if (this._submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
);
} else if (requiresIdServerParam) {
result = await this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token,
);
} else {
throw new Error("The registration with MSISDN flow is misconfigured");
}
@ -511,12 +512,6 @@ export class MsisdnAuthEntry extends React.Component {
sid: this._sid,
client_secret: this.props.clientSecret,
};
if (requiresIdServerParam) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
);
creds.id_server = idServerParsedUrl.host;
}
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
// TODO: Remove `threepid_creds` once servers support proper UIA
@ -614,8 +609,12 @@ export class SSOAuthEntry extends React.Component {
this.props.authSessionId,
);
this._popupWindow = null;
window.addEventListener("message", this._onReceiveMessage);
this.state = {
phase: SSOAuthEntry.PHASE_PREAUTH,
attemptFailed: false,
};
}
@ -623,12 +622,35 @@ export class SSOAuthEntry extends React.Component {
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
}
componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage);
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
}
}
attemptFailed = () => {
this.setState({
attemptFailed: true,
});
};
_onReceiveMessage = event => {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
}
}
};
onStartAuthClick = () => {
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost
// certainly will need to open the thing in a new tab to avoid losing application
// context.
window.open(this._ssoUrl, '_blank');
this._popupWindow = window.open(this._ssoUrl, "_blank");
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
};
@ -661,10 +683,28 @@ export class SSOAuthEntry extends React.Component {
);
}
return <div className='mx_InteractiveAuthEntryComponents_sso_buttons'>
{cancelButton}
{continueButton}
</div>;
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
} else if (this.state.attemptFailed) {
errorSection = (
<div className="error" role="alert">
{ _t("Something went wrong in confirming your identity. Cancel and try again.") }
</div>
);
}
return <React.Fragment>
{ errorSection }
<div className="mx_InteractiveAuthEntryComponents_sso_buttons">
{cancelButton}
{continueButton}
</div>
</React.Fragment>;
}
}
@ -715,8 +755,7 @@ export class FallbackAuthEntry extends React.Component {
this.props.loginType,
this.props.authSessionId,
);
this._popupWindow = window.open(url);
this._popupWindow.opener = null;
this._popupWindow = window.open(url, "_blank");
};
_onReceiveMessage = event => {

View file

@ -1,124 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as ServerType from '../../views/auth/ServerTypeSelector';
import ServerConfig from "./ServerConfig";
const MODULAR_URL = 'https://element.io/matrix-services' +
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
// TODO: TravisR - Can this extend ServerConfig for most things?
/*
* Configure the Modular server name.
*
* This is a variant of ServerConfig with only the HS field and different body
* text that is specific to the Modular case.
*/
export default class ModularServerConfig extends ServerConfig {
static propTypes = ServerConfig.propTypes;
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl,
isUrl,
busy: true,
errorText: "",
});
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null;
}
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
}
render() {
const Field = sdk.getComponent('elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<div className="mx_ServerConfig">
<h3>{_t("Your server")}</h3>
{_t(
"Enter the location of your Element Matrix Services homeserver. It may use your own " +
"domain name or be a subdomain of <a>element.io</a>.",
{}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
{sub}
</a>,
},
)}
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
<div className="mx_ServerConfig_fields">
<Field
id="mx_ServerConfig_hsUrl"
label={_t("Server Name")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
</div>
{submitButton}
</form>
</div>
);
}
}

View file

@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler";
import Field from "../elements/Field";
import Field, {IInputProps} from "../elements/Field";
interface IProps {
interface IProps extends Omit<IInputProps, "onValidate"> {
autoFocus?: boolean;
id?: string;
className?: string;

View file

@ -1,354 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
/**
* A pure UI component which displays a username/password form.
*/
export default class PasswordLogin extends React.Component {
static propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onEditServerDetailsClick: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
busy: PropTypes.bool,
};
static defaultProps = {
onError: function() {},
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
onPhoneNumberBlur: function() {},
initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
disableSubmit: false,
};
static LOGIN_FIELD_EMAIL = "login_field_email";
static LOGIN_FIELD_MXID = "login_field_mxid";
static LOGIN_FIELD_PHONE = "login_field_phone";
constructor(props) {
super(props);
this.state = {
username: this.props.initialUsername,
password: this.props.initialPassword,
phoneCountry: this.props.initialPhoneCountry,
phoneNumber: this.props.initialPhoneNumber,
loginType: PasswordLogin.LOGIN_FIELD_MXID,
};
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
this.onSubmitForm = this.onSubmitForm.bind(this);
this.onUsernameChanged = this.onUsernameChanged.bind(this);
this.onUsernameBlur = this.onUsernameBlur.bind(this);
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
this.onPasswordChanged = this.onPasswordChanged.bind(this);
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
ev.preventDefault();
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
let error;
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
username = this.state.username;
if (!username) {
error = _t('The email field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_MXID:
username = this.state.username;
if (!username) {
error = _t('The username field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_PHONE:
phoneCountry = this.state.phoneCountry;
phoneNumber = this.state.phoneNumber;
if (!phoneNumber) {
error = _t('The phone number field must not be blank.');
}
break;
}
if (error) {
this.props.onError(error);
return;
}
if (!this.state.password) {
this.props.onError(_t('The password field must not be blank.'));
return;
}
this.props.onSubmit(
username,
phoneCountry,
phoneNumber,
this.state.password,
);
}
onUsernameChanged(ev) {
this.setState({username: ev.target.value});
this.props.onUsernameChanged(ev.target.value);
}
onUsernameBlur(ev) {
this.props.onUsernameBlur(ev.target.value);
}
onLoginTypeChange(ev) {
const loginType = ev.target.value;
this.props.onError(null); // send a null error to clear any error messages
this.setState({
loginType: loginType,
username: "", // Reset because email and username use the same state
});
}
onPhoneCountryChanged(country) {
this.setState({
phoneCountry: country.iso2,
phonePrefix: country.prefix,
});
this.props.onPhoneCountryChanged(country.iso2);
}
onPhoneNumberChanged(ev) {
this.setState({phoneNumber: ev.target.value});
this.props.onPhoneNumberChanged(ev.target.value);
}
onPhoneNumberBlur(ev) {
this.props.onPhoneNumberBlur(ev.target.value);
}
onPasswordChanged(ev) {
this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value);
}
renderLoginField(loginType, autoFocus) {
const Field = sdk.getComponent('elements.Field');
const classes = {};
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
}
}
}
isLoginEmpty() {
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
case PasswordLogin.LOGIN_FIELD_MXID:
return !this.state.username;
case PasswordLogin.LOGIN_FIELD_PHONE:
return !this.state.phoneCountry || !this.state.phoneNumber;
}
}
render() {
const Field = sdk.getComponent('elements.Field');
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option
key={PasswordLogin.LOGIN_FIELD_MXID}
value={PasswordLogin.LOGIN_FIELD_MXID}
>
{_t('Username')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_EMAIL}
value={PasswordLogin.LOGIN_FIELD_EMAIL}
>
{_t('Email address')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_PHONE}
value={PasswordLogin.LOGIN_FIELD_PHONE}
>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -0,0 +1,485 @@
/*
Copyright 2015, 2016, 2017, 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
interface IProps {
username: string; // also used for email address
phoneCountry: string;
phoneNumber: string;
serverConfig: ValidatedServerConfig;
loginIncorrect?: boolean;
disableSubmit?: boolean;
busy?: boolean;
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
onUsernameChanged?(username: string): void;
onUsernameBlur?(username: string): void;
onPhoneCountryChanged?(phoneCountry: string): void;
onPhoneNumberChanged?(phoneNumber: string): void;
onForgotPasswordClick?(): void;
}
interface IState {
fieldValid: Partial<Record<LoginField, boolean>>;
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
password: "",
}
enum LoginField {
Email = "login_field_email",
MatrixId = "login_field_mxid",
Phone = "login_field_phone",
Password = "login_field_phone",
}
/*
* A pure UI component which displays a username/password form.
* The email/username/phone fields are fully-controlled, the password field is not.
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
loginIncorrect: false,
disableSubmit: false,
};
constructor(props) {
super(props);
this.state = {
// Field error codes by field ID
fieldValid: {},
loginType: LoginField.MatrixId,
password: "",
};
}
private onForgotPasswordClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
};
private onSubmitForm = async ev => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
username = this.props.username;
break;
case LoginField.Phone:
phoneCountry = this.props.phoneCountry;
phoneNumber = this.props.phoneNumber;
break;
}
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
};
private onUsernameChanged = ev => {
this.props.onUsernameChanged(ev.target.value);
};
private onUsernameFocus = () => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_focus");
}
};
private onUsernameBlur = ev => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_blur");
}
this.props.onUsernameBlur(ev.target.value);
};
private onLoginTypeChange = ev => {
const loginType = ev.target.value;
this.setState({ loginType });
this.props.onUsernameChanged(""); // Reset because email and username use the same state
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
};
private onPhoneCountryChanged = country => {
this.props.onPhoneCountryChanged(country.iso2);
};
private onPhoneNumberChanged = ev => {
this.props.onPhoneNumberChanged(ev.target.value);
};
private onPhoneNumberFocus = () => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
};
private onPhoneNumberBlur = ev => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
};
private onPasswordChanged = ev => {
this.setState({password: ev.target.value});
};
private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
this.state.loginType,
LoginField.Password,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise<void>(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
}
private findFirstInvalidField(fieldIDs: LoginField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
}
private markFieldValid(fieldID: LoginField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
}
private validateUsernameRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter username"),
},
],
});
private onUsernameValidate = async (fieldState) => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(LoginField.MatrixId, result.valid);
return result;
};
private validateEmailRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter email address"),
}, {
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
private onEmailValidate = async (fieldState) => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(LoginField.Email, result.valid);
return result;
};
private validatePhoneNumberRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter phone number"),
}, {
key: "number",
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
},
],
});
private onPhoneNumberValidate = async (fieldState) => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
};
private validatePasswordRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter password"),
},
],
});
private onPasswordValidate = async (fieldState) => {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
}
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
const classes = {
error: false,
};
switch (loginType) {
case LoginField.Email:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onEmailValidate}
ref={field => this[LoginField.Email] = field}
/>;
case LoginField.MatrixId:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onUsernameValidate}
ref={field => this[LoginField.MatrixId] = field}
/>;
case LoginField.Phone: {
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.props.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.props.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onPhoneNumberValidate}
ref={field => this[LoginField.Password] = field}
/>;
}
}
}
private isLoginEmpty() {
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
return !this.props.username;
case LoginField.Phone:
return !this.props.phoneCountry || !this.props.phoneNumber;
}
}
render() {
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{_t("Forgot password?")}
</AccessibleButton>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{_t('Username')}
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{_t('Email address')}
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
onValidate={this.onPasswordValidate}
ref={field => this[LoginField.Password] = field}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -1,8 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016, 2017, 2018, 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.
@ -18,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
@ -29,34 +27,60 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics";
import Field from '../elements/Field';
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
enum RegistrationField {
Email = "field_email",
PhoneNumber = "field_phone_number",
Username = "field_username",
Password = "field_password",
PasswordConfirm = "field_password_confirm",
}
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
interface IProps {
// Values pre-filled in the input boxes when the component loads
defaultEmail?: string;
defaultPhoneCountry?: string;
defaultPhoneNumber?: string;
defaultUsername?: string;
defaultPassword?: string;
flows: {
stages: string[];
}[];
serverConfig: ValidatedServerConfig;
canSubmit?: boolean;
onRegisterClick(params: {
username: string;
password: string;
email?: string;
phoneCountry?: string;
phoneNumber?: string;
}): Promise<void>;
onEditServerDetailsClick?(): void;
}
interface IState {
// Field error codes by field ID
fieldValid: Partial<Record<RegistrationField, boolean>>;
// The ISO2 country code selected in the phone number entry
phoneCountry: string;
username: string;
email: string;
phoneNumber: string;
password: string;
passwordConfirm: string;
passwordComplexity?: number;
}
/*
* A pure UI component which displays a registration form.
*/
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,
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
canSubmit: PropTypes.bool,
serverRequiresIdServer: PropTypes.bool,
};
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
static defaultProps = {
onValidationChange: console.error,
canSubmit: true,
@ -66,9 +90,7 @@ export default class RegistrationForm extends React.Component {
super(props);
this.state = {
// Field error codes by field ID
fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "",
@ -77,57 +99,53 @@ export default class RegistrationForm extends React.Component {
passwordConfirm: this.props.defaultPassword || "",
passwordComplexity: null,
};
CountlyAnalytics.instance.track("onboarding_registration_begin");
}
onSubmit = async ev => {
private onSubmit = async ev => {
ev.preventDefault();
ev.persist();
if (!this.props.canSubmit) return;
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
const self = this;
if (this.state.email === '') {
const haveIs = Boolean(this.props.serverConfig.isUrl);
let desc;
if (this.props.serverRequiresIdServer && !haveIs) {
desc = _t(
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
);
} else if (this._showEmail()) {
desc = _t(
"If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?",
);
if (this.showEmail()) {
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, {
onFinished: async (confirmed: boolean, email?: string) => {
if (confirmed) {
this.setState({
email,
}, () => {
this.doSubmit(ev);
});
}
},
});
} else {
// user can't set an e-mail so don't prompt them to
self._doSubmit(ev);
this.doSubmit(ev);
return;
}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"),
description: desc,
button: _t("Continue"),
onFinished(confirmed) {
if (confirmed) {
self._doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
this.doSubmit(ev);
}
};
_doSubmit(ev) {
private doSubmit(ev) {
const email = this.state.email.trim();
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
email: !!email,
});
const promise = this.props.onRegisterClick({
username: this.state.username.trim(),
password: this.state.password.trim(),
@ -144,20 +162,20 @@ export default class RegistrationForm extends React.Component {
}
}
async verifyFieldsBeforeSubmit() {
private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement;
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
FIELD_USERNAME,
FIELD_PASSWORD,
FIELD_PASSWORD_CONFIRM,
FIELD_EMAIL,
FIELD_PHONE_NUMBER,
RegistrationField.Username,
RegistrationField.Password,
RegistrationField.PasswordConfirm,
RegistrationField.Email,
RegistrationField.PhoneNumber,
];
// Run all fields with stricter validation that no longer allows empty
@ -176,7 +194,7 @@ export default class RegistrationForm extends React.Component {
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
await new Promise<void>(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
@ -198,7 +216,7 @@ export default class RegistrationForm extends React.Component {
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid() {
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
@ -208,7 +226,7 @@ export default class RegistrationForm extends React.Component {
return true;
}
findFirstInvalidField(fieldIDs) {
private findFirstInvalidField(fieldIDs: RegistrationField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
@ -217,7 +235,7 @@ export default class RegistrationForm extends React.Component {
return null;
}
markFieldValid(fieldID, valid) {
private markFieldValid(fieldID: RegistrationField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
@ -225,25 +243,26 @@ export default class RegistrationForm extends React.Component {
});
}
onEmailChange = ev => {
private onEmailChange = ev => {
this.setState({
email: ev.target.value,
});
};
onEmailValidate = async fieldState => {
private onEmailValidate = async fieldState => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid);
this.markFieldValid(RegistrationField.Email, result.valid);
return result;
};
validateEmailRules = withValidation({
private validateEmailRules = withValidation({
description: () => _t("Use an email address to recover your account"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
},
invalid: () => _t("Enter email address (required on this homeserver)"),
},
@ -255,29 +274,29 @@ export default class RegistrationForm extends React.Component {
],
});
onPasswordChange = ev => {
private onPasswordChange = ev => {
this.setState({
password: ev.target.value,
});
};
onPasswordValidate = result => {
this.markFieldValid(FIELD_PASSWORD, result.valid);
private onPasswordValidate = result => {
this.markFieldValid(RegistrationField.Password, result.valid);
};
onPasswordConfirmChange = ev => {
private onPasswordConfirmChange = ev => {
this.setState({
passwordConfirm: ev.target.value,
});
};
onPasswordConfirmValidate = async fieldState => {
private onPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result;
};
validatePasswordConfirmRules = withValidation({
private validatePasswordConfirmRules = withValidation({
rules: [
{
key: "required",
@ -286,65 +305,66 @@ export default class RegistrationForm extends React.Component {
},
{
key: "match",
test({ value }) {
test(this: RegistrationForm, { value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
],
});
onPhoneCountryChange = newVal => {
private onPhoneCountryChange = newVal => {
this.setState({
phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
});
};
onPhoneNumberChange = ev => {
private onPhoneNumberChange = ev => {
this.setState({
phoneNumber: ev.target.value,
});
};
onPhoneNumberValidate = async fieldState => {
private onPhoneNumberValidate = async fieldState => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
return result;
};
validatePhoneNumberRules = withValidation({
private validatePhoneNumberRules = withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
},
invalid: () => _t("Enter phone number (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || phoneNumberLooksValid(value),
invalid: () => _t("Doesn't look like a valid phone number"),
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
},
],
});
onUsernameChange = ev => {
private onUsernameChange = ev => {
this.setState({
username: ev.target.value,
});
};
onUsernameValidate = async fieldState => {
private onUsernameValidate = async fieldState => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid);
this.markFieldValid(RegistrationField.Username, result.valid);
return result;
};
validateUsernameRules = withValidation({
private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
@ -365,7 +385,7 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check
* @returns {boolean} Whether it is required
*/
_authStepIsRequired(step) {
private authStepIsRequired(step: string) {
return this.props.flows.every((flow) => {
return flow.stages.includes(step);
});
@ -377,86 +397,80 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check
* @returns {boolean} Whether it is used
*/
_authStepIsUsed(step) {
private authStepIsUsed(step: string) {
return this.props.flows.some((flow) => {
return flow.stages.includes(step);
});
}
_showEmail() {
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.email.identity')
) {
private showEmail() {
if (!this.authStepIsUsed('m.login.email.identity')) {
return false;
}
return true;
}
_showPhoneNumber() {
private showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
!threePidLogin ||
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.msisdn')
) {
if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) {
return false;
}
return true;
}
renderEmail() {
if (!this._showEmail()) {
private renderEmail() {
if (!this.showEmail()) {
return null;
}
const Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
ref={field => this[FIELD_EMAIL] = field}
ref={field => this[RegistrationField.Email] = field}
type="text"
label={emailPlaceholder}
value={this.state.email}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")}
/>;
}
renderPassword() {
private renderPassword() {
return <PassphraseField
id="mx_RegistrationForm_password"
fieldRef={field => this[FIELD_PASSWORD] = field}
fieldRef={field => this[RegistrationField.Password] = field}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")}
/>;
}
renderPasswordConfirm() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_passwordConfirm"
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password"
autoComplete="new-password"
label={_t("Confirm")}
label={_t("Confirm password")}
value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")}
/>;
}
renderPhoneNumber() {
if (!this._showPhoneNumber()) {
if (!this.showPhoneNumber()) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field');
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
@ -466,7 +480,7 @@ export default class RegistrationForm extends React.Component {
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
ref={field => this[FIELD_PHONE_NUMBER] = field}
ref={field => this[RegistrationField.PhoneNumber] = field}
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
@ -477,86 +491,49 @@ export default class RegistrationForm extends React.Component {
}
renderUsername() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field}
ref={field => this[RegistrationField.Username] = field}
type="text"
autoFocus={true}
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")}
/>;
}
render() {
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
);
let emailHelperText = null;
if (this._showEmail()) {
if (this._showPhoneNumber()) {
if (this.showEmail()) {
if (this.showPhoneNumber()) {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email or phone to optionally be discoverable by existing contacts.",
)}
{
_t("Add an email to be able to reset your password.")
} {
_t("Use email or phone to optionally be discoverable by existing contacts.")
}
</div>;
} else {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email to optionally be discoverable by existing contacts.",
)}
{
_t("Add an email to be able to reset your password.")
} {
_t("Use email to optionally be discoverable by existing contacts.")
}
</div>;
}
}
const haveIs = Boolean(this.props.serverConfig.isUrl);
let noIsText = null;
if (this.props.serverRequiresIdServer && !haveIs) {
noIsText = <div>
{_t(
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
)}
</div>;
}
return (
<div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
{this.renderUsername()}
@ -570,7 +547,6 @@ export default class RegistrationForm extends React.Component {
{this.renderPhoneNumber()}
</div>
{ emailHelperText }
{ noIsText }
{ registerButton }
</form>
</div>

View file

@ -1,288 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import { createClient } from 'matrix-js-sdk/src/matrix';
import classNames from 'classnames';
/*
* A pure UI component which displays the HS and IS to use.
*/
export default class ServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func.isRequired,
// The current configuration that the user is expecting to change.
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
// Called after the component calls onServerConfigChange
onAfterSubmit: PropTypes.func,
// Optional text for the submit button. If falsey, no button will be shown.
submitText: PropTypes.string,
// Optional class for the submit button. Only applies if the submit button
// is to be rendered.
submitClass: PropTypes.string,
// Whether the flow this component is embedded in requires an identity
// server when the homeserver says it will need one. Default false.
showIdentityServerIfRequiredByHomeserver: PropTypes.bool,
};
static defaultProps = {
onServerConfigChange: function() {},
delayTimeMs: 0,
};
constructor(props) {
super(props);
this.state = {
busy: false,
errorText: "",
hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl,
showIdentityServer: false,
};
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
newProps.serverConfig.isUrl === this.state.isUrl) return;
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
if (!result) {
return result;
}
// If the UI flow this component is embedded in requires an identity
// server when the homeserver says it will need one, check first and
// reveal this field if not already shown.
// XXX: This a backward compatibility path for homeservers that require
// an identity server to be passed during certain flows.
// See also https://github.com/matrix-org/synapse/pull/5868.
if (
this.props.showIdentityServerIfRequiredByHomeserver &&
!this.state.showIdentityServer &&
await this.isIdentityServerRequiredByHomeserver()
) {
this.setState({
showIdentityServer: true,
});
return null;
}
return result;
}
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({
hsUrl: defaultConfig.hsUrl,
isUrl: defaultConfig.isUrl,
busy: false,
errorText: "",
});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl,
isUrl,
busy: true,
errorText: "",
});
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
this.setState({
busy: false,
});
// carry on anyway
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
this.props.onServerConfigChange(result);
return result;
} else {
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null;
}
}
}
async isIdentityServerRequiredByHomeserver() {
// XXX: We shouldn't have to create a whole new MatrixClient just to
// check if the homeserver requires an identity server... Should it be
// extracted to a static utils function...?
return createClient({
baseUrl: this.state.hsUrl,
}).doesServerRequireIdServerParam();
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.validateServer();
});
};
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
};
onIdentityServerBlur = (ev) => {
this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => {
this.validateServer();
});
};
onIdentityServerChange = (ev) => {
const isUrl = ev.target.value;
this.setState({ isUrl });
};
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
const result = await this.validateServer();
if (!result) return; // Do not continue.
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();
}
};
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
clearTimeout(existingTimeoutId);
}
return setTimeout(fn.bind(this), this.props.delayTimeMs);
}
showHelpPopup = () => {
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
};
_renderHomeserverSection() {
const Field = sdk.getComponent('elements.Field');
return <div>
{_t("Enter your custom homeserver URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field
id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
disabled={this.state.busy}
/>
</div>;
}
_renderIdentityServerSection() {
const Field = sdk.getComponent('elements.Field');
const classes = classNames({
"mx_ServerConfig_identityServer": true,
"mx_ServerConfig_identityServer_shown": this.state.showIdentityServer,
});
return <div className={classes}>
{_t("Enter your custom identity server URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field
label={_t("Identity Server URL")}
placeholder={this.props.serverConfig.isUrl}
value={this.state.isUrl || ''}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
disabled={this.state.busy}
/>
</div>;
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const errorText = this.state.errorText
? <span className='mx_ServerConfig_error'>{this.state.errorText}</span>
: null;
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<form className="mx_ServerConfig" onSubmit={this.onSubmit} autoComplete="off">
<h3>{_t("Other servers")}</h3>
{errorText}
{this._renderHomeserverSection()}
{this._renderIdentityServerSection()}
{submitButton}
</form>
);
}
}

View file

@ -1,153 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import classnames from 'classnames';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import {makeType} from "../../../utils/TypeUtils";
const MODULAR_URL = 'https://element.io/matrix-services' +
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
export const FREE = 'Free';
export const PREMIUM = 'Premium';
export const ADVANCED = 'Advanced';
export const TYPES = {
FREE: {
id: FREE,
label: () => _t('Free'),
logo: () => <img src={require('../../../../res/img/matrix-org-bw-logo.svg')} />,
description: () => _t('Join millions for free on the largest public server'),
serverConfig: makeType(ValidatedServerConfig, {
hsUrl: "https://matrix-client.matrix.org",
hsName: "matrix.org",
hsNameIsDifferent: false,
isUrl: "https://vector.im",
}),
},
PREMIUM: {
id: PREMIUM,
label: () => _t('Premium'),
logo: () => <img src={require('../../../../res/img/ems-logo.svg')} height={16} />,
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
{sub}
</a>,
}),
identityServerUrl: "https://vector.im",
},
ADVANCED: {
id: ADVANCED,
label: () => _t('Advanced'),
logo: () => <div>
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
{_t('Other')}
</div>,
description: () => _t('Find other public servers or use a custom server'),
},
};
export function getTypeFromServerConfig(config) {
const {hsUrl} = config;
if (!hsUrl) {
return null;
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
return FREE;
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
// This is an unlikely case to reach, as Modular defaults to hiding the
// server type selector.
return PREMIUM;
} else {
return ADVANCED;
}
}
export default class ServerTypeSelector extends React.PureComponent {
static propTypes = {
// The default selected type.
selected: PropTypes.string,
// Handler called when the selected type changes.
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
const {
selected,
} = props;
this.state = {
selected,
};
}
updateSelectedType(type) {
if (this.state.selected === type) {
return;
}
this.setState({
selected: type,
});
if (this.props.onChange) {
this.props.onChange(type);
}
}
onClick = (e) => {
e.stopPropagation();
const type = e.currentTarget.dataset.id;
this.updateSelectedType(type);
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const serverTypes = [];
for (const type of Object.values(TYPES)) {
const { id, label, logo, description } = type;
const classes = classnames(
"mx_ServerTypeSelector_type",
`mx_ServerTypeSelector_type_${id}`,
{
"mx_ServerTypeSelector_type_selected": id === this.state.selected,
},
);
serverTypes.push(<div className={classes} key={id} >
<div className="mx_ServerTypeSelector_label">
{label()}
</div>
<AccessibleButton onClick={this.onClick} data-id={id}>
<div className="mx_ServerTypeSelector_logo">
{logo()}
</div>
<div className="mx_ServerTypeSelector_description">
{description()}
</div>
</AccessibleButton>
</div>);
}
return <div className="mx_ServerTypeSelector">
{serverTypes}
</div>;
}
}

View file

@ -1,62 +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 React from 'react';
import {_t} from "../../../languageHandler";
import * as sdk from "../../../index";
import PropTypes from "prop-types";
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
export default class SignInToText extends React.PureComponent {
static propTypes = {
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
onEditServerDetailsClick: PropTypes.func,
};
render() {
let signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
signInToText = _t('Sign in to your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
return <h3>
{signInToText}
{editLink}
</h3>;
}
}

View file

@ -23,11 +23,18 @@ import AuthPage from "./AuthPage";
import {_td} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
// translatable strings for Welcome pages
_td("Sign in with SSO");
export default class Welcome extends React.PureComponent {
constructor(props) {
super(props);
CountlyAnalytics.instance.track("onboarding_welcome");
}
render() {
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');

View file

@ -51,7 +51,8 @@ const calculateUrls = (url, urls) => {
_urls = urls || [];
if (url) {
_urls.unshift(url); // put in urls[0]
// copy urls and put url first
_urls = [url, ..._urls];
}
}

View file

@ -23,7 +23,7 @@ import {Action} from "../../../dispatcher/actions";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import BaseAvatar from "./BaseAvatar";
interface IProps {
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember;
fallbackUserId?: string;
width: number;

View file

@ -13,7 +13,7 @@ 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 React, {ComponentProps} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
@ -22,8 +22,9 @@ import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
interface IProps {
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick">{
// Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
@ -32,8 +33,9 @@ interface IProps {
oobData?: any;
width?: number;
height?: number;
resizeMethod?: string;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
onClick?(): void;
}
interface IState {
@ -129,7 +131,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
};
public render() {
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props;
const roomName = room ? room.name : oobData.name;
@ -138,7 +140,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
name={roomName}
idName={room ? room.roomId : null}
urls={this.state.urls}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
/>
);
}

View file

@ -0,0 +1,58 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ComponentProps, useContext} from 'react';
import classNames from 'classnames';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {IApp} from "../../../stores/WidgetStore";
import BaseAvatar, {BaseAvatarType} from "./BaseAvatar";
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
app: IApp;
}
const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => {
const cli = useContext(MatrixClientContext);
let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")];
// heuristics for some better icons until Widgets support their own icons
if (app.type.includes("jitsi")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_video.svg")];
} else if (app.type.includes("meeting") || app.type.includes("calendar")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")];
} else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")];
} else if (app.type.includes("clock")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")];
}
return (
<BaseAvatar
{...props}
name={app.id}
className={classNames("mx_WidgetAvatar", className)}
// MSC2765
url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined}
urls={iconUrls}
width={width}
height={height}
/>
)
};
export default WidgetAvatar;

View file

@ -0,0 +1,77 @@
/*
Copyright 2020 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import CallHandler from '../../../CallHandler';
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
import Modal from '../../../Modal';
interface IProps extends IContextMenuProps {
call: MatrixCall;
}
export default class CallContextMenu extends React.Component<IProps> {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props) {
super(props);
}
onHoldClick = () => {
this.props.call.setRemoteOnHold(true);
this.props.onFinished();
}
onUnholdClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
}
onTransferClick = () => {
Modal.createTrackedDialog(
'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
this.props.onFinished();
}
render() {
const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold");
const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick;
let transferItem;
if (this.props.call.opponentCanBeTransferred()) {
transferItem = <MenuItem className="mx_CallContextMenu_item" onClick={this.onTransferClick}>
{_t("Transfer")}
</MenuItem>;
}
return <ContextMenu {...this.props}>
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
{holdUnholdCaption}
</MenuItem>
{transferItem}
</ContextMenu>;
}
}

View file

@ -0,0 +1,59 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Dialpad from '../voip/DialPad';
interface IProps extends IContextMenuProps {
call: MatrixCall;
}
interface IState {
value: string;
}
export default class DialpadContextMenu extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
value: '',
}
}
onDigitPress = (digit) => {
this.props.call.sendDtmfDigit(digit);
this.setState({value: this.state.value + digit});
}
render() {
return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header">
<div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div>
</div>
<div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
</div>
</ContextMenu>;
}
}

View file

@ -31,6 +31,7 @@ import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event";
function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -72,7 +73,10 @@ export default class MessageContextMenu extends React.Component {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
// We explicitly decline to show the redact option on ACL events as it has a potential
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
@ -145,7 +149,7 @@ export default class MessageContextMenu extends React.Component {
onRedactClick = () => {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: async (proceed) => {
onFinished: async (proceed, reason) => {
if (!proceed) return;
const cli = MatrixClientPeg.get();
@ -153,6 +157,8 @@ export default class MessageContextMenu extends React.Component {
await cli.redactEvent(
this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(),
undefined,
reason ? { reason } : {},
);
} catch (e) {
const code = e.errcode || e.statusCode;

View file

@ -1,142 +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 React from 'react';
import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler';
import {MenuItem} from "../../structures/ContextMenu";
export default class WidgetContextMenu extends React.Component {
static propTypes = {
onFinished: PropTypes.func,
// Callback for when the revoke button is clicked. Required.
onRevokeClicked: PropTypes.func.isRequired,
// Callback for when the unpin button is clicked. If absent, unpin will be hidden.
onUnpinClicked: PropTypes.func,
// Callback for when the snapshot button is clicked. Button not shown
// without a callback.
onSnapshotClicked: PropTypes.func,
// Callback for when the reload button is clicked. Button not shown
// without a callback.
onReloadClicked: PropTypes.func,
// Callback for when the edit button is clicked. Button not shown
// without a callback.
onEditClicked: PropTypes.func,
// Callback for when the delete button is clicked. Button not shown
// without a callback.
onDeleteClicked: PropTypes.func,
};
proxyClick(fn) {
fn();
if (this.props.onFinished) this.props.onFinished();
}
// XXX: It's annoying that our context menus require us to hit onFinished() to close :(
onEditClicked = () => {
this.proxyClick(this.props.onEditClicked);
};
onReloadClicked = () => {
this.proxyClick(this.props.onReloadClicked);
};
onSnapshotClicked = () => {
this.proxyClick(this.props.onSnapshotClicked);
};
onDeleteClicked = () => {
this.proxyClick(this.props.onDeleteClicked);
};
onRevokeClicked = () => {
this.proxyClick(this.props.onRevokeClicked);
};
onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked);
render() {
const options = [];
if (this.props.onEditClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'>
{_t("Edit")}
</MenuItem>,
);
}
if (this.props.onUnpinClicked) {
options.push(
<MenuItem className="mx_WidgetContextMenu_option" onClick={this.onUnpinClicked} key="unpin">
{_t("Unpin")}
</MenuItem>,
);
}
if (this.props.onReloadClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked} key='reload'>
{_t("Reload")}
</MenuItem>,
);
}
if (this.props.onSnapshotClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked} key='snap'>
{_t("Take picture")}
</MenuItem>,
);
}
if (this.props.onDeleteClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked} key='delete'>
{_t("Remove for everyone")}
</MenuItem>,
);
}
// Push this last so it appears last. It's always present.
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'>
{_t("Remove for me")}
</MenuItem>,
);
// Put separators between the options
if (options.length > 1) {
const length = options.length;
for (let i = 0; i < length - 1; i++) {
const sep = <hr key={i} className="mx_WidgetContextMenu_separator" />;
// Insert backwards so the insertions don't affect our math on where to place them.
// We also use our cached length to avoid worrying about options.length changing
options.splice(length - 1 - i, 0, sep);
}
}
return <div className="mx_WidgetContextMenu">{options}</div>;
}
}

View file

@ -0,0 +1,178 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext} from "react";
import {MatrixCapabilities} from "matrix-widget-api";
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
import {ChevronFace} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import {IApp} from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
import RoomContext from "../../../contexts/RoomContext";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog";
import {WidgetType} from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
app: IApp;
userWidget?: boolean;
showUnpin?: boolean;
// override delete handler
onDeleteClick?(): void;
}
const WidgetContextMenu: React.FC<IProps> = ({
onFinished,
app,
userWidget,
onDeleteClick,
showUnpin,
...props
}) => {
const cli = useContext(MatrixClientContext);
const {room, roomId} = useContext(RoomContext);
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId);
let unpinButton;
if (showUnpin) {
const onUnpinClick = () => {
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
onFinished();
};
unpinButton = <IconizedContextMenuOption onClick={onUnpinClick} label={_t("Unpin")} />;
}
let editButton;
if (canModify && WidgetUtils.isManagedByManager(app)) {
const onEditClick = () => {
WidgetUtils.editWidget(room, app);
onFinished();
};
editButton = <IconizedContextMenuOption onClick={onEditClick} label={_t("Edit")} />;
}
let snapshotButton;
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
const onSnapshotClick = () => {
widgetMessaging?.takeScreenshot().then(data => {
dis.dispatch({
action: 'picture_snapshot',
file: data.screenshot,
});
}).catch(err => {
console.error("Failed to take screenshot: ", err);
});
onFinished();
};
snapshotButton = <IconizedContextMenuOption onClick={onSnapshotClick} label={_t("Take a picture")} />;
}
let deleteButton;
if (onDeleteClick || canModify) {
const onDeleteClickDefault = () => {
// Show delete confirmation dialog
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
title: _t("Delete Widget"),
description: _t(
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?"),
button: _t("Delete widget"),
onFinished: (confirmed) => {
if (!confirmed) return;
WidgetUtils.setRoomWidget(roomId, app.id);
},
});
onFinished();
};
deleteButton = <IconizedContextMenuOption
onClick={onDeleteClick || onDeleteClickDefault}
label={userWidget ? _t("Remove") : _t("Remove for everyone")}
/>;
}
let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId];
if (isAllowedWidget === undefined) {
isAllowedWidget = app.creatorUserId === cli.getUserId();
}
const isLocalWidget = WidgetType.JITSI.matches(app.type);
let revokeButton;
if (!userWidget && !isLocalWidget && isAllowedWidget) {
const onRevokeClick = () => {
console.info("Revoking permission for widget to load: " + app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
current[app.eventId] = false;
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
onFinished();
};
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}
const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id);
let moveLeftButton;
if (showUnpin && widgetIndex > 0) {
const onClick = () => {
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
onFinished();
};
moveLeftButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move left")} />;
}
let moveRightButton;
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
const onClick = () => {
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
onFinished();
};
moveRightButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move right")} />;
}
return <IconizedContextMenu {...props} chevronFace={ChevronFace.None} onFinished={onFinished}>
<IconizedContextMenuOptionList>
{ editButton }
{ revokeButton }
{ deleteButton }
{ snapshotButton }
{ moveLeftButton }
{ moveRightButton }
{ unpinButton }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
export default WidgetContextMenu;

View file

@ -23,15 +23,17 @@ import { _t } from '../../../languageHandler';
*/
export default class ConfirmRedactDialog extends React.Component {
render() {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog');
return (
<QuestionDialog onFinished={this.props.onFinished}
<TextInputDialog onFinished={this.props.onFinished}
title={_t("Confirm Removal")}
description={
_t("Are you sure you wish to remove (delete) this event? " +
"Note that if you delete a room name or topic change, it could undo the change.")}
placeholder={_t("Reason (optional)")}
focus
button={_t("Remove")}>
</QuestionDialog>
</TextInputDialog>
);
}
}

View file

@ -32,6 +32,12 @@ import {
PHASE_STARTED,
PHASE_CANCELLED,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import WidgetStore from "../../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import {SETTINGS} from "../../../settings/Settings";
import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
class GenericEditor extends React.PureComponent {
// static propTypes = {onBack: PropTypes.func.isRequired};
@ -701,6 +707,377 @@ class VerificationExplorer extends React.Component {
}
}
class WidgetExplorer extends React.Component {
static getLabel() {
return _t("Active Widgets");
}
constructor(props) {
super(props);
this.state = {
query: '',
editWidget: null, // set to an IApp when editing
};
}
onWidgetStoreUpdate = () => {
this.forceUpdate();
};
onQueryChange = (query) => {
this.setState({query});
};
onEditWidget = (widget) => {
this.setState({editWidget: widget});
};
onBack = () => {
const widgets = WidgetStore.instance.getApps(this.props.room.roomId);
if (this.state.editWidget && widgets.includes(this.state.editWidget)) {
this.setState({editWidget: null});
} else {
this.props.onBack();
}
};
componentDidMount() {
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
}
componentWillUnmount() {
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgetStoreUpdate);
}
render() {
const room = this.props.room;
const editWidget = this.state.editWidget;
const widgets = WidgetStore.instance.getApps(room.roomId);
if (editWidget && widgets.includes(editWidget)) {
const allState = Array.from(Array.from(room.currentState.events.values()).map(e => e.values()))
.reduce((p, c) => {p.push(...c); return p;}, []);
const stateEv = allState.find(ev => ev.getId() === editWidget.eventId);
if (!stateEv) { // "should never happen"
return <div>
{_t("There was an error finding this widget.")}
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>;
}
return <SendCustomEvent
onBack={this.onBack}
room={room}
forceStateEvent={true}
inputs={{
eventType: stateEv.getType(),
evContent: JSON.stringify(stateEv.getContent(), null, '\t'),
stateKey: stateEv.getStateKey(),
}}
/>;
}
return (<div>
<div className="mx_Dialog_content">
<FilteredList query={this.state.query} onChange={this.onQueryChange}>
{widgets.map(w => {
return <button
className='mx_DevTools_RoomStateExplorer_button'
key={w.url + w.eventId}
onClick={() => this.onEditWidget(w)}
>{w.url}</button>;
})}
</FilteredList>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>);
}
}
class SettingsExplorer extends React.Component {
static getLabel() {
return _t("Settings Explorer");
}
constructor(props) {
super(props);
this.state = {
query: '',
editSetting: null, // set to a setting ID when editing
viewSetting: null, // set to a setting ID when exploring in detail
explicitValues: null, // stringified JSON for edit view
explicitRoomValues: null, // stringified JSON for edit view
};
}
onQueryChange = (ev) => {
this.setState({query: ev.target.value});
};
onExplValuesEdit = (ev) => {
this.setState({explicitValues: ev.target.value});
};
onExplRoomValuesEdit = (ev) => {
this.setState({explicitRoomValues: ev.target.value});
};
onBack = () => {
if (this.state.editSetting) {
this.setState({editSetting: null});
} else if (this.state.viewSetting) {
this.setState({viewSetting: null});
} else {
this.props.onBack();
}
};
onViewClick = (ev, settingId) => {
ev.preventDefault();
this.setState({viewSetting: settingId});
};
onEditClick = (ev, settingId) => {
ev.preventDefault();
this.setState({
editSetting: settingId,
explicitValues: this.renderExplicitSettingValues(settingId, null),
explicitRoomValues: this.renderExplicitSettingValues(settingId, this.props.room.roomId),
});
};
onSaveClick = async () => {
try {
const settingId = this.state.editSetting;
const parsedExplicit = JSON.parse(this.state.explicitValues);
const parsedExplicitRoom = JSON.parse(this.state.explicitRoomValues);
for (const level of Object.keys(parsedExplicit)) {
console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`);
try {
const val = parsedExplicit[level];
await SettingsStore.setValue(settingId, null, level, val);
} catch (e) {
console.warn(e);
}
}
const roomId = this.props.room.roomId;
for (const level of Object.keys(parsedExplicit)) {
console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`);
try {
const val = parsedExplicitRoom[level];
await SettingsStore.setValue(settingId, roomId, level, val);
} catch (e) {
console.warn(e);
}
}
this.setState({
viewSetting: settingId,
editSetting: null,
});
} catch (e) {
Modal.createTrackedDialog('Devtools - Failed to save settings', '', ErrorDialog, {
title: _t("Failed to save settings"),
description: e.message,
});
}
};
renderSettingValue(val) {
// Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us
const toStringTypes = ['boolean', 'number'];
if (toStringTypes.includes(typeof(val))) {
return val.toString();
} else {
return JSON.stringify(val);
}
}
renderExplicitSettingValues(setting, roomId) {
const vals = {};
for (const level of LEVEL_ORDER) {
try {
vals[level] = SettingsStore.getValueAt(level, setting, roomId, true, true);
if (vals[level] === undefined) {
vals[level] = null;
}
} catch (e) {
console.warn(e);
}
}
return JSON.stringify(vals, null, 4);
}
renderCanEditLevel(roomId, level) {
const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level);
const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable';
return <td className={className}><code>{canEdit.toString()}</code></td>;
}
render() {
const room = this.props.room;
if (!this.state.viewSetting && !this.state.editSetting) {
// view all settings
const allSettings = Object.keys(SETTINGS)
.filter(n => this.state.query ? n.toLowerCase().includes(this.state.query.toLowerCase()) : true);
return (
<div>
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
<Field
label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.state.query} onChange={this.onQueryChange}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
/>
<table>
<thead>
<tr>
<th>{_t("Setting ID")}</th>
<th>{_t("Value")}</th>
<th>{_t("Value in this room")}</th>
</tr>
</thead>
<tbody>
{allSettings.map(i => (
<tr key={i}>
<td>
<a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{i}</code>
</a>
<a href="" onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit'
>
</a>
</td>
<td>
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
</td>
<td>
<code>
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>
);
} else if (this.state.editSetting) {
return (
<div>
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
<h3>{_t("Setting:")} <code>{this.state.editSetting}</code></h3>
<div className='mx_DevTools_SettingsExplorer_warning'>
<b>{_t("Caution:")}</b> {_t(
"This UI does NOT check the types of the values. Use at your own risk.",
)}
</div>
<div>
{_t("Setting definition:")}
<pre><code>{JSON.stringify(SETTINGS[this.state.editSetting], null, 4)}</code></pre>
</div>
<div>
<table>
<thead>
<tr>
<th>{_t("Level")}</th>
<th>{_t("Settable at global")}</th>
<th>{_t("Settable at room")}</th>
</tr>
</thead>
<tbody>
{LEVEL_ORDER.map(lvl => (
<tr key={lvl}>
<td><code>{lvl}</code></td>
{this.renderCanEditLevel(null, lvl)}
{this.renderCanEditLevel(room.roomId, lvl)}
</tr>
))}
</tbody>
</table>
</div>
<div>
<Field
id="valExpl" label={_t("Values at explicit levels")} type="text"
className="mx_DevTools_textarea" element="textarea"
autoComplete="off" value={this.state.explicitValues}
onChange={this.onExplValuesEdit}
/>
</div>
<div>
<Field
id="valExpl" label={_t("Values at explicit levels in this room")} type="text"
className="mx_DevTools_textarea" element="textarea"
autoComplete="off" value={this.state.explicitRoomValues}
onChange={this.onExplRoomValuesEdit}
/>
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onSaveClick}>{_t("Save setting values")}</button>
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>
);
} else if (this.state.viewSetting) {
return (
<div>
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
<h3>{_t("Setting:")} <code>{this.state.viewSetting}</code></h3>
<div>
{_t("Setting definition:")}
<pre><code>{JSON.stringify(SETTINGS[this.state.viewSetting], null, 4)}</code></pre>
</div>
<div>
{_t("Value:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting))}</code>
</div>
<div>
{_t("Value in this room:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting, room.roomId))}</code>
</div>
<div>
{_t("Values at explicit levels:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, null)}</code></pre>
</div>
<div>
{_t("Values at explicit levels in this room:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, room.roomId)}</code></pre>
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{_t("Edit Values")}</button>
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>
);
}
}
}
const Entries = [
SendCustomEvent,
RoomStateExplorer,
@ -708,6 +1085,8 @@ const Entries = [
AccountDataExplorer,
ServersInRoomList,
VerificationExplorer,
WidgetExplorer,
SettingsExplorer,
];
export default class DevtoolsDialog extends React.PureComponent {

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