Merge branches 'develop' and 't3chguy/leaks' of github.com:matrix-org/matrix-react-sdk into t3chguy/leaks

 Conflicts:
	src/components/views/avatars/BaseAvatar.js
This commit is contained in:
Michael Telatynski 2020-05-15 00:16:41 +01:00
commit 5844bee8f2
771 changed files with 25042 additions and 9044 deletions

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

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

View file

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

View file

@ -123,8 +123,8 @@ const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() {
try {
let data = localStorage.getItem(UID_KEY);
if (!data) {
let data = localStorage && localStorage.getItem(UID_KEY);
if (!data && localStorage) {
localStorage.setItem(UID_KEY, data = [...Array(16)].map(() => Math.random().toString(16)[2]).join(''));
}
return data;
@ -145,14 +145,16 @@ class Analytics {
this.firstPage = true;
this._heartbeatIntervalID = null;
this.creationTs = localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs) {
this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs && localStorage) {
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
}
this.lastVisitTs = localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage.getItem(VISIT_COUNT_KEY) || 0;
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0;
if (localStorage) {
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
}
}
get disabled() {
@ -260,7 +262,6 @@ class Analytics {
});
} catch (e) {
console.error("Analytics error: ", e);
window.err = e;
}
}

View file

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

View file

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

View file

@ -4,6 +4,7 @@
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,6 +19,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClient} from "matrix-js-sdk";
import dis from './dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
@ -162,4 +164,30 @@ export default class BasePlatform {
getEventIndexingManager(): BaseEventIndexManager | null {
return null;
}
setLanguage(preferredLangs: string[]) {}
getSSOCallbackUrl(hsUrl: string, isUrl: string, fragmentAfterLogin: string): URL {
const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || "";
url.searchParams.set("homeserver", hsUrl);
url.searchParams.set("identityServer", isUrl);
return url;
}
/**
* Begin Single Sign On flows.
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) {
const callbackUrl = this.getSSOCallbackUrl(mxClient.getHomeserverUrl(), mxClient.getIdentityServerUrl(),
fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
}
onKeyDown(ev: KeyboardEvent): boolean {
return false; // no shortcuts implemented
}
}

View file

@ -60,12 +60,13 @@ import * as sdk from './index';
import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk';
import dis from './dispatcher';
import SdkConfig from './SdkConfig';
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore, { SettingLevel } from './settings/SettingsStore';
import {generateHumanReadableId} from "./utils/NamingUtils";
import {Jitsi} from "./widgets/Jitsi";
import {WidgetType} from "./widgets/WidgetType";
global.mxCalls = {
//room_id: MatrixCall
@ -143,7 +144,7 @@ function _setCallListeners(call) {
"if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call.",
),
button: _t('Review Devices'),
button: _t('Review Sessions'),
onFinished: function(confirmed) {
if (confirmed) {
const room = MatrixClientPeg.get().getRoom(call.roomId);
@ -395,41 +396,15 @@ function _onAction(payload) {
}
async function _startCallApp(roomId, type) {
// check for a working integration manager. Technically we could put
// the state event in anyway, but the resulting widget would then not
// work for us. Better that the user knows before everyone else in the
// room sees it.
const managers = IntegrationManagers.sharedInstance();
let haveScalar = false;
if (managers.hasManager()) {
try {
const scalarClient = managers.getPrimaryManager().getScalarClient();
await scalarClient.connect();
haveScalar = scalarClient.hasCredentials();
} catch (e) {
// ignore
}
}
if (!haveScalar) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, {
title: _t('Could not connect to the integration server'),
description: _t('A conference call could not be started because the integrations server is not available'),
});
return;
}
dis.dispatch({
action: 'appsDrawer',
show: true,
});
const room = MatrixClientPeg.get().getRoom(roomId);
const currentRoomWidgets = WidgetUtils.getRoomWidgets(room);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, 'jitsi')) {
if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
@ -439,9 +414,6 @@ async function _startCallApp(roomId, type) {
return;
}
const currentJitsiWidgets = currentRoomWidgets.filter((ev) => {
return ev.getContent().type === 'jitsi';
});
if (currentJitsiWidgets.length > 0) {
console.warn(
"Refusing to start conference call widget in " + roomId +
@ -456,31 +428,22 @@ async function _startCallApp(roomId, type) {
return;
}
// This inherits its poor naming from the field of the same name that goes into
// the event. It's just a random string to make the Jitsi URLs unique.
const widgetSessionId = Math.random().toString(36).substring(2);
const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId;
// NB. we can't just encodeURICompoent all of these because the $ signs need to be there
// (but currently the only thing that needs encoding is the confId)
const queryString = [
'confId='+encodeURIComponent(confId),
'isAudioConf='+(type === 'voice' ? 'true' : 'false'),
'displayName=$matrix_display_name',
'avatarUrl=$matrix_avatar_url',
'email=$matrix_user_id',
].join('&');
const confId = `JitsiConference${generateHumanReadableId()}`;
const jitsiDomain = Jitsi.getInstance().preferredDomain;
let widgetUrl;
if (SdkConfig.get().integrations_jitsi_widget_url) {
// Try this config key. This probably isn't ideal as a way of discovering this
// URL, but this will at least allow the integration manager to not be hardcoded.
widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString;
} else {
const apiUrl = IntegrationManagers.sharedInstance().getPrimaryManager().apiUrl;
widgetUrl = apiUrl + '/widgets/jitsi.html?' + queryString;
}
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
const widgetData = { widgetSessionId };
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
const parsedUrl = new URL(widgetUrl);
parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
parsedUrl.searchParams.set('confId', confId);
widgetUrl = parsedUrl.toString();
const widgetData = {
conferenceId: confId,
isAudioOnly: type === 'voice',
domain: jitsiDomain,
};
const widgetId = (
'jitsi_' +
@ -489,7 +452,7 @@ async function _startCallApp(roomId, type) {
Date.now()
);
WidgetUtils.setRoomWidget(roomId, widgetId, 'jitsi', widgetUrl, 'Jitsi', widgetData).then(() => {
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
console.log('Jitsi widget added');
}).catch((e) => {
if (e.errcode === 'M_FORBIDDEN') {

View file

@ -21,6 +21,7 @@ import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler';
import SettingsStore from './settings/SettingsStore';
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
@ -50,7 +51,7 @@ async function confirmToDismiss(name) {
} else if (name === "m.cross_signing.self_signing") {
description = _t("If you cancel now, you won't complete verifying your other session.");
} else {
description = _t("If you cancel now, you won't complete your secret storage operation.");
description = _t("If you cancel now, you won't complete your operation.");
}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
@ -96,7 +97,7 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
keyInfo: info,
checkPrivateKey: async (input) => {
const key = await inputToKey(input);
return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey);
return await MatrixClientPeg.get().checkSecretStorageKey(key, info);
},
},
/* className= */ null,
@ -125,10 +126,74 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
return [name, key];
}
const onSecretRequested = async function({
user_id: userId,
device_id: deviceId,
request_id: requestId,
name,
device_trust: deviceTrust,
}) {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get();
if (userId !== client.getUserId()) {
return;
}
if (!deviceTrust || !deviceTrust.isVerified()) {
console.log(`CrossSigningManager: Ignoring request from untrusted device ${deviceId}`);
return;
}
if (name.startsWith("m.cross_signing")) {
const callbacks = client.getCrossSigningCacheCallbacks();
if (!callbacks.getCrossSigningKeyCache) return;
/* Explicit enumeration here is deliberate never share the master key! */
if (name === "m.cross_signing.self_signing") {
const key = await callbacks.getCrossSigningKeyCache("self_signing");
if (!key) {
console.log(
`self_signing requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
} else if (name === "m.cross_signing.user_signing") {
const key = await callbacks.getCrossSigningKeyCache("user_signing");
if (!key) {
console.log(
`user_signing requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
}
} else if (name === "m.megolm_backup.v1") {
const key = await client._crypto.getSessionBackupPrivateKey();
if (!key) {
console.log(
`session backup key requested by ${deviceId}, but not found in cache`,
);
}
return key && encodeBase64(key);
}
console.warn("onSecretRequested didn't recognise the secret named ", name);
};
export const crossSigningCallbacks = {
getSecretStorageKey,
onSecretRequested,
};
export async function promptForBackupPassphrase() {
let key;
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
showSummary: false, keyCallback: k => key = k,
}, null, /* priority = */ false, /* static = */ true);
const success = await finished;
if (!success) throw new Error("Key backup prompt cancelled");
return key;
}
/**
* This helper should be used whenever you need to access secret storage. It
* ensures that secret storage (and also cross-signing since they each depend on
@ -148,19 +213,19 @@ export const crossSigningCallbacks = {
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param {bool} [force] Reset secret storage even if it's already set up
* @param {bool} [forceReset] Reset secret storage even if it's already set up
*/
export async function accessSecretStorage(func = async () => { }, force = false) {
export async function accessSecretStorage(func = async () => { }, forceReset = false) {
const cli = MatrixClientPeg.get();
secretStorageBeingAccessed = true;
try {
if (!await cli.hasSecretStorageKey() || force) {
if (!await cli.hasSecretStorageKey() || forceReset) {
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"),
{
force,
force: forceReset,
},
null, /* priority = */ false, /* static = */ true,
);
@ -185,6 +250,7 @@ export async function accessSecretStorage(func = async () => { }, force = false)
throw new Error("Cross-signing key upload auth canceled");
}
},
getBackupPassphrase: promptForBackupPassphrase,
});
}

View file

@ -20,12 +20,13 @@ import * as sdk from './index';
import { _t } from './languageHandler';
import ToastStore from './stores/ToastStore';
function toastKey(deviceId) {
return 'unverified_session_' + deviceId;
}
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
const THIS_DEVICE_TOAST_KEY = 'setupencryption';
const OTHER_DEVICES_TOAST_KEY = 'reviewsessions';
function toastKey(deviceId) {
return "unverified_session_" + deviceId;
}
export default class DeviceListener {
static sharedInstance() {
@ -34,8 +35,6 @@ export default class DeviceListener {
}
constructor() {
// set of device IDs we're currently showing toasts for
this._activeNagToasts = new Set();
// device IDs for which the user has dismissed the verify toast ('Later')
this._dismissed = new Set();
// has the user dismissed any of the various nag toasts to setup encryption on this device?
@ -44,26 +43,55 @@ export default class DeviceListener {
// cache of the key backup info
this._keyBackupInfo = null;
this._keyBackupFetchedAt = null;
// We keep a list of our own device IDs so we can batch ones that were already
// there the last time the app launched into a single toast, but display new
// ones in their own toasts.
this._ourDeviceIdsAtStart = null;
// The set of device IDs we're currently displaying toasts for
this._displayingToastsForDeviceIds = new Set();
}
start() {
MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().on('accountData', this._onAccountData);
MatrixClientPeg.get().on('sync', this._onSync);
this._recheck();
}
stop() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices);
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
MatrixClientPeg.get().removeListener('sync', this._onSync);
}
this._dismissed.clear();
this._dismissedThisDeviceToast = false;
this._keyBackupInfo = null;
this._keyBackupFetchedAt = null;
this._ourDeviceIdsAtStart = null;
this._displayingToastsForDeviceIds = new Set();
}
dismissVerification(deviceId) {
this._dismissed.add(deviceId);
/**
* Dismiss notifications about our own unverified devices
*
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
async dismissUnverifiedSessions(deviceIds) {
for (const d of deviceIds) {
this._dismissed.add(d);
}
this._recheck();
}
@ -72,6 +100,28 @@ export default class DeviceListener {
this._recheck();
}
_ensureDeviceIdsAtStartPopulated() {
if (this._ourDeviceIdsAtStart === null) {
const cli = MatrixClientPeg.get();
this._ourDeviceIdsAtStart = new Set(
cli.getStoredDevicesForUser(cli.getUserId()).map(d => d.deviceId),
);
}
}
_onWillUpdateDevices = async (users, initialFetch) => {
// If we didn't know about *any* devices before (ie. it's fresh login),
// then they are all pre-existing devices, so ignore this and set the
// devicesAtStart list to the devices that we see after the fetch.
if (initialFetch) return;
const myUserId = MatrixClientPeg.get().getUserId();
if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated();
// No need to do a recheck here: we just need to get a snapshot of our devices
// before we download any new ones.
}
_onDevicesUpdated = (users) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this._recheck();
@ -87,6 +137,28 @@ export default class DeviceListener {
this._recheck();
}
_onCrossSingingKeysChanged = () => {
this._recheck();
}
_onAccountData = (ev) => {
// User may have:
// * migrated SSSS to symmetric
// * uploaded keys to secret storage
// * completed secret storage creation
// which result in account data changes affecting checks below.
if (
ev.getType().startsWith('m.secret_storage.') ||
ev.getType().startsWith('m.cross_signing.')
) {
this._recheck();
}
}
_onSync = (state, prevState) => {
if (state === 'PREPARED' && prevState === null) this._recheck();
}
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
async _getKeyBackupInfo() {
@ -99,80 +171,130 @@ export default class DeviceListener {
}
async _recheck() {
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return;
const cli = MatrixClientPeg.get();
if (!cli.isCryptoEnabled()) return;
if (!cli.getCrossSigningId()) {
if (this._dismissedThisDeviceToast) {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
return;
}
if (
!SettingsStore.getValue("feature_cross_signing") ||
!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
) return;
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (cli.getStoredCrossSigningForUser(cli.getUserId())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Verify this session"),
icon: "verification_warning",
props: {kind: 'verify_this_session'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
const backupInfo = await this._getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
if (!cli.isCryptoEnabled()) return;
// don't recheck until the initial sync is complete: lots of account data events will fire
// while the initial sync is processing and we don't need to recheck on each one of them
// (we add a listener on sync to do once check after the initial sync is done)
if (!cli.isInitialSyncComplete()) return;
const crossSigningReady = await cli.isCrossSigningReady();
if (this._dismissedThisDeviceToast) {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
} else {
if (!crossSigningReady) {
// make sure our keys are finished downlaoding
await cli.downloadKeys([cli.getUserId()]);
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (cli.getStoredCrossSigningForUser(cli.getUserId())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
title: _t("Verify this session"),
icon: "verification_warning",
props: {kind: 'upgrade_encryption'},
props: {kind: 'verify_this_session'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
// No cross-signing or key backup on account (set up encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Set up encryption"),
icon: "verification_warning",
props: {kind: 'set_up_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
const backupInfo = await this._getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
icon: "verification_warning",
props: {kind: 'upgrade_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
// No cross-signing or key backup on account (set up encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Set up encryption"),
icon: "verification_warning",
props: {kind: 'set_up_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
}
}
} else {
// cross-signing is ready, and we don't need to upgrade encryption
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
}
}
// This needs to be done after awaiting on downloadKeys() above, so
// we make sure we get the devices after the fetch is done.
this._ensureDeviceIdsAtStartPopulated();
// Unverified devices that were there last time the app ran
// (technically could just be a boolean: we don't actually
// need to remember the device IDs, but for the sake of
// symmetry...).
const oldUnverifiedDeviceIds = new Set();
// Unverified devices that have appeared since then
const newUnverifiedDeviceIds = new Set();
// as long as cross-signing isn't ready,
// you can't see or dismiss any device toasts
if (crossSigningReady) {
const devices = cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId == cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (!deviceTrust.isCrossSigningVerified() && !this._dismissed.has(device.deviceId)) {
if (this._ourDeviceIdsAtStart.has(device.deviceId)) {
oldUnverifiedDeviceIds.add(device.deviceId);
} else {
newUnverifiedDeviceIds.add(device.deviceId);
}
}
}
return;
} else {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
}
const newActiveToasts = new Set();
// Display or hide the batch toast for old unverified sessions
if (oldUnverifiedDeviceIds.size > 0) {
ToastStore.sharedInstance().addOrReplaceToast({
key: OTHER_DEVICES_TOAST_KEY,
title: _t("Review where youre logged in"),
icon: "verification_warning",
priority: ToastStore.PRIORITY_LOW,
props: {
deviceIds: oldUnverifiedDeviceIds,
},
component: sdk.getComponent("toasts.BulkUnverifiedSessionsToast"),
});
} else {
ToastStore.sharedInstance().dismissToast(OTHER_DEVICES_TOAST_KEY);
}
const devices = await cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId == cli.deviceId) continue;
// Show toasts for new unverified devices if they aren't already there
for (const deviceId of newUnverifiedDeviceIds) {
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey(deviceId),
title: _t("New login. Was this you?"),
icon: "verification_warning",
props: { deviceId },
component: sdk.getComponent("toasts.UnverifiedSessionToast"),
});
}
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId));
} else {
this._activeNagToasts.add(device.deviceId);
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey(device.deviceId),
title: _t("Unverified session"),
icon: "verification_warning",
props: { device },
component: sdk.getComponent("toasts.UnverifiedSessionToast"),
});
newActiveToasts.add(device.deviceId);
// ...and hide any we don't need any more
for (const deviceId of this._displayingToastsForDeviceIds) {
if (!newUnverifiedDeviceIds.has(deviceId)) {
ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
}
}
// clear any other outstanding toasts (eg. logged out devices)
for (const deviceId of this._activeNagToasts) {
if (!newActiveToasts.has(deviceId)) ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
}
this._activeNagToasts = newActiveToasts;
this._displayingToastsForDeviceIds = newUnverifiedDeviceIds;
}
}

View file

@ -24,6 +24,7 @@ import {MatrixClientPeg} from "./MatrixClientPeg";
import RoomViewStore from "./stores/RoomViewStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore from "./settings/SettingsStore";
import {Capability} from "./widgets/WidgetApi";
const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
@ -99,7 +100,7 @@ export default class FromWidgetPostMessageApi {
console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
return;
} else {
console.warn(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
this.widgetMessagingEndpoints.push(endpoint);
}
}
@ -164,7 +165,7 @@ export default class FromWidgetPostMessageApi {
const action = event.data.action;
const widgetId = event.data.widgetId;
if (action === 'content_loaded') {
console.warn('Widget reported content loaded for', widgetId);
console.log('Widget reported content loaded for', widgetId);
dis.dispatch({
action: 'widget_content_loaded',
widgetId: widgetId,
@ -213,7 +214,7 @@ export default class FromWidgetPostMessageApi {
const data = event.data.data;
const val = data.value;
if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) {
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
}
} else if (action === 'get_openid') {

View file

@ -23,7 +23,6 @@ import ReplyThread from "./components/views/elements/ReplyThread";
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import highlight from 'highlight.js';
import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
@ -160,7 +159,7 @@ const transformTags = { // custom to matrix
delete attribs.target;
}
}
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
'img': function(tagName, attribs) {
@ -447,7 +446,8 @@ export function bodyToHtml(content, highlights, opts={}) {
// their username. Permalinks (links in pills) can be any URL
// now, so we just check for an HTTP-looking thing.
(
content.formatted_body == undefined ||
strippedBody === safeBody || // replies have the html fallbacks, account for that here
content.formatted_body === undefined ||
(!content.formatted_body.includes("http:") &&
!content.formatted_body.includes("https:"))
);
@ -467,11 +467,12 @@ export function bodyToHtml(content, highlights, opts={}) {
/**
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
*
* @param {string} str
* @returns {string}
* @param {string} str string to linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/
export function linkifyString(str) {
return _linkifyString(str);
export function linkifyString(str, options = linkifyMatrix.options) {
return _linkifyString(str, options);
}
/**
@ -489,10 +490,11 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* Linkify the given string and sanitize the HTML afterwards.
*
* @param {string} dirtyHtml The HTML string to sanitize and linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml) {
return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams);
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
/**

View file

@ -181,24 +181,12 @@ export default class IdentityAuthClient {
}
async registerForToken(check=true) {
try {
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
// XXX: The spec is `token`, but we used `access_token` for a Sydent release.
const { access_token: accessToken, token } =
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken);
const identityAccessToken = token ? token : accessToken;
if (check) await this._checkToken(identityAccessToken);
return identityAccessToken;
} catch (e) {
if (e.cors === "rejected" || e.httpStatus === 404) {
// Assume IS only supports deprecated v1 API for now
// TODO: Remove this path once v2 is only supported version
// See https://github.com/vector-im/riot-web/issues/10443
console.warn("IS doesn't support v2 auth");
this.authEnabled = false;
return;
}
throw e;
}
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
// XXX: The spec is `token`, but we used `access_token` for a Sydent release.
const { access_token: accessToken, token } =
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken);
const identityAccessToken = token ? token : accessToken;
if (check) await this._checkToken(identityAccessToken);
return identityAccessToken;
}
}

View file

@ -35,7 +35,7 @@ export default class KeyRequestHandler {
handleKeyRequest(keyRequest) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (SettingsStore.getValue("feature_cross_signing")) {
return;
}
@ -70,7 +70,7 @@ export default class KeyRequestHandler {
handleKeyRequestCancellation(cancellation) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (SettingsStore.getValue("feature_cross_signing")) {
return;
}

View file

@ -22,6 +22,7 @@ export const Key = {
PAGE_UP: "PageUp",
PAGE_DOWN: "PageDown",
BACKSPACE: "Backspace",
DELETE: "Delete",
ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
@ -36,10 +37,14 @@ export const Key = {
CONTEXT_MENU: "ContextMenu",
COMMA: ",",
PERIOD: ".",
LESS_THAN: "<",
GREATER_THAN: ">",
BACKTICK: "`",
SPACE: " ",
SLASH: "/",
SQUARE_BRACKET_LEFT: "[",
SQUARE_BRACKET_RIGHT: "]",
A: "a",
B: "b",
C: "c",
@ -68,8 +73,9 @@ export const Key = {
Z: "z",
};
export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
export function isOnlyCtrlOrCmdKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
@ -78,7 +84,6 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) {
}
export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey;
} else {

View file

@ -40,6 +40,7 @@ import ToastStore from "./stores/ToastStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {Mjolnir} from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener";
import {Jitsi} from "./widgets/Jitsi";
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -313,7 +314,7 @@ async function _restoreFromLocalStorage(opts) {
}
}
function _handleLoadSessionFailure(e) {
async function _handleLoadSessionFailure(e) {
console.error("Unable to load session", e);
const SessionRestoreErrorDialog =
@ -323,16 +324,15 @@ function _handleLoadSessionFailure(e) {
error: e.message,
});
return modal.finished.then(([success]) => {
if (success) {
// user clicked continue.
_clearStorage();
return false;
}
const [success] = await modal.finished;
if (success) {
// user clicked continue.
await _clearStorage();
return false;
}
// try, try again
return loadSession();
});
// try, try again
return loadSession();
}
/**
@ -579,9 +579,6 @@ async function startMatrixClient(startSyncing=true) {
UserActivity.sharedInstance().start();
TypingStore.sharedInstance().reset(); // just in case
ToastStore.sharedInstance().reset();
if (!SettingsStore.getValue("lowBandwidth")) {
Presence.start();
}
DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.start();
@ -604,6 +601,14 @@ async function startMatrixClient(startSyncing=true) {
// This needs to be started after crypto is set up
DeviceListener.sharedInstance().start();
// Similarly, don't start sending presence updates until we've started
// the client
if (!SettingsStore.getValue("lowBandwidth")) {
Presence.start();
}
// Now that we have a MatrixClientPeg, update the Jitsi info
await Jitsi.getInstance().update();
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
@ -638,6 +643,10 @@ async function _clearStorage() {
window.localStorage.clear();
}
if (window.sessionStorage) {
window.sessionStorage.clear();
}
// create a temporary client to clear out the persistent stores.
const cli = createMatrixClient({
// we'll never make any requests, so can pass a bogus HS URL

View file

@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -19,8 +20,6 @@ limitations under the License.
import Matrix from "matrix-js-sdk";
import url from 'url';
export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
this._hsUrl = hsUrl;
@ -29,6 +28,7 @@ export default class Login {
this._currentFlowIndex = 0;
this._flows = [];
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this._tempClient = null; // memoize
}
getHomeserverUrl() {
@ -40,10 +40,12 @@ export default class Login {
}
setHomeserverUrl(hsUrl) {
this._tempClient = null; // clear memoization
this._hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._tempClient = null; // clear memoization
this._isUrl = isUrl;
}
@ -52,8 +54,9 @@ export default class Login {
* requests.
* @returns {MatrixClient}
*/
_createTemporaryClient() {
return Matrix.createClient({
createTemporaryClient() {
if (this._tempClient) return this._tempClient; // use memoization
return this._tempClient = Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
});
@ -61,7 +64,7 @@ export default class Login {
getFlows() {
const self = this;
const client = this._createTemporaryClient();
const client = this.createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
@ -139,21 +142,6 @@ export default class Login {
throw error;
});
}
getSsoLoginUrl(loginType) {
const client = this._createTemporaryClient();
const parsedUrl = url.parse(window.location.href, true);
// XXX: at this point, the fragment will always be #/login, which is no
// use to anyone. Ideally, we would get the intended fragment from
// MatrixChat.screenAfterLogin so that you could follow #/room links etc
// through an SSO login.
parsedUrl.hash = "";
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
return client.getSsoLoginUrl(url.format(parsedUrl), loginType);
}
}

View file

@ -136,7 +136,7 @@ export default class Markdown {
// thus opening in a new tab.
if (externalLinks) {
attrs.push(['target', '_blank']);
attrs.push(['rel', 'noopener']);
attrs.push(['rel', 'noreferrer noopener']);
}
this.tag('a', attrs);
} else {

View file

@ -32,7 +32,7 @@ import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientB
import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks } from './CrossSigningManager';
import {SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
interface MatrixClientCreds {
homeserverUrl: string,
@ -148,6 +148,9 @@ class _MatrixClientPeg {
// check that we have a version of the js-sdk which includes initCrypto
if (!SettingsStore.getValue("lowBandwidth") && this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto();
this.matrixClient.setCryptoTrustCrossSignedDevices(
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
);
StorageManager.setCryptoInitialised(true);
}
} catch (e) {
@ -221,7 +224,6 @@ class _MatrixClientPeg {
verificationMethods: [
verificationMethods.SAS,
SHOW_QR_CODE_METHOD,
SCAN_QR_CODE_METHOD, // XXX: We don't actually support scanning yet!
verificationMethods.RECIPROCATE_QR_CODE,
],
unstableClientRelationAggregation: true,

View file

@ -37,6 +37,18 @@ import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
const MAX_PENDING_ENCRYPTED = 20;
/*
Override both the content body and the TextForEvent handler for specific msgtypes, in notifications.
This is useful when the content body contains fallback text that would explain that the client can't handle a particular
type of tile.
*/
const typehandlers = {
"m.key.verification.request": (event) => {
const name = (event.sender || {}).name;
return _t("%(name)s is requesting verification", { name });
},
};
const Notifier = {
notifsByRoom: {},
@ -46,6 +58,9 @@ const Notifier = {
pendingEncryptedEventIds: [],
notificationMessageForEvent: function(ev) {
if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
return typehandlers[ev.getContent().msgtype](ev);
}
return TextForEvent.textForEvent(ev);
},
@ -69,7 +84,9 @@ const Notifier = {
title = room.name;
// notificationMessageForEvent includes sender,
// but we already have the sender here
if (ev.getContent().body) msg = ev.getContent().body;
if (ev.getContent().body && !typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
}
} else if (ev.getType() === 'm.room.member') {
// context is all in the message here, we don't need
// to display sender info
@ -78,7 +95,9 @@ const Notifier = {
title = ev.sender.name + " (" + room.name + ")";
// notificationMessageForEvent includes sender,
// but we've just out sender in the title
if (ev.getContent().body) msg = ev.getContent().body;
if (ev.getContent().body && !typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
}
}
if (!this.isBodyEnabled()) {

View file

@ -39,6 +39,8 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
* If true, goes to the home page if the user cancels the action
* @param {bool} options.go_welcome_on_cancel
* If true, goes to the welcome page if the user cancels the action
* @param {bool} options.screen_after
* If present the screen to redirect to after a successful login or register.
*/
export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {};
@ -66,13 +68,21 @@ export async function startAnyRegistrationFlow(options) {
// });
//} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
title: _t("Registration Required"),
description: _t("You need to register to do this. Would you like to register now?"),
button: _t("Register"),
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button key="start_login" onClick={() => {
modal.close();
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
}}>{ _t('Sign In') }</button>,
],
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({action: 'start_registration'});
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
} else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'});
} else if (options.go_welcome_on_cancel) {
@ -101,4 +111,3 @@ export async function startAnyRegistrationFlow(options) {
// }
// throw new Error("Register request succeeded when it should have returned 401!");
// }

View file

@ -23,7 +23,7 @@ import {MatrixClientPeg} from './MatrixClientPeg';
* of aliases. Otherwise return null;
*/
export function getDisplayAliasForRoom(room) {
return room.getCanonicalAlias() || room.getAliases()[0];
return room.getCanonicalAlias() || room.getAltAliases()[0];
}
/**

View file

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

View file

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

View file

@ -26,6 +26,11 @@ export const DEFAULTS: ConfigOptions = {
integrations_rest_url: "https://scalar.vector.im/api",
// Where to send bug reports. If not specified, bugs cannot be sent.
bug_report_endpoint_url: null,
// Jitsi conference options
jitsi: {
// Default conference domain
preferredDomain: "jitsi.riot.im",
},
};
export default class SdkConfig {

View file

@ -87,6 +87,13 @@ async function localSearch(searchTerm, roomId = undefined) {
searchArgs.room_id = roomId;
}
const emptyResult = {
results: [],
highlights: [],
};
if (searchTerm === "") return emptyResult;
const eventIndex = EventIndexPeg.get();
const localResult = await eventIndex.search(searchArgs);
@ -97,11 +104,6 @@ async function localSearch(searchTerm, roomId = undefined) {
},
};
const emptyResult = {
results: [],
highlights: [],
};
const result = MatrixClientPeg.get()._processRoomEventsSearch(
emptyResult, response);

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,7 +18,8 @@ limitations under the License.
*/
import React from 'react';
import * as React from 'react';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher';
import * as sdk from './index';
@ -33,12 +35,23 @@ import { abbreviateUrl } from './utils/UrlUtils';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks";
import {inviteUsersToRoom} from "./RoomInvite";
import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi";
import { parseFragment as parseHtml } from "parse5";
import sendBugReport from "./rageshake/submit-rageshake";
import SdkConfig from "./SdkConfig";
import { ensureDMExists } from "./createRoom";
const singleMxcUpload = async () => {
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event {
target: HTMLInputElement & EventTarget;
}
const singleMxcUpload = async (): Promise<any> => {
return new Promise((resolve) => {
const fileSelector = document.createElement('input');
fileSelector.setAttribute('type', 'file');
fileSelector.onchange = (ev) => {
fileSelector.onchange = (ev: HTMLInputEvent) => {
const file = ev.target.files[0];
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
@ -62,28 +75,49 @@ export const CommandCategories = {
"other": _td("Other"),
};
class Command {
constructor({name, args='', description, runFn, category=CommandCategories.other, hideCompletionAfterSpace=false}) {
this.command = '/' + name;
this.args = args;
this.description = description;
this.runFn = runFn;
this.category = category;
this.hideCompletionAfterSpace = hideCompletionAfterSpace;
type RunFn = ((roomId: string, args: string, cmd: string) => {error: any} | {promise: Promise<any>});
interface ICommandOpts {
command: string;
aliases?: string[];
args?: string;
description: string;
runFn?: RunFn;
category: string;
hideCompletionAfterSpace?: boolean;
}
export class Command {
command: string;
aliases: string[];
args: undefined | string;
description: string;
runFn: undefined | RunFn;
category: string;
hideCompletionAfterSpace: boolean;
constructor(opts: ICommandOpts) {
this.command = opts.command;
this.aliases = opts.aliases || [];
this.args = opts.args || "";
this.description = opts.description;
this.runFn = opts.runFn;
this.category = opts.category || CommandCategories.other;
this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false;
}
getCommand() {
return this.command;
return `/${this.command}`;
}
getCommandWithArgs() {
return this.getCommand() + " " + this.args;
}
run(roomId, args) {
run(roomId: string, args: string, cmd: string) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) return;
return this.runFn.bind(this)(roomId, args);
return this.runFn.bind(this)(roomId, args, cmd);
}
getUsage() {
@ -95,7 +129,7 @@ function reject(error) {
return {error};
}
function success(promise) {
function success(promise?: Promise<any>) {
return {promise};
}
@ -103,11 +137,9 @@ function success(promise) {
* functions are called with `this` bound to the Command instance.
*/
/* eslint-disable babel/no-invalid-this */
export const CommandMap = {
shrug: new Command({
name: 'shrug',
export const Commands = [
new Command({
command: 'shrug',
args: '<message>',
description: _td('Prepends ¯\\_(ツ)_/¯ to a plain-text message'),
runFn: function(roomId, args) {
@ -119,8 +151,8 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
plain: new Command({
name: 'plain',
new Command({
command: 'plain',
args: '<message>',
description: _td('Sends a message as plain text, without interpreting it as markdown'),
runFn: function(roomId, messages) {
@ -128,11 +160,20 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
ddg: new Command({
name: 'ddg',
new Command({
command: 'html',
args: '<message>',
description: _td('Sends a message as html, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, messages, messages));
},
category: CommandCategories.messages,
}),
new Command({
command: 'ddg',
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
runFn: function(roomId, args) {
runFn: function() {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
@ -144,9 +185,8 @@ export const CommandMap = {
category: CommandCategories.actions,
hideCompletionAfterSpace: true,
}),
upgraderoom: new Command({
name: 'upgraderoom',
new Command({
command: 'upgraderoom',
args: '<new_version>',
description: _td('Upgrades a room to a new version'),
runFn: function(roomId, args) {
@ -215,9 +255,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
nick: new Command({
name: 'nick',
new Command({
command: 'nick',
args: '<display_name>',
description: _td('Changes your display nickname'),
runFn: function(roomId, args) {
@ -228,9 +267,9 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
myroomnick: new Command({
name: 'myroomnick',
new Command({
command: 'myroomnick',
aliases: ['roomnick'],
args: '<display_name>',
description: _td('Changes your display nickname in the current room only'),
runFn: function(roomId, args) {
@ -247,9 +286,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
roomavatar: new Command({
name: 'roomavatar',
new Command({
command: 'roomavatar',
args: '[<mxc_url>]',
description: _td('Changes the avatar of the current room'),
runFn: function(roomId, args) {
@ -265,9 +303,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
myroomavatar: new Command({
name: 'myroomavatar',
new Command({
command: 'myroomavatar',
args: '[<mxc_url>]',
description: _td('Changes your avatar in this current room only'),
runFn: function(roomId, args) {
@ -292,9 +329,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
myavatar: new Command({
name: 'myavatar',
new Command({
command: 'myavatar',
args: '[<mxc_url>]',
description: _td('Changes your avatar in all rooms'),
runFn: function(roomId, args) {
@ -310,9 +346,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
topic: new Command({
name: 'topic',
new Command({
command: 'topic',
args: '[<topic>]',
description: _td('Gets or sets the room topic'),
runFn: function(roomId, args) {
@ -321,7 +356,7 @@ export const CommandMap = {
return success(cli.setRoomTopic(roomId, args));
}
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Failed to set topic"));
const topicEvents = room.currentState.getStateEvents('m.room.topic', '');
const topic = topicEvents && topicEvents.getContent().topic;
@ -331,14 +366,14 @@ export const CommandMap = {
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
title: room.name,
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
hasCloseButton: true,
});
return success();
},
category: CommandCategories.admin,
}),
roomname: new Command({
name: 'roomname',
new Command({
command: 'roomname',
args: '<name>',
description: _td('Sets the room name'),
runFn: function(roomId, args) {
@ -349,9 +384,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
invite: new Command({
name: 'invite',
new Command({
command: 'invite',
args: '<user-id>',
description: _td('Invites user with given id to current room'),
runFn: function(roomId, args) {
@ -385,17 +419,20 @@ export const CommandMap = {
button: _t("Continue"),
},
));
finished = finished.then(([useDefault]: any) => {
if (useDefault) {
useDefaultIdentityServer();
return;
}
throw new Error(_t("Use an identity server to invite by email. Manage in Settings."));
});
} else {
return reject(_t("Use an identity server to invite by email. Manage in Settings."));
}
}
const inviter = new MultiInviter(roomId);
return success(finished.then(([useDefault] = []) => {
if (useDefault) {
useDefaultIdentityServer();
} else if (useDefault === false) {
throw new Error(_t("Use an identity server to invite by email. Manage in Settings."));
}
return success(finished.then(() => {
return inviter.invite([address]);
}).then(() => {
if (inviter.getCompletionState(address) !== "invited") {
@ -408,12 +445,12 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
join: new Command({
name: 'join',
new Command({
command: 'join',
aliases: ['j', 'goto'],
args: '<room-alias>',
description: _td('Joins room with given alias'),
runFn: function(roomId, args) {
runFn: function(_, args) {
if (args) {
// Note: we support 2 versions of this command. The first is
// the public-facing one for most users and the other is a
@ -521,9 +558,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
part: new Command({
name: 'part',
new Command({
command: 'part',
args: '[<room-alias>]',
description: _td('Leave room'),
runFn: function(roomId, args) {
@ -569,9 +605,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
kick: new Command({
name: 'kick',
new Command({
command: 'kick',
args: '<user-id> [reason]',
description: _td('Kicks user with given id'),
runFn: function(roomId, args) {
@ -585,10 +620,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
// Ban a user from the room with an optional reason
ban: new Command({
name: 'ban',
new Command({
command: 'ban',
args: '<user-id> [reason]',
description: _td('Bans user with given id'),
runFn: function(roomId, args) {
@ -602,10 +635,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
// Unban a user from ythe room
unban: new Command({
name: 'unban',
new Command({
command: 'unban',
args: '<user-id>',
description: _td('Unbans user with given ID'),
runFn: function(roomId, args) {
@ -620,9 +651,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
ignore: new Command({
name: 'ignore',
new Command({
command: 'ignore',
args: '<user-id>',
description: _td('Ignores a user, hiding their messages from you'),
runFn: function(roomId, args) {
@ -651,9 +681,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
unignore: new Command({
name: 'unignore',
new Command({
command: 'unignore',
args: '<user-id>',
description: _td('Stops ignoring a user, showing their messages going forward'),
runFn: function(roomId, args) {
@ -683,10 +712,8 @@ export const CommandMap = {
},
category: CommandCategories.actions,
}),
// Define the power level of a user
op: new Command({
name: 'op',
new Command({
command: 'op',
args: '<user-id> [<power-level>]',
description: _td('Define the power level of a user'),
runFn: function(roomId, args) {
@ -696,14 +723,15 @@ export const CommandMap = {
if (matches) {
const userId = matches[1];
if (matches.length === 4 && undefined !== matches[3]) {
powerLevel = parseInt(matches[3]);
powerLevel = parseInt(matches[3], 10);
}
if (!isNaN(powerLevel)) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Command failed"));
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
if (!powerLevelEvent.getContent().users[args]) return reject(_t("Could not find user in room"));
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
}
}
@ -712,10 +740,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
// Reset the power level of a user
deop: new Command({
name: 'deop',
new Command({
command: 'deop',
args: '<user-id>',
description: _td('Deops user with given id'),
runFn: function(roomId, args) {
@ -724,9 +750,10 @@ export const CommandMap = {
if (matches) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
if (!room) return reject(_t("Command failed"));
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
if (!powerLevelEvent.getContent().users[args]) return reject(_t("Could not find user in room"));
return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent));
}
}
@ -734,9 +761,8 @@ export const CommandMap = {
},
category: CommandCategories.admin,
}),
devtools: new Command({
name: 'devtools',
new Command({
command: 'devtools',
description: _td('Opens the Developer Tools dialog'),
runFn: function(roomId) {
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
@ -745,31 +771,60 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
addwidget: new Command({
name: 'addwidget',
args: '<url>',
new Command({
command: 'addwidget',
args: '<url | embed code | Jitsi url>',
description: _td('Adds a custom widget by URL to the room'),
runFn: function(roomId, args) {
if (!args || (!args.startsWith("https://") && !args.startsWith("http://"))) {
runFn: function(roomId, widgetUrl) {
if (!widgetUrl) {
return reject(_t("Please supply a widget URL or embed code"));
}
// Try and parse out a widget URL from iframes
if (widgetUrl.toLowerCase().startsWith("<iframe ")) {
// We use parse5, which doesn't render/create a DOM node. It instead runs
// some superfast regex over the text so we don't have to.
const embed = parseHtml(widgetUrl);
if (embed && embed.childNodes && embed.childNodes.length === 1) {
const iframe = embed.childNodes[0];
if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) {
const srcAttr = iframe.attrs.find(a => a.name === 'src');
console.log("Pulling URL out of iframe (embed code)");
widgetUrl = srcAttr.value;
}
}
}
if (!widgetUrl.startsWith("https://") && !widgetUrl.startsWith("http://")) {
return reject(_t("Please supply a https:// or http:// widget URL"));
}
if (WidgetUtils.canUserModifyWidgets(roomId)) {
const userId = MatrixClientPeg.get().getUserId();
const nowMs = (new Date()).getTime();
const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`);
return success(WidgetUtils.setRoomWidget(
roomId, widgetId, "m.custom", args, "Custom Widget", {}));
let type = WidgetType.CUSTOM;
let name = "Custom Widget";
let data = {};
// Make the widget a Jitsi widget if it looks like a Jitsi widget
const jitsiData = Jitsi.getInstance().parsePreferredConferenceUrl(widgetUrl);
if (jitsiData) {
console.log("Making /addwidget widget a Jitsi conference");
type = WidgetType.JITSI;
name = "Jitsi Conference";
data = jitsiData;
widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
}
return success(WidgetUtils.setRoomWidget(roomId, widgetId, type, widgetUrl, name, data));
} else {
return reject(_t("You cannot modify widgets in this room."));
}
},
category: CommandCategories.admin,
}),
// Verify a user, device, and pubkey tuple
verify: new Command({
name: 'verify',
new Command({
command: 'verify',
args: '<user-id> <device-id> <device-signing-key>',
description: _td('Verifies a user, session, and pubkey tuple'),
runFn: function(roomId, args) {
@ -783,7 +838,7 @@ export const CommandMap = {
const fingerprint = matches[3];
return success((async () => {
const device = await cli.getStoredDevice(userId, deviceId);
const device = cli.getStoredDevice(userId, deviceId);
if (!device) {
throw new Error(_t('Unknown (user, session) pair:') + ` (${userId}, ${deviceId})`);
}
@ -834,20 +889,8 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
me: new Command({
name: 'me',
args: '<message>',
description: _td('Displays action'),
category: CommandCategories.messages,
hideCompletionAfterSpace: true,
}),
discardsession: new Command({
name: 'discardsession',
new Command({
command: 'discardsession',
description: _td('Forces the current outbound group session in an encrypted room to be discarded'),
runFn: function(roomId) {
try {
@ -859,9 +902,8 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
rainbow: new Command({
name: "rainbow",
new Command({
command: "rainbow",
description: _td("Sends the given message coloured as a rainbow"),
args: '<message>',
runFn: function(roomId, args) {
@ -870,9 +912,8 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
rainbowme: new Command({
name: "rainbowme",
new Command({
command: "rainbowme",
description: _td("Sends the given emote coloured as a rainbow"),
args: '<message>',
runFn: function(roomId, args) {
@ -881,9 +922,8 @@ export const CommandMap = {
},
category: CommandCategories.messages,
}),
help: new Command({
name: "help",
new Command({
command: "help",
description: _td("Displays list of commands with usages and descriptions"),
runFn: function() {
const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog');
@ -893,28 +933,113 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
};
/* eslint-enable babel/no-invalid-this */
new Command({
command: "whois",
description: _td("Displays information about a user"),
args: "<user-id>",
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage());
}
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
dis.dispatch({
action: 'view_user',
member: member || {userId},
});
return success();
},
category: CommandCategories.advanced,
}),
new Command({
command: "rageshake",
aliases: ["bugreport"],
description: _td("Send a bug report with logs"),
args: "<description>",
runFn: function(roomId, args) {
return success(
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText: args,
sendLogs: true,
}).then(() => {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Rageshake sent', InfoDialog, {
title: _t('Logs sent'),
description: _t('Thank you!'),
});
}),
);
},
category: CommandCategories.advanced,
}),
new Command({
command: "query",
description: _td("Opens chat with the given user"),
args: "<user-id>",
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage());
}
// helpful aliases
const aliases = {
j: "join",
newballsplease: "discardsession",
goto: "join", // because it handles event permalinks magically
roomnick: "myroomnick",
};
return success((async () => {
dis.dispatch({
action: 'view_room',
room_id: await ensureDMExists(MatrixClientPeg.get(), userId),
});
})());
},
category: CommandCategories.actions,
}),
new Command({
command: "msg",
description: _td("Sends a message to the given user"),
args: "<user-id> <message>",
runFn: function(_, args) {
if (args) {
// matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
if (matches) {
const [userId, msg] = matches.slice(1);
if (msg && userId && userId.startsWith("@") && userId.includes(":")) {
return success((async () => {
const cli = MatrixClientPeg.get();
const roomId = await ensureDMExists(cli, userId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
cli.sendTextMessage(roomId, msg);
})());
}
}
}
return reject(this.getUsage());
},
category: CommandCategories.actions,
}),
/**
* Process the given text for /commands and return a bound method to perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
* @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function getCommand(roomId, input) {
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
new Command({
command: "me",
args: '<message>',
description: _td('Displays action'),
category: CommandCategories.messages,
hideCompletionAfterSpace: true,
}),
];
// build a map from names and aliases to the Command objects.
export const CommandMap = new Map();
Commands.forEach(cmd => {
CommandMap.set(cmd.command, cmd);
cmd.aliases.forEach(alias => {
CommandMap.set(alias, cmd);
});
});
export function parseCommandString(input) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
@ -930,10 +1055,21 @@ export function getCommand(roomId, input) {
cmd = input;
}
if (aliases[cmd]) {
cmd = aliases[cmd];
}
if (CommandMap[cmd]) {
return () => CommandMap[cmd].run(roomId, args);
return {cmd, args};
}
/**
* Process the given text for /commands and return a bound method to perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
* @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function getCommand(roomId, input) {
const {cmd, args} = parseCommandString(input);
if (CommandMap.has(cmd)) {
return () => CommandMap.get(cmd).run(roomId, args, cmd);
}
}

View file

@ -127,6 +127,13 @@ function textForRoomNameEvent(ev) {
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName});
}
if (ev.getPrevContent().name) {
return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
senderDisplayName,
oldRoomName: ev.getPrevContent().name,
newRoomName: ev.getContent().name,
});
}
return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
senderDisplayName,
roomName: ev.getContent().name,
@ -269,85 +276,55 @@ function textForMessageEvent(ev) {
return message;
}
function textForRoomAliasesEvent(ev) {
// An alternative implementation of this as a first-class event can be found at
// https://github.com/matrix-org/matrix-react-sdk/blob/dc7212ec2bd12e1917233ed7153b3e0ef529a135/src/components/views/messages/RoomAliasesEvent.js
// This feels a bit overkill though, and it's not clear the i18n really needs it
// so instead it's landing as a simple textual event.
const maxShown = 3;
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAliases = ev.getPrevContent().aliases || [];
const newAliases = ev.getContent().aliases || [];
const addedAliases = newAliases.filter((x) => !oldAliases.includes(x));
const removedAliases = oldAliases.filter((x) => !newAliases.includes(x));
if (!addedAliases.length && !removedAliases.length) {
return '';
}
if (addedAliases.length && !removedAliases.length) {
if (addedAliases.length > maxShown) {
return _t("%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room", {
senderName: senderName,
count: addedAliases.length - maxShown,
addedAddresses: addedAliases.slice(0, maxShown).join(', '),
});
}
return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', {
senderName: senderName,
count: addedAliases.length,
addedAddresses: addedAliases.join(', '),
});
} else if (!addedAliases.length && removedAliases.length) {
if (removedAliases.length > maxShown) {
return _t("%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room", {
senderName: senderName,
count: removedAliases.length - maxShown,
removedAddresses: removedAliases.slice(0, maxShown).join(', '),
});
}
return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
count: removedAliases.length,
removedAddresses: removedAliases.join(', '),
});
} else {
const combined = addedAliases.length + removedAliases.length;
if (combined > maxShown) {
return _t("%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room", {
senderName: senderName,
countAdded: addedAliases.length,
countRemoved: removedAliases.length,
});
}
return _t(
'%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
addedAddresses: addedAliases.join(', '),
removedAddresses: removedAliases.join(', '),
},
);
}
}
function textForCanonicalAliasEvent(ev) {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
const newAlias = ev.getContent().alias;
const newAltAliases = ev.getContent().alt_aliases || [];
const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias));
const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias));
if (newAlias) {
return _t('%(senderName)s set the main address for this room to %(address)s.', {
senderName: senderName,
address: ev.getContent().alias,
});
} else if (oldAlias) {
return _t('%(senderName)s removed the main address for this room.', {
if (!removedAltAliases.length && !addedAltAliases.length) {
if (newAlias) {
return _t('%(senderName)s set the main address for this room to %(address)s.', {
senderName: senderName,
address: ev.getContent().alias,
});
} else if (oldAlias) {
return _t('%(senderName)s removed the main address for this room.', {
senderName: senderName,
});
}
} else if (newAlias === oldAlias) {
if (addedAltAliases.length && !removedAltAliases.length) {
return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: addedAltAliases.join(", "),
count: addedAltAliases.length,
});
} if (removedAltAliases.length && !addedAltAliases.length) {
return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
senderName: senderName,
addresses: removedAltAliases.join(", "),
count: removedAltAliases.length,
});
} if (removedAltAliases.length && addedAltAliases.length) {
return _t('%(senderName)s changed the alternative addresses for this room.', {
senderName: senderName,
});
}
} else {
// both alias and alt_aliases where modified
return _t('%(senderName)s changed the main and alternative addresses for this room.', {
senderName: senderName,
});
}
// in case there is no difference between the two events,
// say something as we can't simply hide the tile from here
return _t('%(senderName)s changed the addresses for this room.', {
senderName: senderName,
});
}
function textForCallAnswerEvent(event) {
@ -612,7 +589,6 @@ const handlers = {
};
const stateHandlers = {
'm.room.aliases': textForRoomAliasesEvent,
'm.room.canonical_alias': textForCanonicalAliasEvent,
'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent,
@ -627,6 +603,7 @@ const stateHandlers = {
'm.room.guest_access': textForGuestAccessEvent,
'm.room.related_groups': textForRelatedGroupsEvent,
// TODO: Enable support for m.widget event type (https://github.com/vector-im/riot-web/issues/13111)
'im.vector.modular.widgets': textForWidgetEvent,
};

View file

@ -27,6 +27,7 @@ import {MatrixClientPeg} from "./MatrixClientPeg";
import SettingsStore from "./settings/SettingsStore";
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetUtils from "./utils/WidgetUtils";
import {KnownWidgetActions} from "./widgets/WidgetApi";
if (!global.mxFromWidgetMessaging) {
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
@ -40,9 +41,18 @@ if (!global.mxToWidgetMessaging) {
const OUTBOUND_API_NAME = 'toWidget';
export default class WidgetMessaging {
constructor(widgetId, widgetUrl, isUserWidget, target) {
/**
* @param {string} widgetId The widget's ID
* @param {string} wurl The raw URL of the widget as in the event (the 'wURL')
* @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL
* or a different URL of the clients choosing if it is using its own impl).
* @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget
* @param {object} target Where widget messages should be sent (eg. the iframe object)
*/
constructor(widgetId, wurl, renderedUrl, isUserWidget, target) {
this.widgetId = widgetId;
this.widgetUrl = widgetUrl;
this.wurl = wurl;
this.renderedUrl = renderedUrl;
this.isUserWidget = isUserWidget;
this.target = target;
this.fromWidget = global.mxFromWidgetMessaging;
@ -75,12 +85,23 @@ export default class WidgetMessaging {
});
}
/**
* Tells the widget that the client is ready to handle further widget requests.
* @returns {Promise<*>} Resolves after the widget has acknowledged the ready message.
*/
flagReadyToContinue() {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.ClientReady,
});
}
/**
* Request a screenshot from a widget
* @return {Promise} To be resolved with screenshot data when it has been generated
*/
getScreenshot() {
console.warn('Requesting screenshot for', this.widgetId);
console.log('Requesting screenshot for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "screenshot",
@ -94,12 +115,12 @@ export default class WidgetMessaging {
* @return {Promise} To be resolved with an array of requested widget capabilities
*/
getCapabilities() {
console.warn('Requesting capabilities for', this.widgetId);
console.log('Requesting capabilities for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "capabilities",
}).then((response) => {
console.warn('Got capabilities for', this.widgetId, response.capabilities);
console.log('Got capabilities for', this.widgetId, response.capabilities);
return response.capabilities;
});
}
@ -116,19 +137,19 @@ export default class WidgetMessaging {
}
start() {
this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl);
this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl);
this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
}
stop() {
this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl);
this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl);
this.fromWidget.removeListener("get_openid", this._onOpenIdRequest);
}
async _onOpenIdRequest(ev, rawEv) {
if (ev.widgetId !== this.widgetId) return; // not interesting
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.widgetUrl, this.isUserWidget);
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget);
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
@ -149,7 +170,7 @@ export default class WidgetMessaging {
// Actually ask for permission to send the user's data
Modal.createTrackedDialog("OpenID widget permissions", '',
WidgetOpenIDPermissionsDialog, {
widgetUrl: this.widgetUrl,
widgetUrl: this.wurl,
widgetId: this.widgetId,
isUserWidget: this.isUserWidget,

View file

@ -0,0 +1,383 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import classNames from "classnames";
import * as sdk from "../index";
import Modal from "../Modal";
import { _t, _td } from "../languageHandler";
import {isMac, Key} from "../Keyboard";
// TS: once languageHandler is TS we can probably inline this into the enum
_td("Navigation");
_td("Calls");
_td("Composer");
_td("Room List");
_td("Autocomplete");
export enum Categories {
NAVIGATION = "Navigation",
CALLS = "Calls",
COMPOSER = "Composer",
ROOM_LIST = "Room List",
ROOM = "Room",
AUTOCOMPLETE = "Autocomplete",
}
// TS: once languageHandler is TS we can probably inline this into the enum
_td("Alt");
_td("Alt Gr");
_td("Shift");
_td("Super");
_td("Ctrl");
export enum Modifiers {
ALT = "Alt", // Option on Mac and displayed as an Icon
ALT_GR = "Alt Gr",
SHIFT = "Shift",
SUPER = "Super", // should this be "Windows"?
// Instead of using below, consider CMD_OR_CTRL
COMMAND = "Command", // This gets displayed as an Icon
CONTROL = "Ctrl",
}
// Meta-modifier: isMac ? CMD : CONTROL
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
interface IKeybind {
modifiers?: Modifiers[];
key: string; // TS: fix this once Key is an enum
}
interface IShortcut {
keybinds: IKeybind[];
description: string;
}
const shortcuts: Record<Categories, IShortcut[]> = {
[Categories.COMPOSER]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.B,
}],
description: _td("Toggle Bold"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.I,
}],
description: _td("Toggle Italics"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.GREATER_THAN,
}],
description: _td("Toggle Quote"),
}, {
keybinds: [{
modifiers: [Modifiers.SHIFT],
key: Key.ENTER,
}],
description: _td("New line"),
}, {
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Navigate recent messages to edit"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.HOME,
}, {
modifiers: [CMD_OR_CTRL],
key: Key.END,
}],
description: _td("Jump to start/end of the composer"),
}, {
keybinds: [{
modifiers: [Modifiers.CONTROL, Modifiers.ALT],
key: Key.ARROW_UP,
}, {
modifiers: [Modifiers.CONTROL, Modifiers.ALT],
key: Key.ARROW_DOWN,
}],
description: _td("Navigate composer history"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Cancel replying to a message"),
},
],
[Categories.CALLS]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.D,
}],
description: _td("Toggle microphone mute"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.E,
}],
description: _td("Toggle video on/off"),
},
],
[Categories.ROOM]: [
{
keybinds: [{
key: Key.PAGE_UP,
}, {
key: Key.PAGE_DOWN,
}],
description: _td("Scroll up/down in the timeline"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Dismiss read marker and jump to bottom"),
}, {
keybinds: [{
modifiers: [Modifiers.SHIFT],
key: Key.PAGE_UP,
}],
description: _td("Jump to oldest unread message"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],
key: Key.U,
}],
description: _td("Upload a file"),
}
],
[Categories.ROOM_LIST]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.K,
}],
description: _td("Jump to room search"),
}, {
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Navigate up/down in the room list"),
}, {
keybinds: [{
key: Key.ENTER,
}],
description: _td("Select room from the room list"),
}, {
keybinds: [{
key: Key.ARROW_LEFT,
}],
description: _td("Collapse room list section"),
}, {
keybinds: [{
key: Key.ARROW_RIGHT,
}],
description: _td("Expand room list section"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Clear room list filter field"),
},
],
[Categories.NAVIGATION]: [
{
keybinds: [{
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
key: Key.ARROW_UP,
}, {
modifiers: [Modifiers.ALT, Modifiers.SHIFT],
key: Key.ARROW_DOWN,
}],
description: _td("Previous/next unread room or DM"),
}, {
keybinds: [{
modifiers: [Modifiers.ALT],
key: Key.ARROW_UP,
}, {
modifiers: [Modifiers.ALT],
key: Key.ARROW_DOWN,
}],
description: _td("Previous/next room or DM"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.BACKTICK,
}],
description: _td("Toggle the top left menu"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Close dialog or context menu"),
}, {
keybinds: [{
key: Key.ENTER,
}, {
key: Key.SPACE,
}],
description: _td("Activate selected button"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.PERIOD,
}],
description: _td("Toggle right panel"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.SLASH,
}],
description: _td("Toggle this dialog"),
},
],
[Categories.AUTOCOMPLETE]: [
{
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Move autocomplete selection up/down"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Cancel autocomplete"),
},
],
};
const categoryOrder = [
Categories.COMPOSER,
Categories.AUTOCOMPLETE,
Categories.ROOM,
Categories.ROOM_LIST,
Categories.NAVIGATION,
Categories.CALLS,
];
interface IModal {
close: () => void;
finished: Promise<any[]>;
}
const modifierIcon: Record<string, string> = {
[Modifiers.COMMAND]: "⌘",
};
if (isMac) {
modifierIcon[Modifiers.ALT] = "⌥";
}
const alternateKeyName: Record<string, string> = {
[Key.PAGE_UP]: _td("Page Up"),
[Key.PAGE_DOWN]: _td("Page Down"),
[Key.ESCAPE]: _td("Esc"),
[Key.ENTER]: _td("Enter"),
[Key.SPACE]: _td("Space"),
[Key.HOME]: _td("Home"),
[Key.END]: _td("End"),
};
const keyIcon: Record<string, string> = {
[Key.ARROW_UP]: "↑",
[Key.ARROW_DOWN]: "↓",
[Key.ARROW_LEFT]: "←",
[Key.ARROW_RIGHT]: "→",
};
const Shortcut: React.FC<{
shortcut: IShortcut;
}> = ({shortcut}) => {
const classes = classNames({
"mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0),
});
return <div className={classes}>
<h5>{ _t(shortcut.description) }</h5>
{ shortcut.keybinds.map(s => {
let text = s.key;
if (alternateKeyName[s.key]) {
text = _t(alternateKeyName[s.key]);
} else if (keyIcon[s.key]) {
text = keyIcon[s.key];
}
return <div key={s.key}>
{ s.modifiers && s.modifiers.map(m => {
return <React.Fragment key={m}>
<kbd>{ modifierIcon[m] || _t(m) }</kbd>+
</React.Fragment>;
}) }
<kbd>{ text }</kbd>
</div>;
}) }
</div>;
};
let activeModal: IModal = null;
export const toggleDialog = () => {
if (activeModal) {
activeModal.close();
activeModal = null;
return;
}
const sections = categoryOrder.map(category => {
const list = shortcuts[category];
return <div className="mx_KeyboardShortcutsDialog_category" key={category}>
<h3>{_t(category)}</h3>
<div>{list.map(shortcut => <Shortcut key={shortcut.description} shortcut={shortcut} />)}</div>
</div>;
});
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, {
className: "mx_KeyboardShortcutsDialog",
title: _t("Keyboard Shortcuts"),
description: sections,
hasCloseButton: true,
onKeyDown: (ev) => {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.SLASH) { // Ctrl + /
ev.stopPropagation();
activeModal.close();
}
},
onFinished: () => {
activeModal = null;
},
});
};
export const registerShortcut = (category: Categories, defn: IShortcut) => {
shortcuts[category].push(defn);
};

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import { asyncAction } from './actionCreators';
import RoomListStore from '../stores/RoomListStore';
import RoomListStore, {TAG_DM} from '../stores/RoomListStore';
import Modal from '../Modal';
import * as Rooms from '../Rooms';
import { _t } from '../languageHandler';
@ -73,11 +73,11 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
const roomId = room.roomId;
// Evil hack to get DMs behaving
if ((oldTag === undefined && newTag === 'im.vector.fake.direct') ||
(oldTag === 'im.vector.fake.direct' && newTag === undefined)
if ((oldTag === undefined && newTag === TAG_DM) ||
(oldTag === TAG_DM && newTag === undefined)
) {
return Rooms.guessAndSetDMRoom(
room, newTag === 'im.vector.fake.direct',
room, newTag === TAG_DM,
).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set direct chat tag " + err);
@ -91,10 +91,10 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
const hasChangedSubLists = oldTag !== newTag;
// More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with 'im.vector.fake.direct`.
// but we avoid ever doing a request with TAG_DM.
//
// if we moved lists, remove the old tag
if (oldTag && oldTag !== 'im.vector.fake.direct' &&
if (oldTag && oldTag !== TAG_DM &&
hasChangedSubLists
) {
const promiseToDelete = matrixClient.deleteRoomTag(
@ -112,7 +112,7 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
}
// if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== 'im.vector.fake.direct' &&
if (newTag && newTag !== TAG_DM &&
(hasChangedSubLists || metaData)
) {
// metaData is the body of the PUT to set the tag, so it must

View file

@ -37,7 +37,7 @@ export default createReactClass({
return { device: null };
},
componentWillMount: function() {
componentDidMount: function() {
this._unmounted = false;
const client = MatrixClientPeg.get();
@ -79,7 +79,7 @@ export default createReactClass({
},
onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.event.getSender()) {
if (userId === this.props.event.getSender()) {
this.refreshDevice().then((dev) => {
this.setState({ device: dev });
});

View file

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

View file

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

View file

@ -30,7 +30,7 @@ import EventIndexPeg from "../../../../indexing/EventIndexPeg";
export default class ManageEventIndexDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
};
constructor(props) {
super(props);
@ -48,7 +48,16 @@ export default class ManageEventIndexDialog extends React.Component {
updateCurrentRoom = async (room) => {
const eventIndex = EventIndexPeg.get();
const stats = await eventIndex.getStats();
let stats;
try {
stats = await eventIndex.getStats();
} catch {
// This call may fail if sporadically, not a huge issue as we will
// try later again and probably succeed.
return;
}
let currentRoom = null;
if (room) currentRoom = room.name;
@ -73,7 +82,7 @@ export default class ManageEventIndexDialog extends React.Component {
}
}
async componentWillMount(): void {
async componentDidMount(): void {
let eventIndexSize = 0;
let crawlingRoomsCount = 0;
let roomCount = 0;
@ -85,12 +94,19 @@ export default class ManageEventIndexDialog extends React.Component {
if (eventIndex !== null) {
eventIndex.on("changedCheckpoint", this.updateCurrentRoom);
const stats = await eventIndex.getStats();
try {
const stats = await eventIndex.getStats();
eventIndexSize = stats.size;
eventCount = stats.eventCount;
} catch {
// This call may fail if sporadically, not a huge issue as we
// will try later again in the updateCurrentRoom call and
// probably succeed.
}
const roomStats = eventIndex.crawlingRooms();
eventIndexSize = stats.size;
crawlingRoomsCount = roomStats.crawlingRooms.size;
roomCount = roomStats.totalRooms.size;
eventCount = stats.eventCount;
const room = eventIndex.currentRoom();
if (room) currentRoom = room.name;
@ -110,30 +126,28 @@ export default class ManageEventIndexDialog extends React.Component {
import("./DisableEventIndexDialog"),
null, null, /* priority = */ false, /* static = */ true,
);
}
_onDone = () => {
this.props.onFinished(true);
}
};
_onCrawlerSleepTimeChange = (e) => {
this.setState({crawlerSleepTime: e.target.value});
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
}
};
render() {
let crawlerState;
if (this.state.currentRoom === null) {
crawlerState = _t("Not currently downloading messages for any room.");
crawlerState = _t("Not currently indexing messages for any room.");
} else {
crawlerState = (
_t("Downloading mesages for %(currentRoom)s.", { currentRoom: this.state.currentRoom })
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
);
}
const Field = sdk.getComponent('views.elements.Field');
const doneRooms = Math.max(0, (this.state.roomCount - this.state.crawlingRoomsCount));
const eventIndexingSettings = (
<div>
{
@ -142,15 +156,14 @@ export default class ManageEventIndexDialog extends React.Component {
)
}
<div className='mx_SettingsTab_subsectionText'>
{crawlerState}<br />
{_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}<br />
{_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}<br />
{_t("Indexed rooms:")} {_t("%(crawlingRooms)s out of %(totalRooms)s", {
crawlingRooms: formatCountLong(this.state.crawlingRoomsCount),
{_t("Indexed rooms:")} {_t("%(doneRooms)s out of %(totalRooms)s", {
doneRooms: formatCountLong(doneRooms),
totalRooms: formatCountLong(this.state.roomCount),
})} <br />
{crawlerState}<br />
<Field
id={"crawlerSleepTimeMs"}
label={_t('Message downloading sleep time(ms)')}
type='number'
value={this.state.crawlerSleepTime}

View file

@ -25,6 +25,7 @@ import { _t } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager';
import SettingsStore from '../../../../settings/SettingsStore';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import {copyNode} from "../../../../utils/strings";
const PHASE_PASSPHRASE = 0;
const PHASE_PASSPHRASE_CONFIRM = 1;
@ -37,23 +38,12 @@ const PHASE_OPTOUT_CONFIRM = 6;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
// XXX: copied from ShareDialog: factor out into utils
function selectText(target) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/*
* Walks the user through the process of creating an e2e key backup
* on the server.
*/
export default class CreateKeyBackupDialog extends React.PureComponent {
static propTypes = {
secureSecretStorage: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
}
@ -65,7 +55,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
this._setZxcvbnResultTimeout = null;
this.state = {
secureSecretStorage: props.secureSecretStorage,
secureSecretStorage: null,
phase: PHASE_PASSPHRASE,
passPhrase: '',
passPhraseConfirm: '',
@ -73,23 +63,20 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
downloaded: false,
zxcvbnResult: null,
};
if (this.state.secureSecretStorage === undefined) {
this.state.secureSecretStorage =
SettingsStore.isFeatureEnabled("feature_cross_signing");
}
// If we're using secret storage, skip ahead to the backing up step, as
// `accessSecretStorage` will handle passphrases as needed.
if (this.state.secureSecretStorage) {
this.state.phase = PHASE_BACKINGUP;
}
}
componentDidMount() {
async componentDidMount() {
const cli = MatrixClientPeg.get();
const secureSecretStorage = (
SettingsStore.getValue("feature_cross_signing") &&
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
);
this.setState({ secureSecretStorage });
// If we're using secret storage, skip ahead to the backing up step, as
// `accessSecretStorage` will handle passphrases as needed.
if (this.state.secureSecretStorage) {
if (secureSecretStorage) {
this.setState({ phase: PHASE_BACKINGUP });
this._createBackup();
}
}
@ -105,8 +92,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
}
_onCopyClick = () => {
selectText(this._recoveryKeyNode);
const successful = document.execCommand('copy');
const successful = copyNode(this._recoveryKeyNode);
if (successful) {
this.setState({
copied: true,
@ -276,7 +262,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
let helpText;
if (this.state.zxcvbnResult) {
if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) {
helpText = _t("Great! This passphrase looks strong enough.");
helpText = _t("Great! This recovery passphrase looks strong enough.");
} else {
const suggestions = [];
for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) {
@ -301,7 +287,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
)}</p>
<p>{_t(
"We'll store an encrypted copy of your keys on our server. " +
"Protect your backup with a passphrase to keep it secure.",
"Secure your backup with a recovery passphrase.",
)}</p>
<p>{_t("For maximum security, this should be different from your account password.")}</p>
@ -311,7 +297,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
onChange={this._onPassPhraseChange}
value={this.state.passPhrase}
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Enter a passphrase...")}
placeholder={_t("Enter a recovery passphrase...")}
autoFocus={true}
/>
<div className="mx_CreateKeyBackupDialog_passPhraseHelp">
@ -368,7 +354,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t(
"Please enter your passphrase a second time to confirm.",
"Please enter your recovery passphrase a second time to confirm.",
)}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
@ -377,7 +363,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
onChange={this._onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm}
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Repeat your passphrase...")}
placeholder={_t("Repeat your recovery passphrase...")}
autoFocus={true}
/>
</div>
@ -397,7 +383,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
return <div>
<p>{_t(
"Your recovery key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your passphrase.",
"access to your encrypted messages if you forget your recovery passphrase.",
)}</p>
<p>{_t(
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
@ -491,9 +477,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_titleForPhase(phase) {
switch (phase) {
case PHASE_PASSPHRASE:
return _t('Secure your backup with a passphrase');
return _t('Secure your backup with a recovery passphrase');
case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm your passphrase');
return _t('Confirm your recovery passphrase');
case PHASE_OPTOUT_CONFIRM:
return _t('Warning!');
case PHASE_SHOWKEY:

View file

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

View file

@ -23,30 +23,24 @@ import { scorePassword } from '../../../../utils/PasswordScorer';
import FileSaver from 'file-saver';
import { _t } from '../../../../languageHandler';
import Modal from '../../../../Modal';
import { promptForBackupPassphrase } from '../../../../CrossSigningManager';
import {copyNode} from "../../../../utils/strings";
import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents";
const PHASE_LOADING = 0;
const PHASE_MIGRATE = 1;
const PHASE_PASSPHRASE = 2;
const PHASE_PASSPHRASE_CONFIRM = 3;
const PHASE_SHOWKEY = 4;
const PHASE_KEEPITSAFE = 5;
const PHASE_STORING = 6;
const PHASE_DONE = 7;
const PHASE_CONFIRM_SKIP = 8;
const PHASE_LOADERROR = 1;
const PHASE_MIGRATE = 2;
const PHASE_PASSPHRASE = 3;
const PHASE_PASSPHRASE_CONFIRM = 4;
const PHASE_SHOWKEY = 5;
const PHASE_KEEPITSAFE = 6;
const PHASE_STORING = 7;
const PHASE_DONE = 8;
const PHASE_CONFIRM_SKIP = 9;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
// XXX: copied from ShareDialog: factor out into utils
function selectText(target) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/*
* Walks the user through the process of creating a passphrase to guard Secure
* Secret Storage in account data.
@ -66,10 +60,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
constructor(props) {
super(props);
this._keyInfo = null;
this._encodedRecoveryKey = null;
this._recoveryKey = null;
this._recoveryKeyNode = null;
this._setZxcvbnResultTimeout = null;
this._backupKey = null;
this.state = {
phase: PHASE_LOADING,
@ -83,14 +77,22 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
// does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload?
canUploadKeysWithPasswordOnly: null,
accountPassword: props.accountPassword,
accountPassword: props.accountPassword || "",
accountPasswordCorrect: null,
// status of the key backup toggle switch
useKeyBackup: true,
};
this._fetchBackupInfo();
this._queryKeyUploadAuth();
if (this.state.accountPassword) {
// If we have an account password in memory, let's simplify and
// assume it means password auth is also supported for device
// signing key upload as well. This avoids hitting the server to
// test auth flows, which may be slow under high load.
this.state.canUploadKeysWithPasswordOnly = true;
} else {
this._queryKeyUploadAuth();
}
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
}
@ -103,20 +105,29 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
async _fetchBackupInfo() {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = (
// we may not have started crypto yet, in which case we definitely don't trust the backup
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
);
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = (
// we may not have started crypto yet, in which case we definitely don't trust the backup
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
);
const { force } = this.props;
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE;
const { force } = this.props;
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE;
this.setState({
phase,
backupInfo,
backupSigStatus,
});
this.setState({
phase,
backupInfo,
backupSigStatus,
});
return {
backupInfo,
backupSigStatus,
};
} catch (e) {
this.setState({phase: PHASE_LOADERROR});
}
}
async _queryKeyUploadAuth() {
@ -127,8 +138,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
// no keys which would be a no-op.
console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
} catch (error) {
if (!error.data.flows) {
if (!error.data || !error.data.flows) {
console.log("uploadDeviceSigningKeys advertised no flows!");
return;
}
const canUploadKeysWithPasswordOnly = error.data.flows.some(f => {
return f.stages.length === 1 && f.stages[0] === 'm.login.password';
@ -163,8 +175,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_onCopyClick = () => {
selectText(this._recoveryKeyNode);
const successful = document.execCommand('copy');
const successful = copyNode(this._recoveryKeyNode);
if (successful) {
this.setState({
copied: true,
@ -174,7 +185,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_onDownloadClick = () => {
const blob = new Blob([this._encodedRecoveryKey], {
const blob = new Blob([this._recoveryKey.encodedPrivateKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
@ -199,12 +210,32 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
});
} else {
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("To continue, use Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm encryption setup"),
body: _t("Click the button below to confirm setting up encryption."),
continueText: _t("Confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createTrackedDialog(
'Cross-signing keys dialog', '', InteractiveAuthDialog,
{
title: _t("Setting up keys"),
matrixClient: MatrixClientPeg.get(),
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
},
);
const [confirmed] = await finished;
@ -226,18 +257,35 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
try {
if (force) {
console.log("Forcing secret storage reset"); // log something so we can debug this later
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._keyInfo,
setupNewKeyBackup: true,
createSecretStorageKey: async () => this._recoveryKey,
setupNewKeyBackup: this.state.useKeyBackup,
setupNewSecretStorage: true,
});
if (!this.state.useKeyBackup && this.state.backupInfo) {
// If the user is resetting their cross-signing keys and doesn't want
// key backup (but had it enabled before), delete the key backup as it's
// no longer valid.
console.log("Deleting invalid key backup (secrets have been reset; key backup not requested)");
await cli.deleteKeyBackupVersion(this.state.backupInfo.version);
}
} else {
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._keyInfo,
createSecretStorageKey: async () => this._recoveryKey,
keyBackupInfo: this.state.backupInfo,
setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup,
getKeyBackupPassphrase: () => {
// We may already have the backup key if we earlier went
// through the restore backup path, so pass it along
// rather than prompting again.
if (this._backupKey) {
return this._backupKey;
}
return promptForBackupPassphrase();
},
});
}
this.setState({
@ -266,16 +314,24 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_restoreBackup = async () => {
// It's possible we'll need the backup key later on for bootstrapping,
// so let's stash it here, rather than prompting for it twice.
const keyCallback = k => this._backupKey = k;
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
const { finished } = Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null,
/* priority = */ false, /* static = */ true,
'Restore Backup', '', RestoreKeyBackupDialog,
{
showSummary: false,
keyCallback,
},
null, /* priority = */ false, /* static = */ false,
);
await finished;
await this._fetchBackupInfo();
const { backupSigStatus } = await this._fetchBackupInfo();
if (
this.state.backupSigStatus.usable &&
backupSigStatus.usable &&
this.state.canUploadKeysWithPasswordOnly &&
this.state.accountPassword
) {
@ -283,6 +339,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
}
_onLoadRetryClick = () => {
this.setState({phase: PHASE_LOADING});
this._fetchBackupInfo();
}
_onSkipSetupClick = () => {
this.setState({phase: PHASE_CONFIRM_SKIP});
}
@ -292,10 +353,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}
_onSkipPassPhraseClick = async () => {
const [keyInfo, encodedRecoveryKey] =
this._recoveryKey =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase();
this._keyInfo = keyInfo;
this._encodedRecoveryKey = encodedRecoveryKey;
this.setState({
copied: false,
downloaded: false,
@ -328,10 +387,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
if (this.state.passPhrase !== this.state.passPhraseConfirm) return;
const [keyInfo, encodedRecoveryKey] =
this._recoveryKey =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase);
this._keyInfo = keyInfo;
this._encodedRecoveryKey = encodedRecoveryKey;
this.setState({
copied: false,
downloaded: false,
@ -400,17 +457,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
let authPrompt;
let nextCaption = _t("Next");
if (!this.state.backupSigStatus.usable) {
authPrompt = <div>
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
</div>;
nextCaption = _t("Restore");
} else if (this.state.canUploadKeysWithPasswordOnly) {
if (this.state.canUploadKeysWithPasswordOnly) {
authPrompt = <div>
<div>{_t("Enter your account password to confirm the upgrade:")}</div>
<div><Field
type="password"
id="mx_CreateSecretStorage_accountPassword"
label={_t("Password")}
value={this.state.accountPassword}
onChange={this._onAccountPasswordChange}
@ -418,6 +469,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
autoFocus={true}
/></div>
</div>;
} else if (!this.state.backupSigStatus.usable) {
authPrompt = <div>
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
</div>;
nextCaption = _t("Restore");
} else {
authPrompt = <p>
{_t("You'll need to authenticate with the server to confirm the upgrade.")}
@ -433,6 +489,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div>{authPrompt}</div>
<DialogButtons
primaryButton={nextCaption}
onPrimaryButtonClick={this._onMigrateFormSubmit}
hasCancel={false}
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
>
@ -453,7 +510,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
let helpText;
if (this.state.zxcvbnResult) {
if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) {
helpText = _t("Great! This passphrase looks strong enough.");
helpText = _t("Great! This recovery passphrase looks strong enough.");
} else {
// We take the warning from zxcvbn or failing that, the first
// suggestion. In practice The first is generally the most relevant
@ -478,22 +535,17 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
return <form onSubmit={this._onPassPhraseNextClick}>
<p>{_t(
"Set up encryption on this session to allow it to verify other sessions, " +
"granting them access to encrypted messages and marking them as trusted for other users.",
)}</p>
<p>{_t(
"Secure your encryption keys with a passphrase. For maximum security " +
"this should be different to your account password:",
"Set a recovery passphrase to secure encrypted information and recover it if you log out. " +
"This should be different to your account password:",
)}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field
type="password"
id="mx_CreateSecretStorageDialog_passPhraseField"
className="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseChange}
value={this.state.passPhrase}
label={_t("Enter a passphrase")}
label={_t("Enter a recovery passphrase")}
autoFocus={true}
autoComplete="new-password"
/>
@ -504,7 +556,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div>
<LabelledToggleSwitch
label={ _t("Back up my encryption keys, securing them with the same passphrase")}
label={ _t("Back up encrypted message keys")}
onChange={this._onUseKeyBackupChange} value={this.state.useKeyBackup}
/>
@ -561,16 +613,15 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t(
"Enter your passphrase a second time to confirm it.",
"Enter your recovery passphrase a second time to confirm it.",
)}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field
type="password"
id="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm}
className="mx_CreateSecretStorageDialog_passPhraseField"
label={_t("Confirm your passphrase")}
label={_t("Confirm your recovery passphrase")}
autoFocus={true}
autoComplete="new-password"
/>
@ -597,7 +648,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
return <div>
<p>{_t(
"Your recovery key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your passphrase.",
"access to your encrypted messages if you forget your recovery passphrase.",
)}</p>
<p>{_t(
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
@ -608,10 +659,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKey">
<code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code>
<code ref={this._collectRecoveryKeyNode}>{this._recoveryKey.encodedPrivateKey}</code>
</div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
<AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onCopyClick}>
<AccessibleButton
kind='primary'
className="mx_Dialog_primary mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn"
onClick={this._onCopyClick}
>
{_t("Copy")}
</AccessibleButton>
<AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onDownloadClick}>
@ -659,6 +714,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div>;
}
_renderPhaseLoadError() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
<p>{_t("Unable to query secret storage status")}</p>
<div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._onLoadRetryClick}
hasCancel={true}
onCancel={this._onCancel}
/>
</div>
</div>;
}
_renderPhaseDone() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
@ -696,7 +765,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
case PHASE_PASSPHRASE:
return _t('Set up encryption');
case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm passphrase');
return _t('Confirm recovery passphrase');
case PHASE_CONFIRM_SKIP:
return _t('Are you sure?');
case PHASE_SHOWKEY:
@ -732,6 +801,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
case PHASE_LOADING:
content = this._renderBusyPhase();
break;
case PHASE_LOADERROR:
content = this._renderPhaseLoadError();
break;
case PHASE_MIGRATE:
content = this._renderPhaseMigrate();
break;

View file

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

View file

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

View file

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

View file

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

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
/* These were earlier stateless functional components but had to be converted
@ -24,7 +23,14 @@ something that is not entirely possible with stateless functional components. On
presumably wrap them in a <div> before rendering but I think this is the better way to do it.
*/
export class TextualCompletion extends React.Component {
interface ITextualCompletionProps {
title?: string;
subtitle?: string;
description?: string;
className?: string;
}
export class TextualCompletion extends React.PureComponent<ITextualCompletionProps> {
render() {
const {
title,
@ -42,14 +48,16 @@ export class TextualCompletion extends React.Component {
);
}
}
TextualCompletion.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
className: PropTypes.string,
};
export class PillCompletion extends React.Component {
interface IPillCompletionProps {
title?: string;
subtitle?: string;
description?: string;
initialComponent?: React.ReactNode,
className?: string;
}
export class PillCompletion extends React.PureComponent<IPillCompletionProps> {
render() {
const {
title,
@ -69,10 +77,3 @@ export class PillCompletion extends React.Component {
);
}
}
PillCompletion.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
initialComponent: PropTypes.element,
className: PropTypes.string,
};

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
@ -26,6 +25,13 @@ function stripDiacritics(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
funcs?: Array<(T) => string>;
shouldMatchWordsOnly?: boolean;
shouldMatchPrefix?: boolean;
}
/**
* Simple search matcher that matches any results with the query string anywhere
* in the search string. Returns matches in the order the query string appears
@ -39,8 +45,13 @@ function stripDiacritics(str: string): string {
* @param {function[]} options.funcs List of functions that when called with the
* object as an arg will return a string to use as an index
*/
export default class QueryMatcher {
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
export default class QueryMatcher<T> {
private _options: IOptions<T>;
private _keys: IOptions<T>["keys"];
private _funcs: Required<IOptions<T>["funcs"]>;
private _items: Map<string, T[]>;
constructor(objects: T[], options: IOptions<T> = { keys: [] }) {
this._options = options;
this._keys = options.keys;
this._funcs = options.funcs || [];
@ -60,7 +71,7 @@ export default class QueryMatcher {
}
}
setObjects(objects: Array<Object>) {
setObjects(objects: T[]) {
this._items = new Map();
for (const object of objects) {
@ -81,7 +92,7 @@ export default class QueryMatcher {
}
}
match(query: String): Array<Object> {
match(query: string): T[] {
query = stripDiacritics(query).toLowerCase();
if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');

View file

@ -18,20 +18,20 @@ limitations under the License.
*/
import React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
const ROOM_REGEX = /\B#\S*/g;
function score(query, space) {
function score(query: string, space: string) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
@ -40,15 +40,25 @@ function score(query, space) {
}
}
function matcherObject(room: Room, displayedAlias: string, matchName = "") {
return {
room,
matchName,
displayedAlias,
};
}
export default class RoomProvider extends AutocompleteProvider {
matcher: QueryMatcher<Room>;
constructor() {
super(ROOM_REGEX);
this.matcher = new QueryMatcher([], {
keys: ['displayedAlias', 'name'],
keys: ['displayedAlias', 'matchName'],
});
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();
@ -56,16 +66,16 @@ export default class RoomProvider extends AutocompleteProvider {
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
let matcherObjects = client.getVisibleRooms().filter(
(room) => !!room && !!getDisplayAliasForRoom(room),
).map((room) => {
return {
room: room,
name: room.name,
displayedAlias: getDisplayAliasForRoom(room),
};
});
let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => {
if (room.getCanonicalAlias()) {
aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name));
}
if (room.getAltAliases().length) {
const altAliases = room.getAltAliases().map(alias => matcherObject(room, alias));
aliases = aliases.concat(altAliases);
}
return aliases;
}, []);
// Filter out any matches where the user will have also autocompleted new rooms
matcherObjects = matcherObjects.filter((r) => {
const tombstone = r.room.currentState.getStateEvents("m.room.tombstone", "");
@ -84,16 +94,16 @@ export default class RoomProvider extends AutocompleteProvider {
completions = _sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
]).map((room) => {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
]);
completions = completions.map((room) => {
return {
completion: displayAlias,
completionId: displayAlias,
completion: room.displayedAlias,
completionId: room.room.roomId,
type: "room",
suffix: ' ',
href: makeRoomPermalink(displayAlias),
href: makeRoomPermalink(room.displayedAlias),
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.room.name} description={room.displayedAlias} />
),
range,
};
@ -108,7 +118,7 @@ export default class RoomProvider extends AutocompleteProvider {
return '💬 ' + _t('Rooms');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

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

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,93 +17,10 @@ limitations under the License.
import React from "react";
// derived from code from github.com/noeldelgado/gemini-scrollbar
// Copyright (c) Noel Delgado <pixelia.me@gmail.com> (pixelia.me)
function getScrollbarWidth(alternativeOverflow) {
const div = document.createElement('div');
div.className = 'mx_AutoHideScrollbar'; //to get width of css scrollbar
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.width = '100px';
div.style.height = '100px';
div.style.overflow = "scroll";
if (alternativeOverflow) {
div.style.overflow = alternativeOverflow;
}
div.style.msOverflowStyle = '-ms-autohiding-scrollbar';
document.body.appendChild(div);
const scrollbarWidth = (div.offsetWidth - div.clientWidth);
document.body.removeChild(div);
return scrollbarWidth;
}
function install() {
const scrollbarWidth = getScrollbarWidth();
if (scrollbarWidth !== 0) {
const hasForcedOverlayScrollbar = getScrollbarWidth('overlay') === 0;
// overflow: overlay on webkit doesn't auto hide the scrollbar
if (hasForcedOverlayScrollbar) {
document.body.classList.add("mx_scrollbar_overlay_noautohide");
} else {
document.body.classList.add("mx_scrollbar_nooverlay");
const style = document.createElement('style');
style.type = 'text/css';
style.innerText =
`body.mx_scrollbar_nooverlay { --scrollbar-width: ${scrollbarWidth}px; }`;
document.head.appendChild(style);
}
}
}
const installBodyClassesIfNeeded = (function() {
let installed = false;
return function() {
if (!installed) {
install();
installed = true;
}
};
})();
export default class AutoHideScrollbar extends React.Component {
constructor(props) {
super(props);
this.onOverflow = this.onOverflow.bind(this);
this.onUnderflow = this.onUnderflow.bind(this);
this._collectContainerRef = this._collectContainerRef.bind(this);
this._needsOverflowListener = null;
}
onOverflow() {
this.containerRef.classList.add("mx_AutoHideScrollbar_overflow");
this.containerRef.classList.remove("mx_AutoHideScrollbar_underflow");
}
onUnderflow() {
this.containerRef.classList.remove("mx_AutoHideScrollbar_overflow");
this.containerRef.classList.add("mx_AutoHideScrollbar_underflow");
}
checkOverflow() {
if (!this._needsOverflowListener) {
return;
}
if (this.containerRef.scrollHeight > this.containerRef.clientHeight) {
this.onOverflow();
} else {
this.onUnderflow();
}
}
componentDidUpdate() {
this.checkOverflow();
}
componentDidMount() {
installBodyClassesIfNeeded();
this._needsOverflowListener =
document.body.classList.contains("mx_scrollbar_nooverlay");
this.checkOverflow();
}
_collectContainerRef(ref) {
@ -126,9 +44,7 @@ export default class AutoHideScrollbar extends React.Component {
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
>
<div className="mx_AutoHideScrollbar_offset">
{ this.props.children }
</div>
{ this.props.children }
</div>);
}
}

View file

@ -245,7 +245,6 @@ export class ContextMenu extends React.Component {
}
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const padding = 10;
const chevronOffset = {};
if (props.chevronFace) {
@ -255,7 +254,7 @@ export class ContextMenu extends React.Component {
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else {
} else if (position.top !== undefined) {
const target = position.top;
// By default, no adjustment is made
@ -264,7 +263,8 @@ export class ContextMenu extends React.Component {
// If we know the dimensions of the context menu, adjust its position
// such that it does not leave the (padded) window.
if (contextMenuRect) {
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
const padding = 10;
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding);
}
position.top = adjusted;
@ -350,7 +350,7 @@ export const ContextMenuButton = ({ label, isExpanded, children, ...props }) =>
};
ContextMenuButton.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string.isRequired,
label: PropTypes.string,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
};
@ -377,7 +377,6 @@ export const MenuGroup = ({children, label, ...props}) => {
</div>;
};
MenuGroup.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string.isRequired,
className: PropTypes.string, // optional
};

View file

@ -30,7 +30,7 @@ class CustomRoomTagPanel extends React.Component {
};
}
componentWillMount() {
componentDidMount() {
this._tagStoreToken = CustomRoomTagStore.addListener(() => {
this.setState({tags: CustomRoomTagStore.getSortedTags()});
});

View file

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

View file

@ -39,6 +39,7 @@ import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Perm
import {Group} from "matrix-js-sdk";
import {allSettled, sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -423,28 +424,35 @@ export default createReactClass({
membershipBusy: false,
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
};
},
componentWillMount: function() {
componentDidMount: function() {
this._unmounted = false;
this._matrixClient = MatrixClientPeg.get();
this._matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this._changeAvatarComponent = null;
this._initGroupStore(this.props.groupId, true);
this._dispatcherRef = dis.register(this._onAction);
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
},
componentWillUnmount: function() {
this._unmounted = true;
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
dis.unregister(this._dispatcherRef);
// Remove RightPanelStore listener
if (this._rightPanelStoreToken) {
this._rightPanelStoreToken.remove();
}
},
componentWillReceiveProps: function(newProps) {
if (this.props.groupId != newProps.groupId) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
if (this.props.groupId !== newProps.groupId) {
this.setState({
summary: null,
error: null,
@ -454,6 +462,12 @@ export default createReactClass({
}
},
_onRightPanelStoreUpdate: function() {
this.setState({
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
});
},
_onGroupMyMembership: function(group) {
if (this._unmounted || group.groupId !== this.props.groupId) return;
if (group.myMembership === 'leave') {
@ -481,7 +495,7 @@ export default createReactClass({
group_id: groupId,
},
});
dis.dispatch({action: 'require_registration'});
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}});
willDoOnboarding = true;
}
if (stateKey === GroupStore.STATE_KEY.Summary) {
@ -554,10 +568,6 @@ export default createReactClass({
GROUP_JOINPOLICY_INVITE,
},
});
dis.dispatch({
action: 'panel_disable',
sideDisabled: true,
});
},
_onShareClick: function() {
@ -580,10 +590,6 @@ export default createReactClass({
profileForm: null,
});
break;
case 'after_right_panel_phase_change':
// We don't keep state on the right panel, so just re-render to update
this.forceUpdate();
break;
default:
break;
}
@ -726,7 +732,7 @@ export default createReactClass({
_onJoinClick: async function() {
if (this._matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
return;
}
@ -821,10 +827,10 @@ export default createReactClass({
{_t(
"Want more than a community? <a>Get your own server</a>", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener">{sub}</a>,
a: sub => <a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener">
<a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">
<img src={require("../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;
@ -1173,7 +1179,6 @@ export default createReactClass({
render: function() {
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Spinner = sdk.getComponent("elements.Spinner");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
return <Spinner />;
@ -1299,9 +1304,7 @@ export default createReactClass({
);
}
const rightPanel = RightPanelStore.getSharedInstance().isOpenForGroup
? <RightPanel groupId={this.props.groupId} />
: undefined;
const rightPanel = this.state.showRightPanel ? <RightPanel groupId={this.props.groupId} /> : undefined;
const headerClasses = {
"mx_GroupView_header": true,
@ -1332,10 +1335,10 @@ export default createReactClass({
<GroupHeaderButtons />
</div>
<MainSplit panel={rightPanel}>
<GeminiScrollbarWrapper className="mx_GroupView_body">
<AutoHideScrollbar className="mx_GroupView_body">
{ this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper>
</AutoHideScrollbar>
</MainSplit>
</main>
);

View file

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

View file

@ -66,6 +66,22 @@ export default class IndicatorScrollbar extends React.Component {
this._autoHideScrollbar = autoHideScrollbar;
}
componentDidUpdate(prevProps) {
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
const curLen = this.props.children && this.props.children.length || 0;
// check overflow only if amount of children changes.
// if we don't guard here, we end up with an infinite
// render > componentDidUpdate > checkOverflow > setState > render loop
if (prevLen !== curLen) {
this.checkOverflow();
}
}
componentDidMount() {
this.checkOverflow();
}
checkOverflow() {
const hasTopOverflow = this._scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight >
@ -95,10 +111,6 @@ export default class IndicatorScrollbar extends React.Component {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
}
if (this._autoHideScrollbar) {
this._autoHideScrollbar.checkOverflow();
}
if (this.props.trackHorizontalOverflow) {
this.setState({
// Offset from absolute position of the container

View file

@ -1,6 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -24,6 +24,8 @@ import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryCom
import * as sdk from '../../index';
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
export default createReactClass({
displayName: 'InteractiveAuth',
@ -47,7 +49,7 @@ export default createReactClass({
// @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object
// if successful, otherwise the error object.
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
@ -75,6 +77,15 @@ export default createReactClass({
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged: PropTypes.bool,
// Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric).
onStagePhaseChange: PropTypes.func,
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText: PropTypes.string,
continueKind: PropTypes.string,
},
getInitialState: function() {
@ -87,7 +98,8 @@ export default createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._unmounted = false;
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
@ -161,6 +173,7 @@ export default createReactClass({
_authStateUpdated: function(stageType, stageState) {
const oldStage = this.state.authStage;
this.setState({
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
@ -184,11 +197,13 @@ export default createReactClass({
errorText: null,
stageErrorText: null,
});
} else {
this.setState({
busy: false,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any
// immediate work has completed, but that's not really what we want at
// the UI layer, so we ignore this signal and show a spinner until
// there's a new screen to show the user. This is implemented by setting
// `busy: false` in `_authStateUpdated`.
// See also https://github.com/vector-im/riot-web/issues/12546
},
_setFocus: function() {
@ -201,6 +216,16 @@ export default createReactClass({
this._authLogic.submitAuthDict(authData);
},
_onPhaseChange: function(newPhase) {
if (this.props.onStagePhaseChange) {
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
}
},
_onStageCancel: function() {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
},
_renderCurrentStage: function() {
const stage = this.state.authStage;
if (!stage) {
@ -229,6 +254,10 @@ export default createReactClass({
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this._onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this._onStageCancel}
/>
);
},

View file

@ -44,7 +44,8 @@ const LeftPanel = createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount: function() {
this.focusedElement = null;
this._breadcrumbsWatcherRef = SettingsStore.watchSetting(

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2017, 2018, 2020 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,29 +16,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from 'matrix-js-sdk';
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { DragDropContext } from 'react-beautiful-dnd';
import { Key, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import {MatrixClientPeg, MatrixClientCreds} from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import { getHomePageUrl } from '../../utils/pages';
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
import HomePage from "./HomePage";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg";
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general.
@ -51,6 +54,52 @@ function canElementReceiveInput(el) {
!!el.getAttribute("contenteditable");
}
interface IProps {
matrixClient: MatrixClient;
onRegistered: (credentials: MatrixClientCreds) => Promise<MatrixClient>;
viaServers?: string[];
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
middleDisabled: boolean;
initialEventPixelOffset: number;
leftDisabled: boolean;
rightDisabled: boolean;
showCookieBar: boolean;
hasNewVersion: boolean;
userHasGeneratedPassword: boolean;
showNotifierToolbar: boolean;
page_type: string;
autoJoin: boolean;
thirdPartyInvite?: object;
roomOobData?: object;
currentRoomId: string;
ConferenceHandler?: object;
collapseLhs: boolean;
checkingForUpdate: boolean;
config: {
piwik: {
policyUrl: string;
},
[key: string]: any,
};
currentUserId?: string;
currentGroupId?: string;
currentGroupIsNew?: boolean;
version?: string;
newVersion?: string;
newVersionReleaseNotes?: string;
}
interface IState {
mouseDown?: {
x: number;
y: number;
};
syncErrorData: any;
useCompactLayout: boolean;
serverNoticeEvents: MatrixEvent[];
userHasGeneratedPassword: boolean;
}
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
@ -60,10 +109,10 @@ function canElementReceiveInput(el) {
*
* Components mounted below us can access the matrix client via the react context.
*/
const LoggedInView = createReactClass({
displayName: 'LoggedInView',
class LoggedInView extends React.PureComponent<IProps, IState> {
static displayName = 'LoggedInView';
propTypes: {
static propTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
@ -76,24 +125,28 @@ const LoggedInView = createReactClass({
viaServers: PropTypes.arrayOf(PropTypes.string),
// and lots and lots of other stuff.
},
};
getInitialState: function() {
return {
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _sessionStore: sessionStore;
protected readonly _sessionStoreToken: { remove: () => void };
protected resizer: Resizer;
constructor(props, context) {
super(props, context);
this.state = {
mouseDown: undefined,
syncErrorData: undefined,
userHasGeneratedPassword: false,
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
// any currently active server notice events
serverNoticeEvents: [],
};
},
componentDidMount: function() {
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
},
componentWillMount: function() {
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
@ -115,22 +168,29 @@ const LoggedInView = createReactClass({
fixupColorFonts();
this._roomView = createRef();
},
this._roomView = React.createRef();
this._resizeContainer = React.createRef();
}
componentDidUpdate(prevProps) {
componentDidMount() {
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
}
componentDidUpdate(prevProps, prevState) {
// attempt to guess when a banner was opened or closed
if (
(prevProps.showCookieBar !== this.props.showCookieBar) ||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
(prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) ||
(prevState.userHasGeneratedPassword !== this.state.userHasGeneratedPassword) ||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
) {
this.props.resizeNotifier.notifyBannersChanged();
}
},
}
componentWillUnmount: function() {
componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
@ -139,7 +199,7 @@ const LoggedInView = createReactClass({
this._sessionStoreToken.remove();
}
this.resizer.detach();
},
}
// Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy.
@ -147,22 +207,22 @@ const LoggedInView = createReactClass({
// This is required because `LoggedInView` maintains its own state and if this state
// updates after the client peg has been made null (during logout), then it will
// attempt to re-render and the children will throw errors.
shouldComponentUpdate: function() {
shouldComponentUpdate() {
return Boolean(MatrixClientPeg.get());
},
}
canResetTimelineInRoom: function(roomId) {
canResetTimelineInRoom = (roomId) => {
if (!this._roomView.current) {
return true;
}
return this._roomView.current.canResetTimeline();
},
};
_setStateFromSessionStore() {
_setStateFromSessionStore = () => {
this.setState({
userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()),
});
},
};
_createResizer() {
const classNames = {
@ -186,24 +246,22 @@ const LoggedInView = createReactClass({
},
};
const resizer = new Resizer(
this.resizeContainer,
this._resizeContainer.current,
CollapseDistributor,
collapseConfig);
resizer.setClassNames(classNames);
return resizer;
},
}
_loadResizerPreferences() {
let lhsSize = window.localStorage.getItem("mx_lhs_size");
if (lhsSize !== null) {
lhsSize = parseInt(lhsSize, 10);
} else {
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10);
if (isNaN(lhsSize)) {
lhsSize = 350;
}
this.resizer.forHandleAt(0).resize(lhsSize);
},
}
onAccountData: function(event) {
onAccountData = (event) => {
if (event.getType() === "im.vector.web.settings") {
this.setState({
useCompactLayout: event.getContent().useCompactLayout,
@ -212,9 +270,9 @@ const LoggedInView = createReactClass({
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({action: "ignore_state_changed"});
}
},
};
onSync: function(syncState, oldSyncState, data) {
onSync = (syncState, oldSyncState, data) => {
const oldErrCode = (
this.state.syncErrorData &&
this.state.syncErrorData.error &&
@ -236,16 +294,16 @@ const LoggedInView = createReactClass({
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
this._updateServerNoticeEvents();
}
},
};
onRoomStateEvents: function(ev, state) {
onRoomStateEvents = (ev, state) => {
const roomLists = RoomListStore.getRoomLists();
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
}
},
};
_updateServerNoticeEvents: async function() {
_updateServerNoticeEvents = async () => {
const roomLists = RoomListStore.getRoomLists();
if (!roomLists['m.server_notice']) return [];
@ -258,16 +316,16 @@ const LoggedInView = createReactClass({
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
const ev = timeline.getEvents().find(ev => ev.getId() === eventId);
if (ev) pinnedEvents.push(ev);
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
if (event) pinnedEvents.push(event);
}
}
this.setState({
serverNoticeEvents: pinnedEvents,
});
},
};
_onPaste: function(ev) {
_onPaste = (ev) => {
let canReceiveInput = false;
let element = ev.target;
// test for all parents because the target can be a child of a contenteditable element
@ -281,7 +339,7 @@ const LoggedInView = createReactClass({
// so dispatch synchronously before paste happens
dis.dispatch({action: 'focus_composer'}, true);
}
},
};
/*
SOME HACKERY BELOW:
@ -305,22 +363,22 @@ const LoggedInView = createReactClass({
We also listen with a native listener on the document to get keydown events when no element is focused.
Bubbling is irrelevant here as the target is the body element.
*/
_onReactKeyDown: function(ev) {
_onReactKeyDown = (ev) => {
// events caught while bubbling up on the root element
// of this component, so something must be focused.
this._onKeyDown(ev);
},
};
_onNativeKeyDown: function(ev) {
_onNativeKeyDown = (ev) => {
// only pass this if there is no focused element.
// if there is, _onKeyDown will be called by the
// react keydown handler that respects the react bubbling order.
if (ev.target === document.body) {
this._onKeyDown(ev);
}
},
};
_onKeyDown: function(ev) {
_onKeyDown = (ev) => {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
// Will need to find a better meta key if anyone actually cares about using this.
@ -337,13 +395,13 @@ const LoggedInView = createReactClass({
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey ||
ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
switch (ev.key) {
case Key.PAGE_UP:
case Key.PAGE_DOWN:
if (!hasModifier) {
if (!hasModifier && !isModifier) {
this._onScrollKeyPressed(ev);
handled = true;
}
@ -365,8 +423,6 @@ const LoggedInView = createReactClass({
}
break;
case Key.BACKTICK:
if (ev.key !== "`") break;
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in
@ -379,12 +435,48 @@ const LoggedInView = createReactClass({
handled = true;
}
break;
case Key.SLASH:
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) {
KeyboardShortcuts.toggleDialog();
handled = true;
}
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
dis.dispatch({
action: 'view_room_delta',
delta: ev.key === Key.ARROW_UP ? -1 : 1,
unread: ev.shiftKey,
});
handled = true;
}
break;
case Key.PERIOD:
if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) {
dis.dispatch({
action: 'toggle_right_panel',
type: this.props.page_type === "room_view" ? "room" : "group",
});
handled = true;
}
break;
default:
// if we do not have a handler for it, pass it to the platform which might
handled = PlatformPeg.get().onKeyDown(ev);
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
} else if (!hasModifier) {
} else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).
const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER);
@ -401,19 +493,19 @@ const LoggedInView = createReactClass({
// that would prevent typing in the now-focussed composer
}
}
},
};
/**
* dispatch a page-up/page-down/etc to the appropriate component
* @param {Object} ev The key event
*/
_onScrollKeyPressed: function(ev) {
_onScrollKeyPressed = (ev) => {
if (this._roomView.current) {
this._roomView.current.handleScrollKey(ev);
}
},
};
_onDragEnd: function(result) {
_onDragEnd = (result) => {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
@ -436,9 +528,9 @@ const LoggedInView = createReactClass({
} else if (dest.startsWith('room-sub-list-droppable_')) {
this._onRoomTileEndDrag(result);
}
},
};
_onRoomTileEndDrag: function(result) {
_onRoomTileEndDrag = (result) => {
let newTag = result.destination.droppableId.split('_')[1];
let prevTag = result.source.droppableId.split('_')[1];
if (newTag === 'undefined') newTag = undefined;
@ -455,9 +547,9 @@ const LoggedInView = createReactClass({
prevTag, newTag,
oldIndex, newIndex,
), true);
},
};
_onMouseDown: function(ev) {
_onMouseDown = (ev) => {
// When the panels are disabled, clicking on them results in a mouse event
// which bubbles to certain elements in the tree. When this happens, close
// any settings page that is currently open (user/room/group).
@ -476,9 +568,9 @@ const LoggedInView = createReactClass({
});
}
}
},
};
_onMouseUp: function(ev) {
_onMouseUp = (ev) => {
if (!this.state.mouseDown) return;
const deltaX = ev.pageX - this.state.mouseDown.x;
@ -497,17 +589,12 @@ const LoggedInView = createReactClass({
// Always clear the mouseDown state to ensure we don't accidentally
// use stale values due to the mouseDown checks.
this.setState({mouseDown: null});
},
};
_setResizeContainerRef(div) {
this.resizeContainer = div;
},
render: function() {
render() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const ToastContainer = sdk.getComponent('structures.ToastContainer');
@ -546,13 +633,7 @@ const LoggedInView = createReactClass({
break;
case PageTypes.HomePage:
{
const pageUrl = getHomePageUrl(this.props.config);
pageElement = <EmbeddedPage className="mx_HomePage"
url={pageUrl}
scrollbar={true}
/>;
}
pageElement = <HomePage />;
break;
case PageTypes.UserView:
@ -585,7 +666,8 @@ const LoggedInView = createReactClass({
limitType={usageLimitEvent.getContent().limit_type}
/>;
} else if (this.props.showCookieBar &&
this.props.config.piwik
this.props.config.piwik &&
navigator.doNotTrack !== "1"
) {
const policyUrl = this.props.config.piwik.policyUrl || null;
topBar = <CookieBar policyUrl={policyUrl} />;
@ -622,7 +704,7 @@ const LoggedInView = createReactClass({
{ topBar }
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<div ref={this._resizeContainer} className={bodyClasses}>
<LeftPanel
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
@ -635,7 +717,7 @@ const LoggedInView = createReactClass({
</div>
</MatrixClientContext.Provider>
);
},
});
}
}
export default LoggedInView;

View file

@ -93,14 +93,19 @@ export default class MainSplit extends React.Component {
const bodyView = React.Children.only(this.props.children);
const panelView = this.props.panel;
if (this.props.collapsedRhs || !panelView) {
return bodyView;
} else {
return <div className="mx_MainSplit" ref={this._setResizeContainerRef}>
{ bodyView }
const hasResizer = !this.props.collapsedRhs && panelView;
let children;
if (hasResizer) {
children = <React.Fragment>
<ResizeHandle reverse={true} />
{ panelView }
</div>;
</React.Fragment>;
}
return <div className="mx_MainSplit" ref={hasResizer ? this._setResizeContainerRef : undefined}>
{ bodyView }
{ children }
</div>;
}
}

View file

@ -28,6 +28,7 @@ import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@ -502,6 +503,7 @@ export default class MessagePanel extends React.Component {
}
_getTilesForEvent(prevEvent, mxEv, last) {
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
const EventTile = sdk.getComponent('rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = [];
@ -522,7 +524,8 @@ export default class MessagePanel extends React.Component {
// if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId &&
(mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
haveTileForEvent(prevEvent) && (mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
(mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) {
continuation = true;
}
@ -575,25 +578,27 @@ export default class MessagePanel extends React.Component {
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}
>
<EventTile mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting.bind(this)}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
/>
<TileErrorBoundary mxEvent={mxEv}>
<EventTile mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting.bind(this)}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
/>
</TileErrorBoundary>
</li>,
);
@ -755,6 +760,7 @@ export default class MessagePanel extends React.Component {
}
render() {
const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary');
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
const Spinner = sdk.getComponent("elements.Spinner");
@ -787,22 +793,24 @@ export default class MessagePanel extends React.Component {
}
return (
<ScrollPanel
ref={this._scrollPanel}
className={className}
onScroll={this.props.onScroll}
onResize={this.onResize}
onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest}
style={style}
stickyBottom={this.props.stickyBottom}
resizeNotifier={this.props.resizeNotifier}
>
{ topSpinner }
{ this._getEventTiles() }
{ whoIsTyping }
{ bottomSpinner }
</ScrollPanel>
<ErrorBoundary>
<ScrollPanel
ref={this._scrollPanel}
className={className}
onScroll={this.props.onScroll}
onResize={this.onResize}
onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest}
style={style}
stickyBottom={this.props.stickyBottom}
resizeNotifier={this.props.resizeNotifier}
>
{ topSpinner }
{ this._getEventTiles() }
{ whoIsTyping }
{ bottomSpinner }
</ScrollPanel>
</ErrorBoundary>
);
}
}
@ -836,14 +844,16 @@ class CreationGrouper {
// events that we include in the group but then eject out and place
// above the group.
this.ejectedEvents = [];
this.readMarker = panel._readMarkerForEvent(createEvent.getId());
this.readMarker = panel._readMarkerForEvent(
createEvent.getId(),
createEvent === lastShownEvent,
);
}
shouldGroup(ev) {
const panel = this.panel;
const createEvent = this.createEvent;
if (!panel._shouldShowEvent(ev)) {
this.readMarker = this.readMarker || panel._readMarkerForEvent(ev.getId());
return true;
}
if (panel._wantsDateSeparator(this.createEvent, ev.getDate())) {
@ -861,7 +871,10 @@ class CreationGrouper {
add(ev) {
const panel = this.panel;
this.readMarker = this.readMarker || panel._readMarkerForEvent(ev.getId());
this.readMarker = this.readMarker || panel._readMarkerForEvent(
ev.getId(),
ev === this.lastShownEvent,
);
if (!panel._shouldShowEvent(ev)) {
return;
}
@ -873,6 +886,11 @@ class CreationGrouper {
}
getTiles() {
// If we don't have any events to group, don't even try to group them. The logic
// below assumes that we have a group of events to deal with, but we might not if
// the events we were supposed to group were redacted.
if (!this.events || !this.events.length) return [];
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
@ -943,22 +961,43 @@ class MemberGrouper {
constructor(panel, ev, prevEvent, lastShownEvent) {
this.panel = panel;
this.readMarker = panel._readMarkerForEvent(ev.getId());
this.readMarker = panel._readMarkerForEvent(
ev.getId(),
ev === lastShownEvent,
);
this.events = [ev];
this.prevEvent = prevEvent;
this.lastShownEvent = lastShownEvent;
}
shouldGroup(ev) {
if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) {
return false;
}
return isMembershipChange(ev);
}
add(ev) {
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(ev.getId());
if (ev.getType() === 'm.room.member') {
// We'll just double check that it's worth our time to do so, through an
// ugly hack. If textForEvent returns something, we should group it for
// rendering but if it doesn't then we'll exclude it.
const renderText = textForEvent(ev);
if (!renderText || renderText.trim().length === 0) return; // quietly ignore
}
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
ev.getId(),
ev === this.lastShownEvent,
);
this.events.push(ev);
}
getTiles() {
// If we don't have any events to group, don't even try to group them. The logic
// below assumes that we have a group of events to deal with, but we might not if
// the events we were supposed to group were redacted.
if (!this.events || !this.events.length) return [];
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');

View file

@ -22,6 +22,7 @@ import { _t } from '../../languageHandler';
import dis from '../../dispatcher';
import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
export default createReactClass({
displayName: 'MyGroups',
@ -37,7 +38,7 @@ export default createReactClass({
contextType: MatrixClientContext,
},
componentWillMount: function() {
componentDidMount: function() {
this._fetch();
},
@ -62,8 +63,6 @@ export default createReactClass({
const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const GroupTile = sdk.getComponent("groups.GroupTile");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
let content;
let contentHeader;
@ -74,7 +73,7 @@ export default createReactClass({
});
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
content = groupNodes.length > 0 ?
<GeminiScrollbarWrapper>
<AutoHideScrollbar className="mx_MyGroups_scrollable">
<div className="mx_MyGroups_microcopy">
<p>
{ _t(
@ -93,7 +92,7 @@ export default createReactClass({
<div className="mx_MyGroups_joinedGroups">
{ groupNodes }
</div>
</GeminiScrollbarWrapper> :
</AutoHideScrollbar> :
<div className="mx_MyGroups_placeholder">
{ _t(
"You're not currently a member of any communities.",

View file

@ -92,6 +92,7 @@ export default class RightPanel extends React.Component {
// not mounted in time to get the dispatch.
// Until then, let this code serve as a warning from history.
if (
rps.roomPanelPhaseParams.member &&
userForPanel.userId === rps.roomPanelPhaseParams.member.userId &&
rps.roomPanelPhaseParams.verificationRequest
) {
@ -107,7 +108,7 @@ export default class RightPanel extends React.Component {
}
}
componentWillMount() {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
const cli = this.context;
cli.on("RoomState.members", this.onRoomStateMember);
@ -122,7 +123,8 @@ export default class RightPanel extends React.Component {
this._unregisterGroupStore(this.props.groupId);
}
componentWillReceiveProps(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore(this.props.groupId);
this._initGroupStore(newProps.groupId);
@ -181,6 +183,7 @@ export default class RightPanel extends React.Component {
member: payload.member,
event: payload.event,
verificationRequest: payload.verificationRequest,
verificationRequestPromise: payload.verificationRequestPromise,
});
}
}
@ -216,12 +219,29 @@ export default class RightPanel extends React.Component {
break;
case RIGHT_PANEL_PHASES.RoomMemberInfo:
case RIGHT_PANEL_PHASES.EncryptionPanel:
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (SettingsStore.getValue("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null,
});
// XXX: There are three different ways of 'closing' this panel depending on what state
// things are in... this knows far more than it should do about the state of the rest
// of the app and is generally a bit silly.
if (this.props.user) {
// If we have a user prop then we're displaying a user from the 'user' page type
// in LoggedInView, so need to change the page type to close the panel (we switch
// to the home page which is not obviously the correct thing to do, but I'm not sure
// anything else is - we could hide the close button altogether?)
dis.dispatch({
action: "view_home_page",
});
} else {
// Otherwise we have got our user from RoomViewStore which means we're being shown
// within a room, so go back to the member panel if we were in the encryption panel,
// or the member list if we were in the member panel... phew.
dis.dispatch({
action: "view_user",
member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ?
this.state.member : null,
});
}
};
panel = <UserInfo
user={this.state.member}
@ -230,6 +250,7 @@ export default class RightPanel extends React.Component {
onClose={onClose}
phase={this.state.phase}
verificationRequest={this.state.verificationRequest}
verificationRequestPromise={this.state.verificationRequestPromise}
/>;
} else {
panel = <MemberInfo
@ -242,7 +263,7 @@ export default class RightPanel extends React.Component {
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
break;
case RIGHT_PANEL_PHASES.GroupMemberInfo:
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (SettingsStore.getValue("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",

View file

@ -28,6 +28,7 @@ import { _t } from '../../languageHandler';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160;
@ -40,30 +41,23 @@ export default createReactClass({
displayName: 'RoomDirectory',
propTypes: {
config: PropTypes.object,
onFinished: PropTypes.func.isRequired,
},
getDefaultProps: function() {
return {
config: {},
};
},
getInitialState: function() {
return {
publicRooms: [],
loading: true,
protocolsLoading: true,
error: null,
instanceId: null,
includeAll: false,
roomServer: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: null,
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount: function() {
this._unmounted = false;
this.nextBatch = null;
this.filterTimeout = null;
@ -96,6 +90,8 @@ export default createReactClass({
),
});
});
this.refreshRoomList();
},
componentWillUnmount: function() {
@ -130,10 +126,10 @@ export default createReactClass({
if (my_server != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server;
}
if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId;
} else if (this.state.includeAll) {
if (this.state.instanceId === ALL_ROOMS) {
opts.include_all_networks = true;
} else if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId;
}
if (this.nextBatch) opts.since = this.nextBatch;
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
@ -247,7 +243,7 @@ export default createReactClass({
}
},
onOptionChange: function(server, instanceId, includeAll) {
onOptionChange: function(server, instanceId) {
// clear next batch so we don't try to load more rooms
this.nextBatch = null;
this.setState({
@ -257,7 +253,6 @@ export default createReactClass({
publicRooms: [],
roomServer: server,
instanceId: instanceId,
includeAll: includeAll,
error: null,
}, this.refreshRoomList);
// We also refresh the room list each time even though this
@ -305,7 +300,7 @@ export default createReactClass({
onJoinFromSearchClick: function(alias) {
// If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId) {
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
// If the user specified an alias without a domain, add on whichever server is selected
// in the dropdown
if (alias.indexOf(':') == -1) {
@ -372,7 +367,10 @@ export default createReactClass({
onCreateRoomClick: function(room) {
this.props.onFinished();
dis.dispatch({action: 'view_create_room'});
dis.dispatch({
action: 'view_create_room',
public: true,
});
},
showRoomAlias: function(alias, autoJoin=false) {
@ -406,6 +404,12 @@ export default createReactClass({
// would normally decide what the name is.
name: room.name || room_alias || _t('Unnamed room'),
};
if (this.state.roomServer) {
payload.opts = {
viaServers: [this.state.roomServer],
};
}
}
// It's not really possible to join Matrix rooms by ID because the HS has no way to know
// which servers to start querying. However, there's no other way to join rooms in
@ -587,7 +591,7 @@ export default createReactClass({
}
let placeholder = _t('Find a room…');
if (!this.state.instanceId) {
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
@ -604,10 +608,18 @@ export default createReactClass({
listHeader = <div className="mx_RoomDirectory_listheader">
<DirectorySearchBox
className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder} showJoinButton={showJoinButton}
onChange={this.onFilterChange}
onClear={this.onFilterClear}
onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder}
showJoinButton={showJoinButton}
/>
<NetworkDropdown
protocols={this.protocols}
onOptionChange={this.onOptionChange}
selectedServerName={this.state.roomServer}
selectedInstanceId={this.state.instanceId}
/>
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
</div>;
}
const explanation =
@ -628,7 +640,7 @@ export default createReactClass({
title={_t("Explore rooms")}
>
<div className="mx_RoomDirectory">
<p>{explanation}</p>
{explanation}
<div className="mx_RoomDirectory_list">
{listHeader}
{content}

View file

@ -96,7 +96,7 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);

View file

@ -32,10 +32,47 @@ import RoomTile from "../views/rooms/RoomTile";
import LazyRenderList from "../views/elements/LazyRenderList";
import {_t} from "../../languageHandler";
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
import toRem from "../../utils/rem";
// turn this on for drop & drag console debugging galore
const debug = false;
class RoomTileErrorBoundary extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
error: null,
};
}
static getDerivedStateFromError(error) {
// Side effects are not permitted here, so we only update the state so
// that the next render shows an error message.
return { error };
}
componentDidCatch(error, { componentStack }) {
// Browser consoles are better at formatting output when native errors are passed
// in their own `console.error` invocation.
console.error(error);
console.error(
"The above error occured while React was rendering the following components:",
componentStack,
);
}
render() {
if (this.state.error) {
return (<div className="mx_RoomTile mx_RoomTileError">
{this.props.roomId}
</div>);
} else {
return this.props.children;
}
}
}
export default class RoomSubList extends React.PureComponent {
static displayName = 'RoomSubList';
static debug = debug;
@ -46,8 +83,6 @@ export default class RoomSubList extends React.PureComponent {
tagName: PropTypes.string,
addRoomLabel: PropTypes.string,
order: PropTypes.string.isRequired,
// passed through to RoomTile and used to highlight room with `!` regardless of notifications count
isInvite: PropTypes.bool,
@ -113,21 +148,30 @@ export default class RoomSubList extends React.PureComponent {
}
onAction = (payload) => {
// XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched,
// but this is no longer true, so we must do it here (and can apply the small
// optimisation of checking that we care about the room being read).
//
// Ultimately we need to transition to a state pushing flow where something
// explicitly notifies the components concerned that the notif count for a room
// has change (e.g. a Flux store).
if (payload.action === 'on_room_read' &&
this.props.list.some((r) => r.roomId === payload.roomId)
) {
this.forceUpdate();
switch (payload.action) {
case 'on_room_read':
// XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched,
// but this is no longer true, so we must do it here (and can apply the small
// optimisation of checking that we care about the room being read).
//
// Ultimately we need to transition to a state pushing flow where something
// explicitly notifies the components concerned that the notif count for a room
// has change (e.g. a Flux store).
if (this.props.list.some((r) => r.roomId === payload.roomId)) {
this.forceUpdate();
}
break;
case 'view_room':
if (this.state.hidden && !this.props.forceExpand && payload.show_room_tile &&
this.props.list.some((r) => r.roomId === payload.room_id)
) {
this.toggle();
}
}
};
onClick = (ev) => {
toggle = () => {
if (this.isCollapsibleOnClick()) {
// The header isCollapsible, so the click is to be interpreted as collapse and truncation logic
const isHidden = !this.state.hidden;
@ -140,6 +184,10 @@ export default class RoomSubList extends React.PureComponent {
}
};
onClick = (ev) => {
this.toggle();
};
onHeaderKeyDown = (ev) => {
switch (ev.key) {
case Key.ARROW_LEFT:
@ -182,6 +230,7 @@ export default class RoomSubList extends React.PureComponent {
onRoomTileClick = (roomId, ev) => {
dis.dispatch({
action: 'view_room',
show_room_tile: true, // to make sure the room gets scrolled into view
room_id: roomId,
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
});
@ -195,7 +244,7 @@ export default class RoomSubList extends React.PureComponent {
};
makeRoomTile = (room) => {
return <RoomTile
return <RoomTileErrorBoundary roomId={room.roomId}><RoomTile
room={room}
roomSubList={this}
tagName={this.props.tagName}
@ -208,7 +257,7 @@ export default class RoomSubList extends React.PureComponent {
refreshSubList={this._updateSubListCount}
incomingCall={null}
onClick={this.onRoomTileClick}
/>;
/></RoomTileErrorBoundary>;
};
_onNotifBadgeClick = (e) => {
@ -371,7 +420,7 @@ export default class RoomSubList extends React.PureComponent {
setHeight = (height) => {
if (this._subList.current) {
this._subList.current.style.height = `${height}px`;
this._subList.current.style.height = toRem(height);
}
this._updateLazyRenderHeight(height);
};

View file

@ -30,7 +30,6 @@ import classNames from 'classnames';
import { _t } from '../../languageHandler';
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import * as sdk from '../../index';
@ -42,7 +41,7 @@ import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch from '../../Searching';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
@ -50,11 +49,12 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import {haveTileForEvent} from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { shieldStatusForRoom } from '../../utils/ShieldUtils';
const DEBUG = false;
let debuglog = function() {};
@ -97,8 +97,12 @@ export default createReactClass({
viaServers: PropTypes.arrayOf(PropTypes.string),
},
statics: {
contextType: MatrixClientContext,
},
getInitialState: function() {
const llMembers = MatrixClientPeg.get().hasLazyLoadMembersEnabled();
const llMembers = this.context.hasLazyLoadMembersEnabled();
return {
room: null,
roomId: null,
@ -131,6 +135,8 @@ export default createReactClass({
isAlone: false,
isPeeking: false,
showingPinned: false,
showReadReceipts: true,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
// error object, as from the matrix client/server API
// If we failed to load information about the room,
@ -161,29 +167,40 @@ export default createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room", this.onRoom);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData);
MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged);
MatrixClientPeg.get().on("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.on("Room", this.onRoom);
this.context.on("Room.timeline", this.onRoomTimeline);
this.context.on("Room.name", this.onRoomName);
this.context.on("Room.accountData", this.onRoomAccountData);
this.context.on("RoomState.events", this.onRoomStateEvents);
this.context.on("RoomState.members", this.onRoomStateMember);
this.context.on("Room.myMembership", this.onMyMembership);
this.context.on("accountData", this.onAccountData);
this.context.on("crypto.keyBackupStatus", this.onKeyBackupStatus);
this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
// Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate);
this._onRoomViewStoreUpdate(true);
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
this._showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this._onReadReceiptsChange);
this._roomView = createRef();
this._searchResultsPanel = createRef();
},
_onReadReceiptsChange: function() {
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
});
},
_onRoomViewStoreUpdate: function(initial) {
if (this.unmounted) {
return;
@ -204,8 +221,10 @@ export default createReactClass({
return;
}
const roomId = RoomViewStore.getRoomId();
const newState = {
roomId: RoomViewStore.getRoomId(),
roomId,
roomAlias: RoomViewStore.getRoomAlias(),
roomLoading: RoomViewStore.isRoomLoading(),
roomLoadError: RoomViewStore.getRoomLoadError(),
@ -214,9 +233,15 @@ export default createReactClass({
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
shouldPeek: RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", RoomViewStore.getRoomId()),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
};
if (!initial && this.state.shouldPeek && !newState.shouldPeek) {
// Stop peeking because we have joined this room now
this.context.stopPeeking();
}
// Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307
console.log(
'RVS update:',
@ -231,7 +256,7 @@ export default createReactClass({
// NB: This does assume that the roomID will not change for the lifetime of
// the RoomView instance
if (initial) {
newState.room = MatrixClientPeg.get().getRoom(newState.roomId);
newState.room = this.context.getRoom(newState.roomId);
if (newState.room) {
newState.showApps = this._shouldShowApps(newState.room);
this._onRoomLoaded(newState.room);
@ -333,7 +358,7 @@ export default createReactClass({
peekLoading: true,
isPeeking: true, // this will change to false if peeking fails
});
MatrixClientPeg.get().peekInRoom(roomId).then((room) => {
this.context.peekInRoom(roomId).then((room) => {
if (this.unmounted) {
return;
}
@ -342,7 +367,7 @@ export default createReactClass({
peekLoading: false,
});
this._onRoomLoaded(room);
}, (err) => {
}).catch((err) => {
if (this.unmounted) {
return;
}
@ -355,7 +380,7 @@ export default createReactClass({
// This won't necessarily be a MatrixError, but we duck-type
// here and say if it's got an 'errcode' key with the right value,
// it means we can't peek.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") {
if (err.errcode === "M_GUEST_ACCESS_FORBIDDEN" || err.errcode === 'M_FORBIDDEN') {
// This is fine: the room just isn't peekable (we assume).
this.setState({
peekLoading: false,
@ -365,10 +390,8 @@ export default createReactClass({
}
});
} else if (room) {
//viewing a previously joined room, try to lazy load members
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.context.stopPeeking();
this.setState({isPeeking: false});
}
}
@ -382,13 +405,9 @@ export default createReactClass({
const hideWidgetDrawer = localStorage.getItem(
room.roomId + "_hide_widget_drawer");
if (hideWidgetDrawer === "true") {
return false;
}
const widgets = WidgetEchoStore.getEchoedRoomWidgets(room.roomId, WidgetUtils.getRoomWidgets(room));
return widgets.length > 0 || WidgetEchoStore.roomHasPendingWidgets(room.roomId, WidgetUtils.getRoomWidgets(room));
// 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";
},
componentDidMount: function() {
@ -406,22 +425,7 @@ export default createReactClass({
}
this.onResize();
document.addEventListener("keydown", this.onKeyDown);
// XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer.
if (this.state.room &&
this.state.room.getJoinedMemberCount() == 1 &&
this.state.room.getLiveTimeline() &&
this.state.room.getLiveTimeline().getEvents() &&
this.state.room.getLiveTimeline().getEvents().length <= 6) {
const inviteBox = document.getElementById("mx_SearchableEntityList_query");
setTimeout(function() {
if (inviteBox) {
inviteBox.focus();
}
}, 50);
}
document.addEventListener("keydown", this.onNativeKeyDown);
},
shouldComponentUpdate: function(nextProps, nextState) {
@ -460,13 +464,15 @@ export default createReactClass({
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
SettingsStore.unwatchSetting(this._ciderWatcherRef);
// update the scroll map before we get unmounted
if (this.state.roomId) {
RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState());
}
if (this.state.shouldPeek) {
this.context.stopPeeking();
}
// stop tracking room changes to format permalinks
this._stopAllPermalinkCreators();
@ -482,18 +488,19 @@ export default createReactClass({
roomView.removeEventListener('dragend', this.onDragLeaveOrEnd);
}
dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
if (this.context) {
this.context.removeListener("Room", this.onRoom);
this.context.removeListener("Room.timeline", this.onRoomTimeline);
this.context.removeListener("Room.name", this.onRoomName);
this.context.removeListener("Room.accountData", this.onRoomAccountData);
this.context.removeListener("RoomState.events", this.onRoomStateEvents);
this.context.removeListener("Room.myMembership", this.onMyMembership);
this.context.removeListener("RoomState.members", this.onRoomStateMember);
this.context.removeListener("accountData", this.onAccountData);
this.context.removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus);
this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
}
window.removeEventListener('beforeunload', this.onPageUnload);
@ -501,15 +508,24 @@ export default createReactClass({
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
document.removeEventListener("keydown", this.onKeyDown);
document.removeEventListener("keydown", this.onNativeKeyDown);
// Remove RoomStore listener
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
// Remove RightPanelStore listener
if (this._rightPanelStoreToken) {
this._rightPanelStoreToken.remove();
}
WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate);
if (this._showReadReceiptsWatchRef) {
SettingsStore.unwatchSetting(this._showReadReceiptsWatchRef);
this._showReadReceiptsWatchRef = null;
}
// cancel any pending calls to the rate_limited_funcs
this._updateRoomMembers.cancelPendingCall();
@ -518,6 +534,12 @@ export default createReactClass({
// Tinter.tint(); // reset colourscheme
},
_onRightPanelStoreUpdate: function() {
this.setState({
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
});
},
onPageUnload(event) {
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
return event.returnValue =
@ -528,8 +550,8 @@ export default createReactClass({
}
},
onKeyDown: function(ev) {
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
onNativeKeyDown: function(ev) {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
@ -555,12 +577,39 @@ export default createReactClass({
}
},
onReactKeyDown: function(ev) {
let handled = false;
switch (ev.key) {
case Key.ESCAPE:
if (!ev.altKey && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) {
this._messagePanel.forgetReadMarker();
this.jumpToLiveTimeline();
handled = true;
}
break;
case Key.PAGE_UP:
if (!ev.altKey && !ev.ctrlKey && ev.shiftKey && !ev.metaKey) {
this.jumpToReadMarker();
handled = true;
}
break;
case Key.U.toUpperCase():
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) {
dis.dispatch({ action: "upload_file" })
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
},
onAction: function(payload) {
switch (payload.action) {
case 'after_right_panel_phase_change':
// We don't keep state on the right panel, so just re-render to update
this.forceUpdate();
break;
case 'message_send_failed':
case 'message_sent':
this._checkIfAlone(this.state.room);
@ -572,9 +621,7 @@ export default createReactClass({
payload.data.description || payload.data.name);
break;
case 'picture_snapshot':
ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, MatrixClientPeg.get(),
);
ContentMessages.sharedInstance().sendContentListToRoom([payload.file], this.state.room.roomId, this.context);
break;
case 'notifier_enabled':
case 'upload_started':
@ -618,6 +665,22 @@ export default createReactClass({
this.onCancelSearchClick();
}
break;
case 'quote':
if (this.state.searchResults) {
const roomId = payload.event.getRoomId();
if (roomId === this.state.roomId) {
this.onCancelSearchClick();
}
setImmediate(() => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
deferred_action: payload,
});
});
}
break;
}
},
@ -647,7 +710,7 @@ export default createReactClass({
// we'll only be showing a spinner.
if (this.state.joining) return;
if (ev.getSender() !== MatrixClientPeg.get().credentials.userId) {
if (ev.getSender() !== this.context.credentials.userId) {
// update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
// no change
@ -703,8 +766,7 @@ export default createReactClass({
_loadMembersIfJoined: async function(room) {
// lazy load members if enabled
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
if (this.context.hasLazyLoadMembersEnabled()) {
if (room && room.getMyMembership() === 'join') {
try {
await room.loadMembersIfNeeded();
@ -739,7 +801,7 @@ export default createReactClass({
_updatePreviewUrlVisibility: function({roomId}) {
// URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit
const key = MatrixClientPeg.get().isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled';
const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled';
this.setState({
showUrlPreview: SettingsStore.getValue(key, roomId),
});
@ -772,12 +834,18 @@ export default createReactClass({
this._updateE2EStatus(room);
},
onCrossSigningKeysChanged: function() {
const room = this.state.room;
if (room) {
this._updateE2EStatus(room);
}
},
_updateE2EStatus: async function(room) {
const cli = MatrixClientPeg.get();
if (!cli.isRoomEncrypted(room.roomId)) {
if (!this.context.isRoomEncrypted(room.roomId)) {
return;
}
if (!cli.isCryptoEnabled()) {
if (!this.context.isCryptoEnabled()) {
// If crypto is not currently enabled, we aren't tracking devices at all,
// so we don't know what the answer is. Let's error on the safe side and show
// a warning for this case.
@ -786,7 +854,7 @@ export default createReactClass({
});
return;
}
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (!SettingsStore.getValue("feature_cross_signing")) {
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
this.setState({
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
@ -796,40 +864,9 @@ export default createReactClass({
return;
}
// Duplication between here and _updateE2eStatus in RoomTile
/* At this point, the user has encryption on and cross-signing on */
const e2eMembers = await room.getEncryptionTargetMembers();
const verified = [];
const unverified = [];
e2eMembers.map(({userId}) => userId)
.filter((userId) => userId !== cli.getUserId())
.forEach((userId) => {
(cli.checkUserTrust(userId).isCrossSigningVerified() ?
verified : unverified).push(userId)
});
debuglog("e2e verified", verified, "unverified", unverified);
/* Check all verified user devices. */
/* Don't alarm if no other users are verified */
const targets = (verified.length > 0) ? [...verified, cli.getUserId()] : verified;
for (const userId of targets) {
const devices = await cli.getStoredDevicesForUser(userId);
const anyDeviceNotVerified = devices.some(({deviceId}) => {
return !cli.checkDeviceTrust(userId, deviceId).isVerified();
});
if (anyDeviceNotVerified) {
this.setState({
e2eStatus: "warning",
});
debuglog("e2e status set to warning as not all users trust all of their sessions." +
" Aborted on user", userId);
return;
}
}
this.setState({
e2eStatus: unverified.length === 0 ? "verified" : "normal",
e2eStatus: await shieldStatusForRoom(this.context, room),
});
},
@ -898,7 +935,7 @@ export default createReactClass({
_updatePermissions: function(room) {
if (room) {
const me = MatrixClientPeg.get().getUserId();
const me = this.context.getUserId();
const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me);
const canReply = room.maySendMessage();
@ -982,7 +1019,7 @@ export default createReactClass({
if (this.state.searchResults.next_batch) {
debuglog("requesting more search results");
const searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch(
const searchPromise = this.context.backPaginateRoomEventsSearch(
this.state.searchResults);
return this._handleSearchResult(searchPromise);
} else {
@ -1008,10 +1045,8 @@ export default createReactClass({
},
onJoinButtonClicked: function(ev) {
const cli = MatrixClientPeg.get();
// If the user is a ROU, allow them to transition to a PWLU
if (cli && cli.isGuest()) {
if (this.context && this.context.isGuest()) {
// Join this room once the user has registered and logged in
// (If we failed to peek, we may not have a valid room object.)
dis.dispatch({
@ -1108,7 +1143,7 @@ export default createReactClass({
ev.stopPropagation();
ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, MatrixClientPeg.get(),
ev.dataTransfer.files, this.state.room.roomId, this.context,
);
this.setState({ draggingFile: false });
dis.dispatch({action: 'focus_composer'});
@ -1121,12 +1156,12 @@ export default createReactClass({
},
injectSticker: function(url, info, text) {
if (MatrixClientPeg.get().isGuest()) {
if (this.context.isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, this.context)
.then(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
@ -1204,7 +1239,7 @@ export default createReactClass({
});
}, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Search failed: " + error);
console.error("Search failed", error);
Modal.createTrackedDialog('Search failed', '', ErrorDialog, {
title: _t("Search failed"),
description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")),
@ -1217,12 +1252,9 @@ export default createReactClass({
},
getSearchResultTiles: function() {
const EventTile = sdk.getComponent('rooms.EventTile');
const SearchResultTile = sdk.getComponent('rooms.SearchResultTile');
const Spinner = sdk.getComponent("elements.Spinner");
const cli = MatrixClientPeg.get();
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
@ -1230,21 +1262,21 @@ export default createReactClass({
if (this.state.searchInProgress) {
ret.push(<li key="search-spinner">
<Spinner />
</li>);
<Spinner />
</li>);
}
if (!this.state.searchResults.next_batch) {
if (this.state.searchResults.results.length == 0) {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
</li>,
);
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
</li>,
);
} else {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No more results") }</h2>
</li>,
);
<h2 className="mx_RoomView_topMarker">{ _t("No more results") }</h2>
</li>,
);
}
}
@ -1264,7 +1296,7 @@ export default createReactClass({
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId();
const room = cli.getRoom(roomId);
const room = this.context.getRoom(roomId);
if (!haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count
@ -1331,7 +1363,7 @@ export default createReactClass({
},
onForgetClick: function() {
MatrixClientPeg.get().forget(this.state.room.roomId).then(function() {
this.context.forget(this.state.room.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' });
}, function(err) {
const errCode = err.errcode || _t("unknown error code");
@ -1348,7 +1380,7 @@ export default createReactClass({
this.setState({
rejecting: true,
});
MatrixClientPeg.get().leave(this.state.roomId).then(function() {
this.context.leave(this.state.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' });
self.setState({
rejecting: false,
@ -1375,15 +1407,14 @@ export default createReactClass({
rejecting: true,
});
const cli = MatrixClientPeg.get();
try {
const myMember = this.state.room.getMember(cli.getUserId());
const myMember = this.state.room.getMember(this.context.getUserId());
const inviteEvent = myMember.events.member;
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
const ignoredUsers = this.context.getIgnoredUsers();
ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk
await cli.setIgnoredUsers(ignoredUsers);
await this.context.setIgnoredUsers(ignoredUsers);
await cli.leave(this.state.roomId);
await this.context.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' });
this.setState({
rejecting: false,
@ -1602,7 +1633,7 @@ export default createReactClass({
const createEvent = this.state.room.currentState.getStateEvents("m.room.create", "");
if (!createEvent || !createEvent.getContent()['predecessor']) return null;
return MatrixClientPeg.get().getRoom(createEvent.getContent()['predecessor']['room_id']);
return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']);
},
_getHiddenHighlightCount: function() {
@ -1696,10 +1727,13 @@ export default createReactClass({
</ErrorBoundary>
);
} else {
const myUserId = MatrixClientPeg.get().credentials.userId;
const myUserId = this.context.credentials.userId;
const myMember = this.state.room.getMember(myUserId);
const inviteEvent = myMember.events.member;
var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
const inviteEvent = myMember ? myMember.events.member : null;
let inviterName = _t("Unknown");
if (inviteEvent) {
inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
}
// We deliberately don't try to peek into invites, even if we have permission to peek
// as they could be a spam vector.
@ -1763,13 +1797,13 @@ export default createReactClass({
const showRoomUpgradeBar = (
roomVersionRecommendation &&
roomVersionRecommendation.needsUpgrade &&
this.state.room.userMayUpgradeRoom(MatrixClientPeg.get().credentials.userId)
this.state.room.userMayUpgradeRoom(this.context.credentials.userId)
);
const showRoomRecoveryReminder = (
SettingsStore.getValue("showRoomRecoveryReminder") &&
MatrixClientPeg.get().isRoomEncrypted(this.state.room.roomId) &&
!MatrixClientPeg.get().getKeyBackupEnabled()
this.context.isRoomEncrypted(this.state.room.roomId) &&
this.context.getKeyBackupEnabled() === false
);
const hiddenHighlightCount = this._getHiddenHighlightCount();
@ -1840,7 +1874,7 @@ export default createReactClass({
const auxPanel = (
<AuxPanel room={this.state.room}
fullHeight={false}
userId={MatrixClientPeg.get().credentials.userId}
userId={this.context.credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification}
@ -1951,7 +1985,7 @@ export default createReactClass({
<TimelinePanel
ref={this._gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={SettingsStore.getValue('showReadReceipts')}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking}
manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel}
@ -2000,15 +2034,22 @@ export default createReactClass({
},
);
const showRightPanel = !forceHideRightPanel && this.state.room
&& RightPanelStore.getSharedInstance().isOpenForRoom;
const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? <RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} />
: null;
const timelineClasses = classNames("mx_RoomView_timeline", {
mx_RoomView_timeline_rr_enabled: this.state.showReadReceipts,
});
const mainClasses = classNames("mx_RoomView", {
mx_RoomView_inCall: inCall,
});
return (
<RoomContext.Provider value={this.state}>
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref={this._roomView}>
<main className={mainClasses} ref={this._roomView} onKeyDown={this.onReactKeyDown}>
<ErrorBoundary>
<RoomHeader
room={this.state.room}
@ -2029,7 +2070,7 @@ export default createReactClass({
>
<div className={fadableSectionClasses}>
{auxPanel}
<div className="mx_RoomView_timeline">
<div className={timelineClasses}>
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}

View file

@ -156,9 +156,8 @@ export default createReactClass({
};
},
componentWillMount: function() {
this._fillRequestWhileRunning = false;
this._isFilling = false;
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._pendingFillRequests = {b: null, f: null};
if (this.props.resizeNotifier) {
@ -523,7 +522,7 @@ export default createReactClass({
scrollRelative: function(mult) {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
scrollNode.scrollTop = scrollNode.scrollTop + delta;
scrollNode.scrollBy(0, delta);
this._saveScrollState();
},
@ -705,17 +704,15 @@ export default createReactClass({
// the currently filled piece of the timeline
if (trackedNode) {
const oldTop = trackedNode.offsetTop;
// changing the height might change the scrollTop
// if the new height is smaller than the scrollTop.
// We calculate the diff that needs to be applied
// ourselves, so be sure to measure the
// scrollTop before changing the height.
const preexistingScrollTop = sn.scrollTop;
itemlist.style.height = `${newHeight}px`;
const newTop = trackedNode.offsetTop;
const topDiff = newTop - oldTop;
sn.scrollTop = preexistingScrollTop + topDiff;
debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop});
// important to scroll by a relative amount as
// reading scrollTop and then setting it might
// yield out of date values and cause a jump
// when setting it
sn.scrollBy(0, topDiff);
debuglog("updateHeight to", {newHeight, topDiff});
}
}
},
@ -767,6 +764,7 @@ export default createReactClass({
},
_topFromBottom(node) {
// current capped height - distance from top = distance from bottom of container to top of tracked element
return this._itemlist.current.clientHeight - node.offsetTop;
},
@ -783,7 +781,7 @@ export default createReactClass({
if (!this._divScroll) {
// Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected");
}
return this._divScroll;

View file

@ -53,6 +53,7 @@ export default createReactClass({
};
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._search = createRef();
},

View file

@ -1,7 +1,7 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,41 +18,55 @@ limitations under the License.
import * as React from "react";
import {_t} from '../../languageHandler';
import PropTypes from "prop-types";
import * as PropTypes from "prop-types";
import * as sdk from "../../index";
import AutoHideScrollbar from './AutoHideScrollbar';
import { ReactNode } from "react";
/**
* Represents a tab for the TabbedView.
*/
export class Tab {
public label: string;
public icon: string;
public body: React.ReactNode;
/**
* Creates a new tab.
* @param {string} tabLabel The untranslated tab label.
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
* @param {string} tabJsx The JSX for the tab container.
* @param {React.ReactNode} tabJsx The JSX for the tab container.
*/
constructor(tabLabel, tabIconClass, tabJsx) {
constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) {
this.label = tabLabel;
this.icon = tabIconClass;
this.body = tabJsx;
}
}
export default class TabbedView extends React.Component {
interface IProps {
tabs: Tab[];
}
interface IState {
activeTabIndex: number;
}
export default class TabbedView extends React.Component<IProps, IState> {
static propTypes = {
// The tabs to show
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
};
constructor() {
super();
constructor(props: IProps) {
super(props);
this.state = {
activeTabIndex: 0,
};
}
_getActiveTabIndex() {
private _getActiveTabIndex() {
if (!this.state || !this.state.activeTabIndex) return 0;
return this.state.activeTabIndex;
}
@ -62,7 +76,7 @@ export default class TabbedView extends React.Component {
* @param {Tab} tab the tab to show
* @private
*/
_setActiveTab(tab) {
private _setActiveTab(tab: Tab) {
const idx = this.props.tabs.indexOf(tab);
if (idx !== -1) {
this.setState({activeTabIndex: idx});
@ -71,7 +85,7 @@ export default class TabbedView extends React.Component {
}
}
_renderTabLabel(tab) {
private _renderTabLabel(tab: Tab) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let classes = "mx_TabbedView_tabLabel ";
@ -97,17 +111,17 @@ export default class TabbedView extends React.Component {
);
}
_renderTabPanel(tab) {
private _renderTabPanel(tab: Tab): React.ReactNode {
return (
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
<div className='mx_TabbedView_tabPanelContent'>
<AutoHideScrollbar className='mx_TabbedView_tabPanelContent'>
{tab.body}
</div>
</AutoHideScrollbar>
</div>
);
}
render() {
public render(): React.ReactNode {
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);

View file

@ -28,6 +28,7 @@ import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
const TagPanel = createReactClass({
displayName: 'TagPanel',
@ -43,7 +44,7 @@ const TagPanel = createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this.unmounted = false;
this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync);
@ -106,7 +107,6 @@ const TagPanel = createReactClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const ActionButton = sdk.getComponent('elements.ActionButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
@ -138,9 +138,8 @@ const TagPanel = createReactClass({
{ clearButton }
</div>
<div className="mx_TagPanel_divider" />
<GeminiScrollbarWrapper
<AutoHideScrollbar
className="mx_TagPanel_scroller"
autoshow={true}
// 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/riot-web/issues/6253
onMouseDown={this.onMouseDown}
@ -166,7 +165,7 @@ const TagPanel = createReactClass({
</div>
) }
</Droppable>
</GeminiScrollbarWrapper>
</AutoHideScrollbar>
</div>;
},
});

View file

@ -202,7 +202,8 @@ const TimelinePanel = createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
debuglog("TimelinePanel: mounting");
this.lastRRSentEventId = undefined;
@ -234,7 +235,8 @@ const TimelinePanel = createReactClass({
this._initTimeline(this.props);
},
componentWillReceiveProps: function(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
if (newProps.timelineSet !== this.props.timelineSet) {
// throw new Error("changing timelineSet on a TimelinePanel is not supported");

View file

@ -35,14 +35,17 @@ export default class UserView extends React.Component {
this.state = {};
}
componentWillMount() {
componentDidMount() {
if (this.props.userId) {
this._loadProfileInfo();
}
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
// XXX: We shouldn't need to null check the userId here, but we declare
// it as optional and MatrixChat sometimes fires in a way which results
// in an NPE when we try to update the profile info.
if (prevProps.userId !== this.props.userId && this.props.userId) {
this._loadProfileInfo();
}
}

View file

@ -18,13 +18,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { accessSecretStorage, AccessCancelledError } from '../../../CrossSigningManager';
const PHASE_INTRO = 0;
const PHASE_BUSY = 1;
const PHASE_DONE = 2;
const PHASE_CONFIRM_SKIP = 3;
import {
SetupEncryptionStore,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
} from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody";
export default class CompleteSecurity extends React.Component {
static propTypes = {
@ -33,202 +34,42 @@ export default class CompleteSecurity extends React.Component {
constructor() {
super();
this.state = {
phase: PHASE_INTRO,
// this serves dual purpose as the object for the request logic and
// the presence of it insidicating that we're in 'verify mode'.
// Because of the latter, it lives in the state.
verificationRequest: null,
backupInfo: null,
};
MatrixClientPeg.get().on("crypto.verification.request", this.onVerificationRequest);
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate);
store.start();
this.state = {phase: store.phase};
}
_onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
this.setState({phase: store.phase});
};
componentWillUnmount() {
if (this.state.verificationRequest) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
}
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("crypto.verification.request", this.onVerificationRequest);
}
}
onStartClick = async () => {
this.setState({
phase: PHASE_BUSY,
});
const cli = MatrixClientPeg.get();
const backupInfo = await cli.getKeyBackupVersion();
this.setState({backupInfo});
try {
await accessSecretStorage(async () => {
await cli.checkOwnCrossSigningTrust();
if (backupInfo) await cli.restoreKeyBackupWithSecretStorage(backupInfo);
});
if (cli.getCrossSigningId()) {
this.setState({
phase: PHASE_DONE,
});
}
} catch (e) {
if (!(e instanceof AccessCancelledError)) {
console.log(e);
}
// this will throw if the user hits cancel, so ignore
this.setState({
phase: PHASE_INTRO,
});
}
}
onVerificationRequest = async (request) => {
if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return;
if (this.state.verificationRequest) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
}
await request.accept();
request.on("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: request,
});
}
onVerificationRequestChange = () => {
if (this.state.verificationRequest.cancelled) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: null,
});
}
}
onSkipClick = () => {
this.setState({
phase: PHASE_CONFIRM_SKIP,
});
}
onSkipConfirmClick = () => {
this.props.onFinished();
}
onSkipBackClick = () => {
this.setState({
phase: PHASE_INTRO,
});
}
onDoneClick = () => {
this.props.onFinished();
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate);
store.stop();
}
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {
phase,
} = this.state;
const {phase} = this.state;
let icon;
let title;
let body;
if (this.state.verificationRequest) {
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
body = <EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.props.onFinished}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
/>;
} else if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Complete security");
body = (
<div>
<p>{_t(
"Verify this session to grant it access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="danger"
onClick={this.onSkipClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.onStartClick}
>
{_t("Start")}
</AccessibleButton>
</div>
</div>
);
if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else if (phase === PHASE_DONE) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified"></span>;
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Session verified");
let message;
if (this.state.backupInfo) {
message = <p>{_t(
"Your new session is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
)}</p>;
} else {
message = <p>{_t(
"Your new session is now verified. Other users will see it as trusted.",
)}</p>;
}
body = (
<div>
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified"></div>
{message}
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="primary"
onClick={this.onDoneClick}
>
{_t("Done")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_CONFIRM_SKIP) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Are you sure?");
body = (
<div>
<p>{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
className="warning"
kind="secondary"
onClick={this.onSkipConfirmClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="danger"
onClick={this.onSkipBackClick}
>
{_t("Go Back")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_BUSY) {
const Spinner = sdk.getComponent('views.elements.Spinner');
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Complete security");
body = <Spinner />;
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else {
throw new Error(`Unknown phase ${phase}`);
}
@ -241,7 +82,7 @@ export default class CompleteSecurity extends React.Component {
{title}
</h2>
<div className="mx_CompleteSecurity_body">
{body}
<SetupEncryptionBody onFinished={this.props.onFinished} />
</div>
</CompleteSecurityBody>
</AuthPage>

View file

@ -69,12 +69,13 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this.reset = null;
this._checkServerLiveliness(this.props.serverConfig);
},
componentWillReceiveProps: function(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
@ -296,7 +297,6 @@ export default createReactClass({
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<Field
id="mx_ForgotPassword_email"
name="reset_email" // define a name so browser's password autofill gets less confused
type="text"
label={_t('Email')}
@ -307,7 +307,6 @@ export default createReactClass({
</div>
<div className="mx_AuthBody_fieldRow">
<Field
id="mx_ForgotPassword_password"
name="reset_password"
type="password"
label={_t('Password')}
@ -315,7 +314,6 @@ export default createReactClass({
onChange={this.onInputChanged.bind(this, "password")}
/>
<Field
id="mx_ForgotPassword_passwordConfirm"
name="reset_password_confirm"
type="password"
label={_t('Confirm')}

View file

@ -27,6 +27,8 @@ 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';
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -82,11 +84,13 @@ export default createReactClass({
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
isSyncing: PropTypes.bool,
},
getInitialState: function() {
return {
busy: false,
busyLoggingIn: null,
errorText: null,
loginIncorrect: false,
canTryLogin: true, // can we attempt to log in or are there validation errors?
@ -111,7 +115,8 @@ export default createReactClass({
};
},
componentWillMount: function() {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount: function() {
this._unmounted = false;
// map from login step type to a function which will render a control
@ -120,8 +125,8 @@ export default createReactClass({
'm.login.password': this._renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to
'm.login.cas': () => this._renderSsoStep(this._loginLogic.getSsoLoginUrl("cas")),
'm.login.sso': () => this._renderSsoStep(this._loginLogic.getSsoLoginUrl("sso")),
'm.login.cas': () => this._renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep("sso"),
};
this._initLoginLogic();
@ -131,7 +136,8 @@ export default createReactClass({
this._unmounted = true;
},
componentWillReceiveProps(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
@ -165,6 +171,7 @@ export default createReactClass({
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
this.setState({
busy: false,
busyLoggingIn: false,
...componentState,
});
aliveAgain = !componentState.serverErrorIsFatal;
@ -178,6 +185,7 @@ export default createReactClass({
this.setState({
busy: true,
busyLoggingIn: true,
errorText: null,
loginIncorrect: false,
});
@ -245,6 +253,8 @@ export default createReactClass({
}
this.setState({
busy: false,
busyLoggingIn: false,
errorText: errorText,
// 401 would be the sensible status code for 'incorrect password'
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
@ -252,13 +262,6 @@ export default createReactClass({
// We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
});
}).finally(() => {
if (this._unmounted) {
return;
}
this.setState({
busy: false,
});
});
},
@ -344,6 +347,22 @@ export default createReactClass({
this.props.onRegisterClick();
},
onTryRegisterClick: function(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'.
ev.preventDefault();
ev.stopPropagation();
const ssoKind = step === '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
this.onRegisterClick(ev);
}
},
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_LOGIN,
@ -481,7 +500,7 @@ export default createReactClass({
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noopener"
return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
@ -496,11 +515,10 @@ export default createReactClass({
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
"is not blocking requests.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noopener" href={this.props.serverConfig.hsUrl}>
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>;
},
</a>,
},
) }
</span>;
@ -582,11 +600,12 @@ export default createReactClass({
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
/>
);
},
_renderSsoStep: function(url) {
_renderSsoStep: function(loginType) {
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let onEditServerDetailsClick = null;
@ -607,16 +626,23 @@ export default createReactClass({
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={onEditServerDetailsClick} />
<a href={url} className="mx_Login_sso_link mx_Login_submit">{ _t('Sign in with single sign-on') }</a>
<SSOButton
className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
</div>
);
},
render: function() {
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() ? <div className="mx_Login_loader"><Loader /></div> : null;
const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Loader /></div> : null;
const errorText = this.state.errorText;
@ -643,9 +669,28 @@ export default createReactClass({
);
}
let footer;
if (this.props.isSyncing || this.state.busyLoggingIn) {
footer = <div className="mx_AuthBody_paddedFooter">
<div className="mx_AuthBody_paddedFooter_title">
<InlineSpinner w={20} h={20} />
{ this.props.isSyncing ? _t("Syncing...") : _t("Signing In...") }
</div>
{ this.props.isSyncing && <div className="mx_AuthBody_paddedFooter_subtitle">
{_t("If you've joined lots of rooms, this might take a while")}
</div> }
</div>;
} else {
footer = (
<a className="mx_AuthBody_changeFlow" onClick={this.onTryRegisterClick} href="#">
{ _t('Create account') }
</a>
);
}
return (
<AuthPage>
<AuthHeader />
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
<AuthBody>
<h2>
{_t('Sign in')}
@ -655,9 +700,7 @@ export default createReactClass({
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">
{ _t('Create account') }
</a>
{ footer }
</AuthBody>
</AuthPage>
);

View file

@ -37,7 +37,7 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
// 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).

View file

@ -31,6 +31,8 @@ import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login from "../../../Login";
import dis from "../../../dispatcher";
// Phases
// Show controls to configure server details
@ -118,12 +120,13 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this._unmounted = false;
this._replaceClient();
},
componentWillReceiveProps(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
@ -232,6 +235,13 @@ export default createReactClass({
serverRequiresIdServer,
busy: false,
});
const showGenericError = (e) => {
this.setState({
errorText: _t("Unable to query for supported registration methods."),
// add empty flows array to get rid of spinner
flows: [],
});
};
try {
await this._makeRegisterRequest({});
// This should never succeed since we specified an empty
@ -243,18 +253,33 @@ export default createReactClass({
flows: e.data.flows,
});
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") {
this.setState({
errorText: _t("Registration has been disabled on this homeserver."),
// add empty flows array to get rid of spinner
flows: [],
});
// 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: "riot login check", // We shouldn't ever be used
});
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);
this.setState({
errorText: _t("Unable to query for supported registration methods."),
// add empty flows array to get rid of spinner
flows: [],
});
showGenericError(e);
}
}
},
@ -439,7 +464,7 @@ export default createReactClass({
initial_device_display_name: this.props.defaultDeviceDisplayName,
};
if (auth) registerParams.auth = auth;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibitLogin = inhibitLogin;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
return this.state.matrixClient.registerRequest(registerParams);
},

View file

@ -0,0 +1,200 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
PHASE_FINISHED,
} from '../../../stores/SetupEncryptionStore';
export default class SetupEncryptionBody extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
constructor() {
super();
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate);
store.start();
this.state = {
phase: store.phase,
// this serves dual purpose as the object for the request logic and
// the presence of it indicating that we're in 'verify mode'.
// Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
};
}
_onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === PHASE_FINISHED) {
this.props.onFinished();
return;
}
this.setState({
phase: store.phase,
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
});
};
componentWillUnmount() {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate);
store.stop();
}
_onUsePassphraseClick = async () => {
const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase();
}
onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
}
onSkipConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm();
}
onSkipBackClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterSkip();
}
onDoneClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
}
render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {
phase,
} = this.state;
if (this.state.verificationRequest) {
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
return <EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.props.onFinished}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
/>;
} else if (phase === PHASE_INTRO) {
return (
<div>
<p>{_t(
"Confirm your identity by verifying this login from one of your other sessions, " +
"granting it access to encrypted messages.",
)}</p>
<p>{_t(
"This requires the latest Riot on your other devices:",
)}</p>
<div className="mx_CompleteSecurity_clients">
<div className="mx_CompleteSecurity_clients_desktop">
<div>Riot Web</div>
<div>Riot Desktop</div>
</div>
<div className="mx_CompleteSecurity_clients_mobile">
<div>Riot iOS</div>
<div>Riot X for Android</div>
</div>
<p>{_t("or another cross-signing capable Matrix client")}</p>
</div>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="link" onClick={this._onUsePassphraseClick}>
{_t("Use Recovery Passphrase or Key")}
</AccessibleButton>
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
{_t("Skip")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_DONE) {
let message;
if (this.state.backupInfo) {
message = <p>{_t(
"Your new session is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
)}</p>;
} else {
message = <p>{_t(
"Your new session is now verified. Other users will see it as trusted.",
)}</p>;
}
return (
<div>
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified" />
{message}
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="primary"
onClick={this.onDoneClick}
>
{_t("Done")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_CONFIRM_SKIP) {
return (
<div>
<p>{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
className="warning"
kind="secondary"
onClick={this.onSkipConfirmClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="danger"
onClick={this.onSkipBackClick}
>
{_t("Go Back")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_BUSY) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <Spinner />;
} else {
console.log(`SetupEncryptionBody: Unknown phase ${phase}`);
}
}
}

View file

@ -23,8 +23,8 @@ import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import url from 'url';
import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
const LOGIN_VIEW = {
LOADING: 1,
@ -54,8 +54,7 @@ export default class SoftLogout extends React.Component {
this.state = {
loginView: LOGIN_VIEW.LOADING,
keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount)
ssoUrl: null,
keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount)
busy: false,
password: "",
@ -105,18 +104,6 @@ export default class SoftLogout extends React.Component {
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
this.setState({loginView: chosenView});
if (chosenView === LOGIN_VIEW.CAS || chosenView === LOGIN_VIEW.SSO) {
const client = MatrixClientPeg.get();
const appUrl = url.parse(window.location.href, true);
appUrl.hash = ""; // Clear #/soft_logout off the URL
appUrl.query["homeserver"] = client.getHomeserverUrl();
appUrl.query["identityServer"] = client.getIdentityServerUrl();
const ssoUrl = client.getSsoLoginUrl(url.format(appUrl), chosenView === LOGIN_VIEW.CAS ? "cas" : "sso");
this.setState({ssoUrl});
}
}
onPasswordChange = (ev) => {
@ -195,14 +182,6 @@ export default class SoftLogout extends React.Component {
});
}
onSsoLogin = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({busy: true});
window.location.href = this.state.ssoUrl;
};
_renderSignInSection() {
if (this.state.loginView === LOGIN_VIEW.LOADING) {
const Spinner = sdk.getComponent("elements.Spinner");
@ -234,7 +213,6 @@ export default class SoftLogout extends React.Component {
<p>{introText}</p>
{error}
<Field
id="softlogout_password"
type="password"
label={_t("Password")}
onChange={this.onPasswordChange}
@ -257,8 +235,6 @@ export default class SoftLogout extends React.Component {
}
if (this.state.loginView === LOGIN_VIEW.SSO || this.state.loginView === LOGIN_VIEW.CAS) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
if (!introText) {
introText = _t("Sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
@ -266,9 +242,11 @@ export default class SoftLogout extends React.Component {
return (
<div>
<p>{introText}</p>
<AccessibleButton kind='primary' onClick={this.onSsoLogin}>
{_t('Sign in with single sign-on')}
</AccessibleButton>
<SSOButton
matrixClient={MatrixClientPeg.get()}
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
</div>
);
}

View file

@ -26,7 +26,7 @@ export default createReactClass({
render: function() {
return (
<div className="mx_AuthFooter">
<a href="https://matrix.org" target="_blank" rel="noopener">{ _t("powered by Matrix") }</a>
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a>
</div>
);
},

View file

@ -16,12 +16,17 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as sdk from '../../../index';
export default createReactClass({
displayName: 'AuthHeader',
propTypes: {
disableLanguageSelector: PropTypes.bool,
},
render: function() {
const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo');
const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector');
@ -29,7 +34,7 @@ export default createReactClass({
return (
<div className="mx_AuthHeader">
<AuthHeaderLogo />
<LanguageSelector />
<LanguageSelector disabled={this.props.disableLanguageSelector} />
</div>
);
},

View file

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

View file

@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { COUNTRIES } from '../../../phonenumber';
import {COUNTRIES, getEmojiFlag} from '../../../phonenumber';
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
@ -60,7 +60,7 @@ export default class CountryDropdown extends React.Component {
};
}
componentWillMount() {
componentDidMount() {
if (!this.props.value) {
// If no value is given, we start with the default
// country selected, but our parent component
@ -80,7 +80,7 @@ export default class CountryDropdown extends React.Component {
}
_flagImgForIso2(iso2) {
return <img src={require(`../../../../res/img/flags/${iso2}.png`)} />;
return <div className="mx_Dropdown_option_emoji">{ getEmojiFlag(iso2) }</div>;
}
_getShortOption(iso2) {

View file

@ -1,7 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -25,6 +25,7 @@ import classnames from 'classnames';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -59,11 +60,21 @@ import SettingsStore from "../../../settings/SettingsStore";
* session to be failed and the process to go back to the start.
* setEmailSid: m.login.email.identity only: a function to be called with the
* email sid after a token is requested.
* onPhaseChange: A function which is called when the stage's phase changes. If
* the stage has no phases, call this with DEFAULT_PHASE. Takes
* one argument, the phase, and is always defined/required.
* continueText: For stages which have a continue button, the text to use.
* continueKind: For stages which have a continue button, the style of button to
* use. For example, 'danger' or 'primary'.
* onCancel A function with no arguments which is called by the stage if the
* user knowingly cancelled/dismissed the authentication attempt.
*
* Each component may also provide the following functions (beyond the standard React ones):
* focus: set the input focus appropriately in the form.
*/
export const DEFAULT_PHASE = 0;
export const PasswordAuthEntry = createReactClass({
displayName: 'PasswordAuthEntry',
@ -78,6 +89,11 @@ export const PasswordAuthEntry = createReactClass({
// is the auth logic currently waiting for something to
// happen?
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
getInitialState: function() {
@ -145,7 +161,6 @@ export const PasswordAuthEntry = createReactClass({
<p>{ _t("Confirm your identity by entering your account password below.") }</p>
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<Field
id="mx_InteractiveAuthEntryComponents_password"
className={passwordBoxClass}
type="password"
name="passwordField"
@ -176,6 +191,11 @@ export const RecaptchaAuthEntry = createReactClass({
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
_onCaptchaResponse: function(response) {
@ -237,8 +257,14 @@ export const TermsAuthEntry = createReactClass({
errorText: PropTypes.string,
busy: PropTypes.bool,
showContinue: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
// TODO: [REACT-WARNING] Move this to constructor
componentWillMount: function() {
// example stageParams:
//
@ -331,7 +357,7 @@ export const TermsAuthEntry = createReactClass({
checkboxes.push(
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} />
<a href={policy.url} target="_blank" rel="noopener">{ policy.name }</a>
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
</label>,
);
}
@ -379,6 +405,11 @@ export const EmailIdentityAuthEntry = createReactClass({
stageState: PropTypes.object.isRequired,
fail: PropTypes.func.isRequired,
setEmailSid: PropTypes.func.isRequired,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
getInitialState: function() {
@ -421,6 +452,7 @@ export const MsisdnAuthEntry = createReactClass({
clientSecret: PropTypes.func,
submitAuthDict: PropTypes.func.isRequired,
matrixClient: PropTypes.object,
onPhaseChange: PropTypes.func.isRequired,
},
getInitialState: function() {
@ -430,7 +462,9 @@ export const MsisdnAuthEntry = createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
this._submitUrl = null;
this._sid = null;
this._msisdn = null;
@ -565,6 +599,91 @@ export const MsisdnAuthEntry = createReactClass({
},
});
export class SSOAuthEntry extends React.Component {
static propTypes = {
matrixClient: PropTypes.object.isRequired,
authSessionId: PropTypes.string.isRequired,
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
continueText: PropTypes.string,
continueKind: PropTypes.string,
onCancel: PropTypes.func,
};
static LOGIN_TYPE = "m.login.sso";
static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso";
static PHASE_PREAUTH = 1; // button to start SSO
static PHASE_POSTAUTH = 2; // button to confirm SSO completed
_ssoUrl: string;
constructor(props) {
super(props);
// We actually send the user through fallback auth so we don't have to
// deal with a redirect back to us, losing application context.
this._ssoUrl = props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId,
);
this.state = {
phase: SSOAuthEntry.PHASE_PREAUTH,
};
}
componentDidMount(): void {
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
}
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.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
};
onConfirmClick = () => {
this.props.submitAuthDict({});
};
render() {
let continueButton = null;
const cancelButton = (
<AccessibleButton
onClick={this.props.onCancel}
kind={this.props.continueKind ? (this.props.continueKind + '_outline') : 'primary_outline'}
>{_t("Cancel")}</AccessibleButton>
);
if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) {
continueButton = (
<AccessibleButton
onClick={this.onStartAuthClick}
kind={this.props.continueKind || 'primary'}
>{this.props.continueText || _t("Single Sign On")}</AccessibleButton>
);
} else {
continueButton = (
<AccessibleButton
onClick={this.onConfirmClick}
kind={this.props.continueKind || 'primary'}
>{this.props.continueText || _t("Confirm")}</AccessibleButton>
);
}
return <div className='mx_InteractiveAuthEntryComponents_sso_buttons'>
{cancelButton}
{continueButton}
</div>;
}
}
export const FallbackAuthEntry = createReactClass({
displayName: 'FallbackAuthEntry',
@ -574,9 +693,15 @@ export const FallbackAuthEntry = createReactClass({
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
},
componentWillMount: function() {
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
// we have to make the user click a button, as browsers will block
// the popup if we open it immediately.
this._popupWindow = null;
@ -598,12 +723,16 @@ export const FallbackAuthEntry = createReactClass({
}
},
_onShowFallbackClick: function() {
_onShowFallbackClick: function(e) {
e.preventDefault();
e.stopPropagation();
const url = this.props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId,
);
this._popupWindow = window.open(url);
this._popupWindow.opener = null;
},
_onReceiveMessage: function(event) {
@ -626,7 +755,7 @@ export const FallbackAuthEntry = createReactClass({
}
return (
<div>
<a ref={this._fallbackButton} onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
<a href="" ref={this._fallbackButton} onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
{errorSection}
</div>
);
@ -639,11 +768,12 @@ const AuthEntryComponents = [
EmailIdentityAuthEntry,
MsisdnAuthEntry,
TermsAuthEntry,
SSOAuthEntry,
];
export default function getEntryComponentForLoginType(loginType) {
for (const c of AuthEntryComponents) {
if (c.LOGIN_TYPE == loginType) {
if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) {
return c;
}
}

View file

@ -28,12 +28,14 @@ function onChange(newLang) {
}
}
export default function LanguageSelector() {
export default function LanguageSelector({disabled}) {
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
return <LanguageDropdown className="mx_AuthBody_language"
return <LanguageDropdown
className="mx_AuthBody_language"
onOptionChange={onChange}
value={getCurrentLanguage()}
disabled={disabled}
/>;
}

View file

@ -99,14 +99,15 @@ export default class ModularServerConfig extends ServerConfig {
"Enter the location of your Modular homeserver. It may use your own " +
"domain name or be a subdomain of <a>modular.im</a>.",
{}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noopener">
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"
<Field
id="mx_ServerConfig_hsUrl"
label={_t("Server Name")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}

View file

@ -23,6 +23,7 @@ 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.
@ -44,6 +45,7 @@ export default class PasswordLogin extends React.Component {
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
busy: PropTypes.bool,
};
static defaultProps = {
@ -183,7 +185,7 @@ export default class PasswordLogin extends React.Component {
this.props.onPasswordChanged(ev.target.value);
}
renderLoginField(loginType) {
renderLoginField(loginType, autoFocus) {
const Field = sdk.getComponent('elements.Field');
const classes = {};
@ -193,7 +195,6 @@ export default class PasswordLogin extends React.Component {
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
id="mx_PasswordLogin_email"
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
@ -203,13 +204,12 @@ export default class PasswordLogin extends React.Component {
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
id="mx_PasswordLogin_username"
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
@ -218,7 +218,7 @@ export default class PasswordLogin extends React.Component {
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
@ -233,7 +233,6 @@ export default class PasswordLogin extends React.Component {
return <Field
className={classNames(classes)}
id="mx_PasswordLogin_phoneNumber"
name="phoneNumber"
key="phone_input"
type="text"
@ -243,7 +242,7 @@ export default class PasswordLogin extends React.Component {
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus
autoFocus={autoFocus}
/>;
}
}
@ -268,12 +267,16 @@ export default class PasswordLogin extends React.Component {
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => <a className="mx_Login_forgot"
onClick={this.onForgotPasswordClick}
href="#"
>
{sub}
</a>,
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
@ -282,7 +285,10 @@ export default class PasswordLogin extends React.Component {
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
const loginField = this.renderLoginField(this.state.loginType);
// 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) {
@ -290,7 +296,6 @@ export default class PasswordLogin extends React.Component {
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
id="mx_PasswordLogin_type"
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
@ -328,20 +333,20 @@ export default class PasswordLogin extends React.Component {
{loginField}
<Field
className={pwFieldClass}
id="mx_PasswordLogin_password"
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
/>
{forgotPasswordJsx}
<input className="mx_Login_submit"
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/>
/> }
</form>
</div>
);

View file

@ -76,7 +76,7 @@ export default createReactClass({
email: this.props.defaultEmail || "",
phoneNumber: this.props.defaultPhoneNumber || "",
password: this.props.defaultPassword || "",
passwordConfirm: "",
passwordConfirm: this.props.defaultPassword || "",
passwordComplexity: null,
passwordSafe: false,
};
@ -102,11 +102,15 @@ export default createReactClass({
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
);
} else {
} 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?",
);
} else {
// user can't set an e-mail so don't prompt them to
self._doSubmit(ev);
return;
}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
@ -470,7 +474,6 @@ export default createReactClass({
_t("Email") :
_t("Email (optional)");
return <Field
id="mx_RegistrationForm_email"
ref={field => this[FIELD_EMAIL] = field}
type="text"
label={emailPlaceholder}
@ -524,7 +527,6 @@ export default createReactClass({
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
id="mx_RegistrationForm_phoneNumber"
ref={field => this[FIELD_PHONE_NUMBER] = field}
type="text"
label={phoneLabel}

View file

@ -72,7 +72,8 @@ export default class ServerConfig extends React.PureComponent {
};
}
componentWillReceiveProps(newProps) {
// 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;
@ -223,7 +224,8 @@ export default class ServerConfig extends React.PureComponent {
{sub}
</a>,
})}
<Field id="mx_ServerConfig_hsUrl"
<Field
id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
@ -246,7 +248,7 @@ export default class ServerConfig extends React.PureComponent {
{sub}
</a>,
})}
<Field id="mx_ServerConfig_isUrl"
<Field
label={_t("Identity Server URL")}
placeholder={this.props.serverConfig.isUrl}
value={this.state.isUrl || ''}

View file

@ -46,7 +46,7 @@ export const TYPES = {
label: () => _t('Premium'),
logo: () => <img src={require('../../../../res/img/modular-bw-logo.svg')} />,
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noopener">
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
{sub}
</a>,
}),

View file

@ -18,6 +18,12 @@ import React from 'react';
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import AuthPage from "./AuthPage";
import * as Matrix from "matrix-js-sdk";
import {_td} from "../../../languageHandler";
import PlatformPeg from "../../../PlatformPeg";
// translatable strings for Welcome pages
_td("Sign in with SSO");
export default class Welcome extends React.PureComponent {
render() {
@ -33,11 +39,25 @@ export default class Welcome extends React.PureComponent {
pageUrl = 'welcome.html';
}
const {hsUrl, isUrl} = this.props.serverConfig;
const tmpClient = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
const plaf = PlatformPeg.get();
const callbackUrl = plaf.getSSOCallbackUrl(tmpClient.getHomeserverUrl(), tmpClient.getIdentityServerUrl(),
this.props.fragmentAfterLogin);
return (
<AuthPage>
<div className="mx_Welcome">
<EmbeddedPage className="mx_WelcomePage"
<EmbeddedPage
className="mx_WelcomePage"
url={pageUrl}
replaceMap={{
"$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"),
"$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"),
}}
/>
<LanguageSelector />
</div>

View file

@ -24,6 +24,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import toRem from "../../../utils/rem";
const useImageUrl = ({url, urls, idName, name, defaultToInitialLetter}) => {
const [imageUrls, setUrls] = useState([]);
@ -104,9 +105,9 @@ const BaseAvatar = (props) => {
className="mx_BaseAvatar_initial"
aria-hidden="true"
style={{
fontSize: (width * 0.65) + "px",
width: width + "px",
lineHeight: height + "px",
fontSize: toRem(width * 0.65),
width: toRem(width),
lineHeight: toRem(height),
}}
>
{ initialLetter }
@ -119,8 +120,10 @@ const BaseAvatar = (props) => {
alt=""
title={title}
onError={onError}
width={width}
height={height}
style={{
width: toRem(width),
height: toRem(height),
}}
aria-hidden="true" />
);
@ -155,7 +158,10 @@ const BaseAvatar = (props) => {
src={imageUrl}
onClick={onClick}
onError={onError}
width={width} height={height}
style={{
width: toRem(width),
height: toRem(height),
}}
title={title} alt=""
inputRef={inputRef}
{...otherProps} />
@ -166,7 +172,10 @@ const BaseAvatar = (props) => {
className="mx_BaseAvatar mx_BaseAvatar_image"
src={imageUrl}
onError={onError}
width={width} height={height}
style={{
width: toRem(width),
height: toRem(height),
}}
title={title} alt=""
ref={inputRef}
{...otherProps} />

View file

@ -51,7 +51,8 @@ export default createReactClass({
return this._getState(this.props);
},
componentWillReceiveProps: function(nextProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(nextProps) {
this.setState(this._getState(nextProps));
},

View file

@ -49,7 +49,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
this._button = createRef();
}
componentWillMount() {
componentDidMount() {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
}

View file

@ -63,7 +63,8 @@ export default createReactClass({
}
},
componentWillReceiveProps: function(newProps) {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps: function(newProps) {
this.setState({
urls: this.getImageUrls(newProps),
});

View file

@ -61,7 +61,7 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
this._checkPermissions();
},
@ -90,7 +90,8 @@ export default createReactClass({
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
if (!pinnedEvent) return false;
return pinnedEvent.getContent().pinned.includes(this.props.mxEvent.getId());
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
},
onResendClick: function() {
@ -129,22 +130,24 @@ export default createReactClass({
},
onViewSourceClick: function() {
const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent;
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
content: this.props.mxEvent.event,
roomId: ev.getRoomId(),
eventId: ev.getId(),
content: ev.event,
}, 'mx_Dialog_viewsource');
this.closeMenu();
},
onViewClearSourceClick: function() {
const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent;
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
roomId: ev.getRoomId(),
eventId: ev.getId(),
// FIXME: _clearEvent is private
content: this.props.mxEvent._clearEvent,
content: ev._clearEvent,
}, 'mx_Dialog_viewsource');
this.closeMenu();
},
@ -420,7 +423,7 @@ export default createReactClass({
onClick={this.onPermalinkClick}
href={permalink}
target="_blank"
rel="noopener"
rel="noreferrer noopener"
>
{ mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message'
? _t('Share Permalink') : _t('Share Message') }
@ -445,7 +448,7 @@ export default createReactClass({
element="a"
className="mx_MessageContextMenu_field"
target="_blank"
rel="noopener"
rel="noreferrer noopener"
onClick={this.closeMenu}
href={mxEvent.event.content.external_url}
>

View file

@ -82,7 +82,7 @@ export default createReactClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this._unmounted = false;
},

View file

@ -35,7 +35,7 @@ export default class StatusMessageContextMenu extends React.Component {
};
}
componentWillMount() {
componentDidMount() {
const { user } = this.props;
if (!user) {
return;

View file

@ -26,6 +26,7 @@ import { getHostingLink } from '../../../utils/HostingLink';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {MenuItem} from "../../structures/ContextMenu";
import * as sdk from "../../../index";
import {getHomePageUrl} from "../../../utils/pages";
export default class TopLeftMenu extends React.Component {
static propTypes = {
@ -47,15 +48,7 @@ export default class TopLeftMenu extends React.Component {
}
hasHomePage() {
const config = SdkConfig.get();
const pagesConfig = config.embeddedPages;
if (pagesConfig && pagesConfig.homeUrl) {
return true;
}
// This is a deprecated config option for the home page
// (despite the name, given we also now have a welcome
// page, which is not the same).
return !!config.welcomePageUrl;
return !!getHomePageUrl(SdkConfig.get());
}
render() {
@ -68,10 +61,11 @@ export default class TopLeftMenu extends React.Component {
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener" tabIndex={-1}>{sub}</a>,
a: sub =>
<a href={hostingSignupLink} target="_blank" rel="noreferrer noopener" tabIndex={-1}>{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener" role="presentation" aria-hidden={true} tabIndex={-1}>
<a href={hostingSignupLink} target="_blank" rel="noreferrer noopener" role="presentation" aria-hidden={true} tabIndex={-1}>
<img src={require("../../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;

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