Merge remote-tracking branch 'origin/develop' into t3chguy/clear_notifications
This commit is contained in:
commit
96ffe94876
733 changed files with 50889 additions and 9584 deletions
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,7 +17,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import sdk from './index';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
|
||||
function getIdServerDomain() {
|
||||
return MatrixClientPeg.get().idBaseUrl.split("://")[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows a user to add a third party identifier to their homeserver and,
|
||||
|
@ -25,21 +33,25 @@ import { _t } from './languageHandler';
|
|||
* This involves getting an email token from the identity server to "prove" that
|
||||
* the client owns the given email address, which is then passed to the
|
||||
* add threepid API on the homeserver.
|
||||
*
|
||||
* Diagrams of the intended API flows here are available at:
|
||||
*
|
||||
* https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928
|
||||
*/
|
||||
export default class AddThreepid {
|
||||
constructor() {
|
||||
this.clientSecret = MatrixClientPeg.get().generateClientSecret();
|
||||
this.sessionId = null;
|
||||
this.submitUrl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to add an email threepid. This will trigger a side-effect of
|
||||
* sending an email to the provided email address.
|
||||
* Attempt to add an email threepid to the homeserver.
|
||||
* This will trigger a side-effect of sending an email to the provided email address.
|
||||
* @param {string} emailAddress The email address to add
|
||||
* @param {boolean} bind If True, bind this email to this mxid on the Identity Server
|
||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||
*/
|
||||
addEmailAddress(emailAddress, bind) {
|
||||
this.bind = bind;
|
||||
addEmailAddress(emailAddress) {
|
||||
return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
return res;
|
||||
|
@ -54,19 +66,50 @@ export default class AddThreepid {
|
|||
}
|
||||
|
||||
/**
|
||||
* Attempt to add a msisdn threepid. This will trigger a side-effect of
|
||||
* sending a test message to the provided phone number.
|
||||
* Attempt to bind an email threepid on the identity server via the homeserver.
|
||||
* This will trigger a side-effect of sending an email to the provided email address.
|
||||
* @param {string} emailAddress The email address to add
|
||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||
*/
|
||||
async bindEmailAddress(emailAddress) {
|
||||
this.bind = true;
|
||||
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
|
||||
// For separate bind, request a token directly from the IS.
|
||||
const authClient = new IdentityAuthClient();
|
||||
const identityAccessToken = await authClient.getAccessToken();
|
||||
return MatrixClientPeg.get().requestEmailToken(
|
||||
emailAddress, this.clientSecret, 1,
|
||||
undefined, undefined, identityAccessToken,
|
||||
).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
return res;
|
||||
}, function(err) {
|
||||
if (err.errcode === 'M_THREEPID_IN_USE') {
|
||||
err.message = _t('This email address is already in use');
|
||||
} else if (err.httpStatus) {
|
||||
err.message = err.message + ` (Status ${err.httpStatus})`;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
} else {
|
||||
// For tangled bind, request a token via the HS.
|
||||
return this.addEmailAddress(emailAddress);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to add a MSISDN threepid to the homeserver.
|
||||
* This will trigger a side-effect of sending an SMS to the provided phone number.
|
||||
* @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in
|
||||
* @param {string} phoneNumber The national or international formatted phone number to add
|
||||
* @param {boolean} bind If True, bind this phone number to this mxid on the Identity Server
|
||||
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
|
||||
*/
|
||||
addMsisdn(phoneCountry, phoneNumber, bind) {
|
||||
this.bind = bind;
|
||||
addMsisdn(phoneCountry, phoneNumber) {
|
||||
return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
|
||||
phoneCountry, phoneNumber, this.clientSecret, 1,
|
||||
).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
this.submitUrl = res.submit_url;
|
||||
return res;
|
||||
}, function(err) {
|
||||
if (err.errcode === 'M_THREEPID_IN_USE') {
|
||||
|
@ -78,49 +121,185 @@ export default class AddThreepid {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to bind a MSISDN threepid on the identity server via the homeserver.
|
||||
* This will trigger a side-effect of sending an SMS to the provided phone number.
|
||||
* @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in
|
||||
* @param {string} phoneNumber The national or international formatted phone number to add
|
||||
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
|
||||
*/
|
||||
async bindMsisdn(phoneCountry, phoneNumber) {
|
||||
this.bind = true;
|
||||
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
|
||||
// For separate bind, request a token directly from the IS.
|
||||
const authClient = new IdentityAuthClient();
|
||||
const identityAccessToken = await authClient.getAccessToken();
|
||||
return MatrixClientPeg.get().requestMsisdnToken(
|
||||
phoneCountry, phoneNumber, this.clientSecret, 1,
|
||||
undefined, undefined, identityAccessToken,
|
||||
).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
return res;
|
||||
}, function(err) {
|
||||
if (err.errcode === 'M_THREEPID_IN_USE') {
|
||||
err.message = _t('This phone number is already in use');
|
||||
} else if (err.httpStatus) {
|
||||
err.message = err.message + ` (Status ${err.httpStatus})`;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
} else {
|
||||
// For tangled bind, request a token via the HS.
|
||||
return this.addMsisdn(phoneCountry, phoneNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the email link has been clicked by attempting to add the threepid
|
||||
* @return {Promise} Resolves if the email address was added. Rejects with an object
|
||||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the request failed.
|
||||
*/
|
||||
checkEmailLinkClicked() {
|
||||
const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
|
||||
return MatrixClientPeg.get().addThreePid({
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
id_server: identityServerDomain,
|
||||
}, this.bind).catch(function(err) {
|
||||
async checkEmailLinkClicked() {
|
||||
try {
|
||||
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
|
||||
if (this.bind) {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const identityAccessToken = await authClient.getAccessToken();
|
||||
await MatrixClientPeg.get().bindThreePid({
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
id_server: getIdServerDomain(),
|
||||
id_access_token: identityAccessToken,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await this._makeAddThreepidOnlyRequest();
|
||||
|
||||
// The spec has always required this to use UI auth but synapse briefly
|
||||
// implemented it without, so this may just succeed and that's OK.
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e.httpStatus !== 401 || !e.data || !e.data.flows) {
|
||||
// doesn't look like an interactive-auth failure
|
||||
throw e;
|
||||
}
|
||||
|
||||
// pop up an interactive auth dialog
|
||||
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
|
||||
|
||||
const { finished } = Modal.createTrackedDialog('Add Email', '', InteractiveAuthDialog, {
|
||||
title: _t("Add Email Address"),
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
authData: e.data,
|
||||
makeRequest: this._makeAddThreepidOnlyRequest,
|
||||
});
|
||||
return finished;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await MatrixClientPeg.get().addThreePid({
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
id_server: getIdServerDomain(),
|
||||
}, this.bind);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.httpStatus === 401) {
|
||||
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
|
||||
} else if (err.httpStatus) {
|
||||
err.message += ` (Status ${err.httpStatus})`;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} auth UI auth object
|
||||
* @return {Promise<Object>} Response from /3pid/add call (in current spec, an empty object)
|
||||
*/
|
||||
_makeAddThreepidOnlyRequest = (auth) => {
|
||||
return MatrixClientPeg.get().addThreePidOnly({
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a phone number verification code as entered by the user and validates
|
||||
* it with the ID server, then if successful, adds the phone number.
|
||||
* @param {string} token phone number verification code as entered by the user
|
||||
* @param {string} msisdnToken phone number verification code as entered by the user
|
||||
* @return {Promise} Resolves if the phone number was added. Rejects with an object
|
||||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the request failed.
|
||||
*/
|
||||
haveMsisdnToken(token) {
|
||||
return MatrixClientPeg.get().submitMsisdnToken(
|
||||
this.sessionId, this.clientSecret, token,
|
||||
).then((result) => {
|
||||
if (result.errcode) {
|
||||
throw result;
|
||||
async haveMsisdnToken(msisdnToken) {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const supportsSeparateAddAndBind =
|
||||
await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind();
|
||||
|
||||
let result;
|
||||
if (this.submitUrl) {
|
||||
result = await MatrixClientPeg.get().submitMsisdnTokenOtherUrl(
|
||||
this.submitUrl,
|
||||
this.sessionId,
|
||||
this.clientSecret,
|
||||
msisdnToken,
|
||||
);
|
||||
} else if (this.bind || !supportsSeparateAddAndBind) {
|
||||
result = await MatrixClientPeg.get().submitMsisdnToken(
|
||||
this.sessionId,
|
||||
this.clientSecret,
|
||||
msisdnToken,
|
||||
await authClient.getAccessToken(),
|
||||
);
|
||||
} else {
|
||||
throw new Error("The add / bind with MSISDN flow is misconfigured");
|
||||
}
|
||||
if (result.errcode) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
if (supportsSeparateAddAndBind) {
|
||||
if (this.bind) {
|
||||
await MatrixClientPeg.get().bindThreePid({
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
id_server: getIdServerDomain(),
|
||||
id_access_token: await authClient.getAccessToken(),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await this._makeAddThreepidOnlyRequest();
|
||||
|
||||
// The spec has always required this to use UI auth but synapse briefly
|
||||
// implemented it without, so this may just succeed and that's OK.
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e.httpStatus !== 401 || !e.data || !e.data.flows) {
|
||||
// doesn't look like an interactive-auth failure
|
||||
throw e;
|
||||
}
|
||||
|
||||
// pop up an interactive auth dialog
|
||||
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
|
||||
|
||||
const { finished } = Modal.createTrackedDialog('Add MSISDN', '', InteractiveAuthDialog, {
|
||||
title: _t("Add Phone Number"),
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
authData: e.data,
|
||||
makeRequest: this._makeAddThreepidOnlyRequest,
|
||||
});
|
||||
return finished;
|
||||
}
|
||||
}
|
||||
const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
|
||||
return MatrixClientPeg.get().addThreePid({
|
||||
} else {
|
||||
await MatrixClientPeg.get().addThreePid({
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
id_server: identityServerDomain,
|
||||
id_server: getIdServerDomain(),
|
||||
}, this.bind);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
'use strict';
|
||||
import {ContentRepo} from 'matrix-js-sdk';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
|
||||
module.exports = {
|
||||
avatarUrlForMember: function(member, width, height, resizeMethod) {
|
||||
|
@ -58,4 +59,76 @@ module.exports = {
|
|||
}
|
||||
return require('../res/img/' + images[total % images.length] + '.png');
|
||||
},
|
||||
|
||||
/**
|
||||
* returns the first (non-sigil) character of 'name',
|
||||
* converted to uppercase
|
||||
* @param {string} name
|
||||
* @return {string} the first letter
|
||||
*/
|
||||
getInitialLetter(name) {
|
||||
if (!name) {
|
||||
// XXX: We should find out what causes the name to sometimes be falsy.
|
||||
console.trace("`name` argument to `getInitialLetter` not supplied");
|
||||
return undefined;
|
||||
}
|
||||
if (name.length < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
const initial = name[0];
|
||||
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
|
||||
idx++;
|
||||
}
|
||||
|
||||
// string.codePointAt(0) would do this, but that isn't supported by
|
||||
// some browsers (notably PhantomJS).
|
||||
let chars = 1;
|
||||
const first = name.charCodeAt(idx);
|
||||
|
||||
// check if it’s the start of a surrogate pair
|
||||
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
|
||||
const second = name.charCodeAt(idx+1);
|
||||
if (second >= 0xDC00 && second <= 0xDFFF) {
|
||||
chars++;
|
||||
}
|
||||
}
|
||||
|
||||
const firstChar = name.substring(idx, idx+chars);
|
||||
return firstChar.toUpperCase();
|
||||
},
|
||||
|
||||
avatarUrlForRoom(room, width, height, resizeMethod) {
|
||||
const explicitRoomAvatar = room.getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
width,
|
||||
height,
|
||||
resizeMethod,
|
||||
false,
|
||||
);
|
||||
if (explicitRoomAvatar) {
|
||||
return explicitRoomAvatar;
|
||||
}
|
||||
|
||||
let otherMember = null;
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (otherUserId) {
|
||||
otherMember = room.getMember(otherUserId);
|
||||
} else {
|
||||
// if the room is not marked as a 1:1, but only has max 2 members
|
||||
// then still try to show any avatar (pref. other member)
|
||||
otherMember = room.getAvatarFallbackMember();
|
||||
}
|
||||
if (otherMember) {
|
||||
return otherMember.getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
width,
|
||||
height,
|
||||
resizeMethod,
|
||||
false,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import dis from './dispatcher';
|
||||
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
|
||||
|
||||
/**
|
||||
* Base class for classes that provide platform-specific functionality
|
||||
|
@ -36,6 +37,7 @@ export default class BasePlatform {
|
|||
|
||||
_onAction(payload: Object) {
|
||||
switch (payload.action) {
|
||||
case 'on_client_not_viable':
|
||||
case 'on_logged_out':
|
||||
this.setNotificationCount(0);
|
||||
break;
|
||||
|
@ -127,6 +129,18 @@ export default class BasePlatform {
|
|||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
supportsAutoHideMenuBar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getAutoHideMenuBarEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setAutoHideMenuBarEnabled(enabled: boolean): void {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
supportsMinimizeToTray(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
@ -138,4 +152,14 @@ export default class BasePlatform {
|
|||
async setMinimizeToTrayEnabled(enabled: boolean): void {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get our platform specific EventIndexManager.
|
||||
*
|
||||
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
|
||||
* can be null if the platform doesn't support event indexing.
|
||||
*/
|
||||
getEventIndexingManager(): BaseEventIndexManager | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -63,7 +64,8 @@ import SdkConfig from './SdkConfig';
|
|||
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
import ScalarAuthClient from './ScalarAuthClient';
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import SettingsStore, { SettingLevel } from './settings/SettingsStore';
|
||||
|
||||
global.mxCalls = {
|
||||
//room_id: MatrixCall
|
||||
|
@ -78,13 +80,26 @@ function play(audioId) {
|
|||
// which listens?
|
||||
const audio = document.getElementById(audioId);
|
||||
if (audio) {
|
||||
const playAudio = async () => {
|
||||
try {
|
||||
// This still causes the chrome debugger to break on promise rejection if
|
||||
// the promise is rejected, even though we're catching the exception.
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
// This is usually because the user hasn't interacted with the document,
|
||||
// or chrome doesn't think so and is denying the request. Not sure what
|
||||
// we can really do here...
|
||||
// https://github.com/vector-im/riot-web/issues/7657
|
||||
console.log("Unable to play audio clip", e);
|
||||
}
|
||||
};
|
||||
if (audioPromises[audioId]) {
|
||||
audioPromises[audioId] = audioPromises[audioId].then(()=>{
|
||||
audio.load();
|
||||
return audio.play();
|
||||
return playAudio();
|
||||
});
|
||||
} else {
|
||||
audioPromises[audioId] = audio.play();
|
||||
audioPromises[audioId] = playAudio();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,8 +132,7 @@ function _reAttemptCall(call) {
|
|||
|
||||
function _setCallListeners(call) {
|
||||
call.on("error", function(err) {
|
||||
console.error("Call error: %s", err);
|
||||
console.error(err.stack);
|
||||
console.error("Call error:", err);
|
||||
if (err.code === 'unknown_devices') {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
||||
|
@ -146,8 +160,15 @@ function _setCallListeners(call) {
|
|||
},
|
||||
});
|
||||
} else {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
if (
|
||||
MatrixClientPeg.get().getTurnServers().length === 0 &&
|
||||
SettingsStore.getValue("fallbackICEServerAllowed") === null
|
||||
) {
|
||||
_showICEFallbackPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
title: _t('Call Failed'),
|
||||
description: err.message,
|
||||
|
@ -197,7 +218,7 @@ function _setCallListeners(call) {
|
|||
|
||||
function _setCallState(call, roomId, status) {
|
||||
console.log(
|
||||
"Call state in %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-"),
|
||||
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
|
||||
);
|
||||
calls[roomId] = call;
|
||||
|
||||
|
@ -217,6 +238,36 @@ function _setCallState(call, roomId, status) {
|
|||
});
|
||||
}
|
||||
|
||||
function _showICEFallbackPrompt() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const code = sub => <code>{sub}</code>;
|
||||
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
|
||||
title: _t("Call failed due to misconfigured server"),
|
||||
description: <div>
|
||||
<p>{_t(
|
||||
"Please ask the administrator of your homeserver " +
|
||||
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
|
||||
"order for calls to work reliably.",
|
||||
{ homeserverDomain: cli.getDomain() }, { code },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Alternatively, you can try to use the public server at " +
|
||||
"<code>turn.matrix.org</code>, but this will not be as reliable, and " +
|
||||
"it will share your IP address with that server. You can also manage " +
|
||||
"this in Settings.",
|
||||
null, { code },
|
||||
)}</p>
|
||||
</div>,
|
||||
button: _t('Try using turn.matrix.org'),
|
||||
cancelButton: _t('OK'),
|
||||
onFinished: (allow) => {
|
||||
SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
|
||||
cli.setFallbackICEServerAllowed(allow);
|
||||
},
|
||||
}, null, true);
|
||||
}
|
||||
|
||||
function _onAction(payload) {
|
||||
function placeCall(newCall) {
|
||||
_setCallListeners(newCall);
|
||||
|
@ -284,7 +335,7 @@ function _onAction(payload) {
|
|||
});
|
||||
return;
|
||||
} else if (members.length === 2) {
|
||||
console.log("Place %s call in %s", payload.type, payload.room_id);
|
||||
console.info("Place %s call in %s", payload.type, payload.room_id);
|
||||
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
|
||||
placeCall(call);
|
||||
} else { // > 2
|
||||
|
@ -299,7 +350,7 @@ function _onAction(payload) {
|
|||
}
|
||||
break;
|
||||
case 'place_conference_call':
|
||||
console.log("Place conference call in %s", payload.room_id);
|
||||
console.info("Place conference call in %s", payload.room_id);
|
||||
_startCallApp(payload.room_id, payload.type);
|
||||
break;
|
||||
case 'incoming_call':
|
||||
|
@ -344,24 +395,28 @@ function _onAction(payload) {
|
|||
}
|
||||
|
||||
async function _startCallApp(roomId, type) {
|
||||
// check for a working intgrations manager. Technically we could put
|
||||
// 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 scalarClient = new ScalarAuthClient();
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
let haveScalar = false;
|
||||
try {
|
||||
await scalarClient.connect();
|
||||
haveScalar = scalarClient.hasCredentials();
|
||||
} catch (e) {
|
||||
// fall through
|
||||
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 intgrations server is not available'),
|
||||
description: _t('A conference call could not be started because the integrations server is not available'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -421,7 +476,8 @@ async function _startCallApp(roomId, type) {
|
|||
// URL, but this will at least allow the integration manager to not be hardcoded.
|
||||
widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString;
|
||||
} else {
|
||||
widgetUrl = SdkConfig.get().integrations_rest_url + '/widgets/jitsi.html?' + queryString;
|
||||
const apiUrl = IntegrationManagers.sharedInstance().getPrimaryManager().apiUrl;
|
||||
widgetUrl = apiUrl + '/widgets/jitsi.html?' + queryString;
|
||||
}
|
||||
|
||||
const widgetData = { widgetSessionId };
|
||||
|
@ -452,6 +508,17 @@ async function _startCallApp(roomId, type) {
|
|||
// with the dispatcher once
|
||||
if (!global.mxCallHandler) {
|
||||
dis.register(_onAction);
|
||||
// add empty handlers for media actions, otherwise the media keys
|
||||
// end up causing the audio elements with our ring/ringback etc
|
||||
// audio clips in to play.
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.setActionHandler('play', function() {});
|
||||
navigator.mediaSession.setActionHandler('pause', function() {});
|
||||
navigator.mediaSession.setActionHandler('seekbackward', function() {});
|
||||
navigator.mediaSession.setActionHandler('seekforward', function() {});
|
||||
navigator.mediaSession.setActionHandler('previoustrack', function() {});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', function() {});
|
||||
}
|
||||
}
|
||||
|
||||
const callHandler = {
|
||||
|
|
|
@ -18,6 +18,11 @@ import * as Matrix from 'matrix-js-sdk';
|
|||
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
|
||||
|
||||
export default {
|
||||
hasAnyLabeledDevices: async function() {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.some(d => !!d.label);
|
||||
},
|
||||
|
||||
getDevices: function() {
|
||||
// Only needed for Electron atm, though should work in modern browsers
|
||||
// once permission has been granted to the webapp
|
||||
|
@ -26,8 +31,6 @@ export default {
|
|||
const audioinput = [];
|
||||
const videoinput = [];
|
||||
|
||||
if (devices.some((device) => !device.label)) return false;
|
||||
|
||||
devices.forEach((device) => {
|
||||
switch (device.kind) {
|
||||
case 'audiooutput': audiooutput.push(device); break;
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
|
||||
'use strict';
|
||||
|
||||
import Promise from 'bluebird';
|
||||
import extend from './extend';
|
||||
import dis from './dispatcher';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
|
@ -59,40 +58,38 @@ export class UploadCanceledError extends Error {}
|
|||
* and a thumbnail key.
|
||||
*/
|
||||
function createThumbnail(element, inputWidth, inputHeight, mimeType) {
|
||||
const deferred = Promise.defer();
|
||||
return new Promise((resolve) => {
|
||||
let targetWidth = inputWidth;
|
||||
let targetHeight = inputHeight;
|
||||
if (targetHeight > MAX_HEIGHT) {
|
||||
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
|
||||
targetHeight = MAX_HEIGHT;
|
||||
}
|
||||
if (targetWidth > MAX_WIDTH) {
|
||||
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
|
||||
targetWidth = MAX_WIDTH;
|
||||
}
|
||||
|
||||
let targetWidth = inputWidth;
|
||||
let targetHeight = inputHeight;
|
||||
if (targetHeight > MAX_HEIGHT) {
|
||||
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
|
||||
targetHeight = MAX_HEIGHT;
|
||||
}
|
||||
if (targetWidth > MAX_WIDTH) {
|
||||
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
|
||||
targetWidth = MAX_WIDTH;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
canvas.toBlob(function(thumbnail) {
|
||||
deferred.resolve({
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
canvas.toBlob(function(thumbnail) {
|
||||
resolve({
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
},
|
||||
thumbnail: thumbnail,
|
||||
});
|
||||
}, mimeType);
|
||||
|
||||
return deferred.promise;
|
||||
thumbnail: thumbnail,
|
||||
});
|
||||
}, mimeType);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -179,30 +176,29 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
|
|||
* @return {Promise} A promise that resolves with the video image element.
|
||||
*/
|
||||
function loadVideoElement(videoFile) {
|
||||
const deferred = Promise.defer();
|
||||
return new Promise((resolve, reject) => {
|
||||
// Load the file into an html element
|
||||
const video = document.createElement("video");
|
||||
|
||||
// Load the file into an html element
|
||||
const video = document.createElement("video");
|
||||
const reader = new FileReader();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
video.src = e.target.result;
|
||||
reader.onload = function(e) {
|
||||
video.src = e.target.result;
|
||||
|
||||
// Once ready, returns its size
|
||||
// Wait until we have enough data to thumbnail the first frame.
|
||||
video.onloadeddata = function() {
|
||||
deferred.resolve(video);
|
||||
// Once ready, returns its size
|
||||
// Wait until we have enough data to thumbnail the first frame.
|
||||
video.onloadeddata = function() {
|
||||
resolve(video);
|
||||
};
|
||||
video.onerror = function(e) {
|
||||
reject(e);
|
||||
};
|
||||
};
|
||||
video.onerror = function(e) {
|
||||
deferred.reject(e);
|
||||
reader.onerror = function(e) {
|
||||
reject(e);
|
||||
};
|
||||
};
|
||||
reader.onerror = function(e) {
|
||||
deferred.reject(e);
|
||||
};
|
||||
reader.readAsDataURL(videoFile);
|
||||
|
||||
return deferred.promise;
|
||||
reader.readAsDataURL(videoFile);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -236,16 +232,16 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
|
|||
* is read.
|
||||
*/
|
||||
function readFileAsArrayBuffer(file) {
|
||||
const deferred = Promise.defer();
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
deferred.resolve(e.target.result);
|
||||
};
|
||||
reader.onerror = function(e) {
|
||||
deferred.reject(e);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
return deferred.promise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
resolve(e.target.result);
|
||||
};
|
||||
reader.onerror = function(e) {
|
||||
reject(e);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -425,19 +421,25 @@ export default class ContentMessages {
|
|||
}
|
||||
|
||||
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
|
||||
let uploadAll = false;
|
||||
for (let i = 0; i < okFiles.length; ++i) {
|
||||
const file = okFiles[i];
|
||||
const shouldContinue = await new Promise((resolve) => {
|
||||
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
|
||||
file,
|
||||
currentIndex: i,
|
||||
totalFiles: okFiles.length,
|
||||
onFinished: (shouldContinue) => {
|
||||
resolve(shouldContinue);
|
||||
},
|
||||
if (!uploadAll) {
|
||||
const shouldContinue = await new Promise((resolve) => {
|
||||
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
|
||||
file,
|
||||
currentIndex: i,
|
||||
totalFiles: okFiles.length,
|
||||
onFinished: (shouldContinue, shouldUploadAll) => {
|
||||
if (shouldUploadAll) {
|
||||
uploadAll = true;
|
||||
}
|
||||
resolve(shouldContinue);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
if (!shouldContinue) break;
|
||||
if (!shouldContinue) break;
|
||||
}
|
||||
this._sendContentToRoom(file, roomId, matrixClient);
|
||||
}
|
||||
}
|
||||
|
@ -455,33 +457,34 @@ export default class ContentMessages {
|
|||
content.info.mimetype = file.type;
|
||||
}
|
||||
|
||||
const def = Promise.defer();
|
||||
if (file.type.indexOf('image/') == 0) {
|
||||
content.msgtype = 'm.image';
|
||||
infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{
|
||||
extend(content.info, imageInfo);
|
||||
def.resolve();
|
||||
}, (error)=>{
|
||||
console.error(error);
|
||||
const prom = new Promise((resolve) => {
|
||||
if (file.type.indexOf('image/') == 0) {
|
||||
content.msgtype = 'm.image';
|
||||
infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{
|
||||
extend(content.info, imageInfo);
|
||||
resolve();
|
||||
}, (error)=>{
|
||||
console.error(error);
|
||||
content.msgtype = 'm.file';
|
||||
resolve();
|
||||
});
|
||||
} else if (file.type.indexOf('audio/') == 0) {
|
||||
content.msgtype = 'm.audio';
|
||||
resolve();
|
||||
} else if (file.type.indexOf('video/') == 0) {
|
||||
content.msgtype = 'm.video';
|
||||
infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{
|
||||
extend(content.info, videoInfo);
|
||||
resolve();
|
||||
}, (error)=>{
|
||||
content.msgtype = 'm.file';
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
content.msgtype = 'm.file';
|
||||
def.resolve();
|
||||
});
|
||||
} else if (file.type.indexOf('audio/') == 0) {
|
||||
content.msgtype = 'm.audio';
|
||||
def.resolve();
|
||||
} else if (file.type.indexOf('video/') == 0) {
|
||||
content.msgtype = 'm.video';
|
||||
infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{
|
||||
extend(content.info, videoInfo);
|
||||
def.resolve();
|
||||
}, (error)=>{
|
||||
content.msgtype = 'm.file';
|
||||
def.resolve();
|
||||
});
|
||||
} else {
|
||||
content.msgtype = 'm.file';
|
||||
def.resolve();
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const upload = {
|
||||
fileName: file.name || 'Attachment',
|
||||
|
@ -503,7 +506,7 @@ export default class ContentMessages {
|
|||
dis.dispatch({action: 'upload_progress', upload: upload});
|
||||
}
|
||||
|
||||
return def.promise.then(function() {
|
||||
return prom.then(function() {
|
||||
// XXX: upload.promise must be the promise that
|
||||
// is returned by uploadFile as it has an abort()
|
||||
// method hacked onto it.
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Travis Ralston
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,9 +18,12 @@ limitations under the License.
|
|||
|
||||
import URL from 'url';
|
||||
import dis from './dispatcher';
|
||||
import IntegrationManager from './IntegrationManager';
|
||||
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
|
||||
import ActiveWidgetStore from './stores/ActiveWidgetStore';
|
||||
import MatrixClientPeg from "./MatrixClientPeg";
|
||||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
|
||||
const WIDGET_API_VERSION = '0.0.2'; // Current API version
|
||||
const SUPPORTED_WIDGET_API_VERSIONS = [
|
||||
|
@ -189,7 +193,21 @@ export default class FromWidgetPostMessageApi {
|
|||
const data = event.data.data || event.data.widgetData;
|
||||
const integType = (data && data.integType) ? data.integType : null;
|
||||
const integId = (data && data.integId) ? data.integId : null;
|
||||
IntegrationManager.open(integType, integId);
|
||||
|
||||
// TODO: Open the right integration manager for the widget
|
||||
if (SettingsStore.isFeatureEnabled("feature_many_integration_managers")) {
|
||||
IntegrationManagers.sharedInstance().openAll(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
} else {
|
||||
IntegrationManagers.sharedInstance().getPrimaryManager().open(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
}
|
||||
} else if (action === 'set_always_on_screen') {
|
||||
// This is a new message: there is no reason to support the deprecated widgetData here
|
||||
const data = event.data.data;
|
||||
|
|
|
@ -21,6 +21,7 @@ import MultiInviter from './utils/MultiInviter';
|
|||
import { _t } from './languageHandler';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import GroupStore from './stores/GroupStore';
|
||||
import {allSettled} from "./utils/promise";
|
||||
|
||||
export function showGroupInviteDialog(groupId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -38,7 +39,7 @@ export function showGroupInviteDialog(groupId) {
|
|||
Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, {
|
||||
title: _t("Invite new community members"),
|
||||
description: description,
|
||||
placeholder: _t("Name or matrix ID"),
|
||||
placeholder: _t("Name or Matrix ID"),
|
||||
button: _t("Invite to Community"),
|
||||
validAddressTypes: ['mx-user-id'],
|
||||
onFinished: (success, addrs) => {
|
||||
|
@ -46,7 +47,7 @@ export function showGroupInviteDialog(groupId) {
|
|||
|
||||
_onGroupInviteFinished(groupId, addrs).then(resolve, reject);
|
||||
},
|
||||
});
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -61,7 +62,7 @@ export function showGroupAddRoomDialog(groupId) {
|
|||
</div>;
|
||||
|
||||
const checkboxContainer = <label className="mx_GroupAddressPicker_checkboxContainer">
|
||||
<input type="checkbox" onClick={onCheckboxClicked} />
|
||||
<input type="checkbox" onChange={onCheckboxClicked} />
|
||||
<div>
|
||||
{ _t("Show these rooms to non-members on the community page and room list?") }
|
||||
</div>
|
||||
|
@ -81,7 +82,7 @@ export function showGroupAddRoomDialog(groupId) {
|
|||
|
||||
_onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly).then(resolve, reject);
|
||||
},
|
||||
});
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -118,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) {
|
|||
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
const errorList = [];
|
||||
return Promise.all(addrs.map((addr) => {
|
||||
return allSettled(addrs.map((addr) => {
|
||||
return GroupStore
|
||||
.addRoomToGroup(groupId, addr.address, addRoomsPublicly)
|
||||
.catch(() => { errorList.push(addr.address); })
|
||||
|
@ -138,7 +139,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
|
|||
groups.push(groupId);
|
||||
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, '');
|
||||
}
|
||||
}).reflect();
|
||||
});
|
||||
})).then(() => {
|
||||
if (errorList.length === 0) {
|
||||
return;
|
||||
|
|
225
src/HtmlUtils.js
225
src/HtmlUtils.js
|
@ -1,6 +1,8 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -26,23 +28,19 @@ import * as linkify from 'linkifyjs';
|
|||
import linkifyMatrix from './linkify-matrix';
|
||||
import _linkifyElement from 'linkifyjs/element';
|
||||
import _linkifyString from 'linkifyjs/string';
|
||||
import escape from 'lodash/escape';
|
||||
import emojione from 'emojione';
|
||||
import classNames from 'classnames';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import url from 'url';
|
||||
|
||||
linkifyMatrix(linkify);
|
||||
import EMOJIBASE from 'emojibase-data/en/compact.json';
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
||||
|
||||
emojione.imagePathSVG = 'emojione/svg/';
|
||||
// Store PNG path for displaying many flags at once (for increased performance over SVG)
|
||||
emojione.imagePathPNG = 'emojione/png/';
|
||||
// Use SVGs for emojis
|
||||
emojione.imageType = 'svg';
|
||||
linkifyMatrix(linkify);
|
||||
|
||||
// Anything outside the basic multilingual plane will be a surrogate pair
|
||||
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
|
||||
// And there a bunch more symbol characters that emojione has within the
|
||||
// And there a bunch more symbol characters that emojibase has within the
|
||||
// BMP, so this includes the ranges from 'letterlike symbols' to
|
||||
// 'miscellaneous symbols and arrows' which should catch all of them
|
||||
// (with plenty of false positives, but that's OK)
|
||||
|
@ -54,79 +52,61 @@ const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g");
|
|||
// Regex pattern for whitespace characters
|
||||
const WHITESPACE_REGEX = new RegExp("\\s", "g");
|
||||
|
||||
// And this is emojione's complete regex
|
||||
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
|
||||
const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
|
||||
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||
|
||||
const VARIATION_SELECTOR = String.fromCharCode(0xFE0F);
|
||||
|
||||
/*
|
||||
* Return true if the given string contains emoji
|
||||
* Uses a much, much simpler regex than emojione's so will give false
|
||||
* Uses a much, much simpler regex than emojibase's so will give false
|
||||
* positives, but useful for fast-path testing strings to see if they
|
||||
* need emojification.
|
||||
* unicodeToImage uses this function.
|
||||
*/
|
||||
export function containsEmoji(str) {
|
||||
function mightContainEmoji(str) {
|
||||
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
|
||||
}
|
||||
|
||||
/* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js
|
||||
* because we want to include emoji shortnames in title text
|
||||
/**
|
||||
* Find emoji data in emojibase by character.
|
||||
*
|
||||
* @param {String} char The emoji character
|
||||
* @return {Object} The emoji data
|
||||
*/
|
||||
function unicodeToImage(str, addAlt) {
|
||||
if (addAlt === undefined) addAlt = true;
|
||||
|
||||
let replaceWith; let unicode; let short; let fname;
|
||||
const mappedUnicode = emojione.mapUnicodeToShort();
|
||||
|
||||
str = str.replace(emojione.regUnicode, function(unicodeChar) {
|
||||
if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) {
|
||||
// if the unicodeChar doesnt exist just return the entire match
|
||||
return unicodeChar;
|
||||
} else {
|
||||
// get the unicode codepoint from the actual char
|
||||
unicode = emojione.jsEscapeMap[unicodeChar];
|
||||
|
||||
short = mappedUnicode[unicode];
|
||||
fname = emojione.emojioneList[short].fname;
|
||||
|
||||
// depending on the settings, we'll either add the native unicode as the alt tag, otherwise the shortname
|
||||
const title = mappedUnicode[unicode];
|
||||
|
||||
if (addAlt) {
|
||||
const alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode];
|
||||
replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${fname}.svg${emojione.cacheBustParam}"/>`;
|
||||
} else {
|
||||
replaceWith = `<img class="mx_emojione" src="${emojione.imagePathSVG}${fname}.svg${emojione.cacheBustParam}"/>`;
|
||||
}
|
||||
return replaceWith;
|
||||
}
|
||||
});
|
||||
|
||||
return str;
|
||||
export function findEmojiData(char) {
|
||||
// Check against both the char and the char with an empty variation selector
|
||||
// appended because that's how emojibase stores its base emojis which have
|
||||
// variations.
|
||||
// See also https://github.com/vector-im/riot-web/issues/9785.
|
||||
const emptyVariation = char + VARIATION_SELECTOR;
|
||||
return EMOJIBASE.find(e => e.unicode === char || e.unicode === emptyVariation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given one or more unicode characters (represented by unicode
|
||||
* character number), return an image node with the corresponding
|
||||
* emoji.
|
||||
* Returns the shortcode for an emoji character.
|
||||
*
|
||||
* @param alt {string} String to use for the image alt text
|
||||
* @param useSvg {boolean} Whether to use SVG image src. If False, PNG will be used.
|
||||
* @param unicode {integer} One or more integers representing unicode characters
|
||||
* @returns A img node with the corresponding emoji
|
||||
* @param {String} char The emoji character
|
||||
* @return {String} The shortcode (such as :thumbup:)
|
||||
*/
|
||||
export function charactersToImageNode(alt, useSvg, ...unicode) {
|
||||
const fileName = unicode.map((u) => {
|
||||
return u.toString(16);
|
||||
}).join('-');
|
||||
const path = useSvg ? emojione.imagePathSVG : emojione.imagePathPNG;
|
||||
const fileType = useSvg ? 'svg' : 'png';
|
||||
return <img
|
||||
alt={alt}
|
||||
src={`${path}${fileName}.${fileType}${emojione.cacheBustParam}`}
|
||||
/>;
|
||||
export function unicodeToShortcode(char) {
|
||||
const data = findEmojiData(char);
|
||||
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unicode character for an emoji shortcode
|
||||
*
|
||||
* @param {String} shortcode The shortcode (such as :thumbup:)
|
||||
* @return {String} The emoji character; null if none exists
|
||||
*/
|
||||
export function shortcodeToUnicode(shortcode) {
|
||||
shortcode = shortcode.slice(1, shortcode.length - 1);
|
||||
const data = EMOJIBASE.find(e => e.shortcodes && e.shortcodes.includes(shortcode));
|
||||
return data ? data.unicode : null;
|
||||
}
|
||||
|
||||
export function processHtmlForSending(html: string): string {
|
||||
|
@ -191,30 +171,10 @@ const transformTags = { // custom to matrix
|
|||
if (attribs.href) {
|
||||
attribs.target = '_blank'; // by default
|
||||
|
||||
let m;
|
||||
// FIXME: horrible duplication with linkify-matrix
|
||||
m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN);
|
||||
if (m) {
|
||||
attribs.href = m[1];
|
||||
const transformed = tryTransformPermalinkToLocalHref(attribs.href);
|
||||
if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN)) {
|
||||
attribs.href = transformed;
|
||||
delete attribs.target;
|
||||
} else {
|
||||
m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN);
|
||||
if (m) {
|
||||
const entity = m[1];
|
||||
switch (entity[0]) {
|
||||
case '@':
|
||||
attribs.href = '#/user/' + entity;
|
||||
break;
|
||||
case '+':
|
||||
attribs.href = '#/group/' + entity;
|
||||
break;
|
||||
case '#':
|
||||
case '!':
|
||||
attribs.href = '#/room/' + entity;
|
||||
break;
|
||||
}
|
||||
delete attribs.target;
|
||||
}
|
||||
}
|
||||
}
|
||||
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
|
||||
|
@ -289,7 +249,7 @@ const sanitizeHtmlParams = {
|
|||
allowedAttributes: {
|
||||
// custom ones first:
|
||||
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
|
||||
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
|
||||
span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
|
||||
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
|
||||
img: ['src', 'width', 'height', 'alt', 'title'],
|
||||
ol: ['start'],
|
||||
|
@ -433,13 +393,10 @@ class TextHighlighter extends BaseHighlighter {
|
|||
* opts.disableBigEmoji: optional argument to disable the big emoji class.
|
||||
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
|
||||
* opts.returnString: return an HTML string rather than JSX elements
|
||||
* opts.emojiOne: optional param to do emojiOne (default true)
|
||||
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
|
||||
*/
|
||||
export function bodyToHtml(content, highlights, opts={}) {
|
||||
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
||||
|
||||
const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne;
|
||||
let bodyHasEmoji = false;
|
||||
|
||||
let sanitizeParams = sanitizeHtmlParams;
|
||||
|
@ -466,32 +423,18 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
};
|
||||
}
|
||||
|
||||
let formattedBody = content.formatted_body;
|
||||
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
|
||||
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body;
|
||||
let formattedBody = typeof content.formatted_body === 'string' ? content.formatted_body : null;
|
||||
const plainBody = typeof content.body === 'string' ? content.body : null;
|
||||
|
||||
if (doEmojiOne) {
|
||||
bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body);
|
||||
}
|
||||
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
|
||||
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(plainBody) : plainBody;
|
||||
|
||||
bodyHasEmoji = mightContainEmoji(isHtmlMessage ? formattedBody : plainBody);
|
||||
|
||||
// Only generate safeBody if the message was sent as org.matrix.custom.html
|
||||
if (isHtmlMessage) {
|
||||
isDisplayedWithHtml = true;
|
||||
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
|
||||
} else {
|
||||
// ... or if there are emoji, which we insert as HTML alongside the
|
||||
// escaped plaintext body.
|
||||
if (bodyHasEmoji) {
|
||||
isDisplayedWithHtml = true;
|
||||
safeBody = sanitizeHtml(escape(strippedBody), sanitizeParams);
|
||||
}
|
||||
}
|
||||
|
||||
// An HTML message with emoji
|
||||
// or a plaintext message with emoji that was escaped and sanitized into
|
||||
// HTML.
|
||||
if (bodyHasEmoji) {
|
||||
safeBody = unicodeToImage(safeBody);
|
||||
}
|
||||
} finally {
|
||||
delete sanitizeParams.textFilter;
|
||||
|
@ -503,7 +446,6 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
|
||||
let emojiBody = false;
|
||||
if (!opts.disableBigEmoji && bodyHasEmoji) {
|
||||
EMOJI_REGEX.lastIndex = 0;
|
||||
let contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
|
||||
|
||||
// Ignore spaces in body text. Emojis with spaces in between should
|
||||
|
@ -515,29 +457,27 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
// presented as large.
|
||||
contentBodyTrimmed = contentBodyTrimmed.replace(ZWJ_REGEX, '');
|
||||
|
||||
const match = EMOJI_REGEX.exec(contentBodyTrimmed);
|
||||
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length
|
||||
const match = BIGEMOJI_REGEX.exec(contentBodyTrimmed);
|
||||
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length &&
|
||||
// Prevent user pills expanding for users with only emoji in
|
||||
// their username
|
||||
&& (content.formatted_body == undefined
|
||||
|| !content.formatted_body.includes("https://matrix.to/"));
|
||||
// their username. Permalinks (links in pills) can be any URL
|
||||
// now, so we just check for an HTTP-looking thing.
|
||||
(
|
||||
content.formatted_body == undefined ||
|
||||
(!content.formatted_body.includes("http:") &&
|
||||
!content.formatted_body.includes("https:"))
|
||||
);
|
||||
}
|
||||
|
||||
const className = classNames({
|
||||
'mx_EventTile_body': true,
|
||||
'mx_EventTile_bigEmoji': emojiBody,
|
||||
'markdown-body': isHtmlMessage,
|
||||
'markdown-body': isHtmlMessage && !emojiBody,
|
||||
});
|
||||
|
||||
return isDisplayedWithHtml ?
|
||||
<span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
|
||||
<span className={className} dir="auto">{ strippedBody }</span>;
|
||||
}
|
||||
|
||||
export function emojifyText(text, addAlt) {
|
||||
return {
|
||||
__html: unicodeToImage(escape(text), addAlt),
|
||||
};
|
||||
<span key="body" className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
|
||||
<span key="body" className={className} dir="auto">{ strippedBody }</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -570,3 +510,38 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
|
|||
export function linkifyAndSanitizeHtml(dirtyHtml) {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if a node is a block element or not.
|
||||
* Only takes html nodes into account that are allowed in matrix messages.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {bool}
|
||||
*/
|
||||
export function checkBlockNode(node) {
|
||||
switch (node.nodeName) {
|
||||
case "H1":
|
||||
case "H2":
|
||||
case "H3":
|
||||
case "H4":
|
||||
case "H5":
|
||||
case "H6":
|
||||
case "PRE":
|
||||
case "BLOCKQUOTE":
|
||||
case "DIV":
|
||||
case "P":
|
||||
case "UL":
|
||||
case "OL":
|
||||
case "LI":
|
||||
case "HR":
|
||||
case "TABLE":
|
||||
case "THEAD":
|
||||
case "TBODY":
|
||||
case "TR":
|
||||
case "TH":
|
||||
case "TD":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
204
src/IdentityAuthClient.js
Normal file
204
src/IdentityAuthClient.js
Normal file
|
@ -0,0 +1,204 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { createClient, SERVICE_TYPES } from 'matrix-js-sdk';
|
||||
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import Modal from './Modal';
|
||||
import sdk from './index';
|
||||
import { _t } from './languageHandler';
|
||||
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
|
||||
import {
|
||||
doesAccountDataHaveIdentityServer,
|
||||
doesIdentityServerHaveTerms,
|
||||
useDefaultIdentityServer,
|
||||
} from './utils/IdentityServerUtils';
|
||||
import { abbreviateUrl } from './utils/UrlUtils';
|
||||
|
||||
export class AbortedIdentityActionError extends Error {}
|
||||
|
||||
export default class IdentityAuthClient {
|
||||
/**
|
||||
* Creates a new identity auth client
|
||||
* @param {string} identityUrl The URL to contact the identity server with.
|
||||
* When provided, this class will operate solely within memory, refusing to
|
||||
* persist any information such as tokens. Default null (not provided).
|
||||
*/
|
||||
constructor(identityUrl = null) {
|
||||
this.accessToken = null;
|
||||
this.authEnabled = true;
|
||||
|
||||
if (identityUrl) {
|
||||
// XXX: We shouldn't have to create a whole new MatrixClient just to
|
||||
// do identity server auth. The functions don't take an identity URL
|
||||
// though, and making all of them take one could lead to developer
|
||||
// confusion about what the idBaseUrl does on a client. Therefore, we
|
||||
// just make a new client and live with it.
|
||||
this.tempClient = createClient({
|
||||
baseUrl: "", // invalid by design
|
||||
idBaseUrl: identityUrl,
|
||||
});
|
||||
} else {
|
||||
// Indicates that we're using the real client, not some workaround.
|
||||
this.tempClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
get _matrixClient() {
|
||||
return this.tempClient ? this.tempClient : MatrixClientPeg.get();
|
||||
}
|
||||
|
||||
_writeToken() {
|
||||
if (this.tempClient) return; // temporary client: ignore
|
||||
window.localStorage.setItem("mx_is_access_token", this.accessToken);
|
||||
}
|
||||
|
||||
_readToken() {
|
||||
if (this.tempClient) return null; // temporary client: ignore
|
||||
return window.localStorage.getItem("mx_is_access_token");
|
||||
}
|
||||
|
||||
hasCredentials() {
|
||||
return this.accessToken != null; // undef or null
|
||||
}
|
||||
|
||||
// Returns a promise that resolves to the access_token string from the IS
|
||||
async getAccessToken({ check = true } = {}) {
|
||||
if (!this.authEnabled) {
|
||||
// The current IS doesn't support authentication
|
||||
return null;
|
||||
}
|
||||
|
||||
let token = this.accessToken;
|
||||
if (!token) {
|
||||
token = this._readToken();
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
token = await this.registerForToken(check);
|
||||
if (token) {
|
||||
this.accessToken = token;
|
||||
this._writeToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
if (check) {
|
||||
try {
|
||||
await this._checkToken(token);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof TermsNotSignedError ||
|
||||
e instanceof AbortedIdentityActionError
|
||||
) {
|
||||
// Retrying won't help this
|
||||
throw e;
|
||||
}
|
||||
// Retry in case token expired
|
||||
token = await this.registerForToken();
|
||||
if (token) {
|
||||
this.accessToken = token;
|
||||
this._writeToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async _checkToken(token) {
|
||||
const identityServerUrl = this._matrixClient.getIdentityServerUrl();
|
||||
|
||||
try {
|
||||
await this._matrixClient.getIdentityAccount(token);
|
||||
} catch (e) {
|
||||
if (e.errcode === "M_TERMS_NOT_SIGNED") {
|
||||
console.log("Identity Server requires new terms to be agreed to");
|
||||
await startTermsFlow([new Service(
|
||||
SERVICE_TYPES.IS,
|
||||
identityServerUrl,
|
||||
token,
|
||||
)]);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.tempClient &&
|
||||
!doesAccountDataHaveIdentityServer() &&
|
||||
!await doesIdentityServerHaveTerms(identityServerUrl)
|
||||
) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '',
|
||||
QuestionDialog, {
|
||||
title: _t("Identity server has no terms of service"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{_t(
|
||||
"This action requires accessing the default identity server " +
|
||||
"<server /> to validate an email address or phone number, " +
|
||||
"but the server does not have any terms of service.", {},
|
||||
{
|
||||
server: () => <b>{abbreviateUrl(identityServerUrl)}</b>,
|
||||
},
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Only continue if you trust the owner of the server.",
|
||||
)}</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("Trust"),
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (confirmed) {
|
||||
useDefaultIdentityServer();
|
||||
} else {
|
||||
throw new AbortedIdentityActionError(
|
||||
"User aborted identity server action without terms",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// We should ensure the token in `localStorage` is cleared
|
||||
// appropriately. We already clear storage on sign out, but we'll need
|
||||
// additional clearing when changing ISes in settings as part of future
|
||||
// privacy work.
|
||||
// See also https://github.com/vector-im/riot-web/issues/10455.
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import Modal from './Modal';
|
||||
import sdk from './index';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import ScalarMessaging from './ScalarMessaging';
|
||||
import ScalarAuthClient from './ScalarAuthClient';
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
|
||||
if (!global.mxIntegrationManager) {
|
||||
global.mxIntegrationManager = {};
|
||||
}
|
||||
|
||||
export default class IntegrationManager {
|
||||
static _init() {
|
||||
if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) {
|
||||
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
|
||||
ScalarMessaging.startListening();
|
||||
global.mxIntegrationManager.client = new ScalarAuthClient();
|
||||
|
||||
return global.mxIntegrationManager.client.connect().then(() => {
|
||||
global.mxIntegrationManager.connected = true;
|
||||
}).catch((e) => {
|
||||
console.error("Failed to connect to integrations server", e);
|
||||
global.mxIntegrationManager.error = e;
|
||||
});
|
||||
} else {
|
||||
console.error('Invalid integration manager config', SdkConfig.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the integrations manager on the stickers integration page
|
||||
* @param {string} integName integration / widget type
|
||||
* @param {string} integId integration / widget ID
|
||||
* @param {function} onFinished Callback to invoke on integration manager close
|
||||
*/
|
||||
static async open(integName, integId, onFinished) {
|
||||
await IntegrationManager._init();
|
||||
if (global.mxIntegrationManager.client) {
|
||||
await global.mxIntegrationManager.client.connect();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
|
||||
if (global.mxIntegrationManager.error ||
|
||||
!(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) {
|
||||
console.error("Scalar error", global.mxIntegrationManager);
|
||||
return;
|
||||
}
|
||||
const integType = 'type_' + integName;
|
||||
const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ?
|
||||
global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom(
|
||||
{roomId: RoomViewStore.getRoomId()},
|
||||
integType,
|
||||
integId,
|
||||
) :
|
||||
null;
|
||||
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
|
||||
src: src,
|
||||
onFinished: onFinished,
|
||||
}, "mx_IntegrationsManager");
|
||||
}
|
||||
}
|
|
@ -125,7 +125,7 @@ export default class KeyRequestHandler {
|
|||
};
|
||||
|
||||
const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog");
|
||||
Modal.createTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, {
|
||||
Modal.appendTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, {
|
||||
matrixClient: this._matrixClient,
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
|
|
|
@ -58,6 +58,36 @@ export const KeyCode = {
|
|||
KEY_X: 88,
|
||||
KEY_Y: 89,
|
||||
KEY_Z: 90,
|
||||
KEY_BACKTICK: 223, // DO NOT USE THIS: browsers disagree on backtick 192 vs 223
|
||||
};
|
||||
|
||||
export const Key = {
|
||||
HOME: "Home",
|
||||
END: "End",
|
||||
PAGE_UP: "PageUp",
|
||||
PAGE_DOWN: "PageDown",
|
||||
BACKSPACE: "Backspace",
|
||||
ARROW_UP: "ArrowUp",
|
||||
ARROW_DOWN: "ArrowDown",
|
||||
ARROW_LEFT: "ArrowLeft",
|
||||
ARROW_RIGHT: "ArrowRight",
|
||||
TAB: "Tab",
|
||||
ESCAPE: "Escape",
|
||||
ENTER: "Enter",
|
||||
ALT: "Alt",
|
||||
CONTROL: "Control",
|
||||
META: "Meta",
|
||||
SHIFT: "Shift",
|
||||
|
||||
LESS_THAN: "<",
|
||||
GREATER_THAN: ">",
|
||||
BACKTICK: "`",
|
||||
SPACE: " ",
|
||||
B: "b",
|
||||
I: "i",
|
||||
K: "k",
|
||||
Y: "y",
|
||||
Z: "z",
|
||||
};
|
||||
|
||||
export function isOnlyCtrlOrCmdKeyEvent(ev) {
|
||||
|
|
208
src/Lifecycle.js
208
src/Lifecycle.js
|
@ -16,10 +16,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import Promise from 'bluebird';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import EventIndexPeg from './indexing/EventIndexPeg';
|
||||
import createMatrixClient from './utils/createMatrixClient';
|
||||
import Analytics from './Analytics';
|
||||
import Notifier from './Notifier';
|
||||
|
@ -33,6 +33,10 @@ import ActiveWidgetStore from './stores/ActiveWidgetStore';
|
|||
import PlatformPeg from "./PlatformPeg";
|
||||
import { sendLoginRequest } from "./Login";
|
||||
import * as StorageManager from './utils/StorageManager';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import TypingStore from "./stores/TypingStore";
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import {Mjolnir} from "./mjolnir/Mjolnir";
|
||||
|
||||
/**
|
||||
* Called at startup, to attempt to build a logged-in Matrix session. It tries
|
||||
|
@ -64,6 +68,9 @@ import * as StorageManager from './utils/StorageManager';
|
|||
* @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is
|
||||
* true; defines the IS to use.
|
||||
*
|
||||
* @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore
|
||||
* it and don't load it.
|
||||
*
|
||||
* @returns {Promise} a promise which resolves when the above process completes.
|
||||
* Resolves to `true` if we ended up starting a session, or `false` if we
|
||||
* failed.
|
||||
|
@ -76,7 +83,7 @@ export async function loadSession(opts) {
|
|||
const fragmentQueryParams = opts.fragmentQueryParams || {};
|
||||
const defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||
|
||||
if (!guestHsUrl) {
|
||||
if (enableGuest && !guestHsUrl) {
|
||||
console.warn("Cannot enable guest access: can't determine HS URL to use");
|
||||
enableGuest = false;
|
||||
}
|
||||
|
@ -94,7 +101,9 @@ export async function loadSession(opts) {
|
|||
guest: true,
|
||||
}, true).then(() => true);
|
||||
}
|
||||
const success = await _restoreFromLocalStorage();
|
||||
const success = await _restoreFromLocalStorage({
|
||||
ignoreGuest: Boolean(opts.ignoreGuest),
|
||||
});
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
|
@ -122,10 +131,19 @@ export async function loadSession(opts) {
|
|||
* @returns {String} The persisted session's owner, if an owner exists. Null otherwise.
|
||||
*/
|
||||
export function getStoredSessionOwner() {
|
||||
const {hsUrl, userId, accessToken} = _getLocalStorageSessionVars();
|
||||
const {hsUrl, userId, accessToken} = getLocalStorageSessionVars();
|
||||
return hsUrl && userId && accessToken ? userId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {bool} True if the stored session is for a guest user or false if it is
|
||||
* for a real user. If there is no stored session, return null.
|
||||
*/
|
||||
export function getStoredSessionIsGuest() {
|
||||
const sessVars = getLocalStorageSessionVars();
|
||||
return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} queryParams string->string map of the
|
||||
* query-parameters extracted from the real query-string of the starting
|
||||
|
@ -223,19 +241,32 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
|
|||
guest: true,
|
||||
}, true).then(() => true);
|
||||
}, (err) => {
|
||||
console.error("Failed to register as guest: " + err + " " + err.data);
|
||||
console.error("Failed to register as guest", err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function _getLocalStorageSessionVars() {
|
||||
/**
|
||||
* Retrieves information about the stored session in localstorage. The session
|
||||
* may not be valid, as it is not tested for consistency here.
|
||||
* @returns {Object} Information about the session - see implementation for variables.
|
||||
*/
|
||||
export function getLocalStorageSessionVars() {
|
||||
const hsUrl = localStorage.getItem("mx_hs_url");
|
||||
const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
|
||||
const isUrl = localStorage.getItem("mx_is_url");
|
||||
const accessToken = localStorage.getItem("mx_access_token");
|
||||
const userId = localStorage.getItem("mx_user_id");
|
||||
const deviceId = localStorage.getItem("mx_device_id");
|
||||
|
||||
return {hsUrl, isUrl, accessToken, userId, deviceId};
|
||||
let isGuest;
|
||||
if (localStorage.getItem("mx_is_guest") !== null) {
|
||||
isGuest = localStorage.getItem("mx_is_guest") === "true";
|
||||
} else {
|
||||
// legacy key name
|
||||
isGuest = localStorage.getItem("matrix-is-guest") === "true";
|
||||
}
|
||||
|
||||
return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest};
|
||||
}
|
||||
|
||||
// returns a promise which resolves to true if a session is found in
|
||||
|
@ -248,22 +279,21 @@ function _getLocalStorageSessionVars() {
|
|||
// The plan is to gradually move the localStorage access done here into
|
||||
// SessionStore to avoid bugs where the view becomes out-of-sync with
|
||||
// localStorage (e.g. isGuest etc.)
|
||||
async function _restoreFromLocalStorage() {
|
||||
async function _restoreFromLocalStorage(opts) {
|
||||
const ignoreGuest = opts.ignoreGuest;
|
||||
|
||||
if (!localStorage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {hsUrl, isUrl, accessToken, userId, deviceId} = _getLocalStorageSessionVars();
|
||||
|
||||
let isGuest;
|
||||
if (localStorage.getItem("mx_is_guest") !== null) {
|
||||
isGuest = localStorage.getItem("mx_is_guest") === "true";
|
||||
} else {
|
||||
// legacy key name
|
||||
isGuest = localStorage.getItem("matrix-is-guest") === "true";
|
||||
}
|
||||
const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = getLocalStorageSessionVars();
|
||||
|
||||
if (accessToken && userId && hsUrl) {
|
||||
if (ignoreGuest && isGuest) {
|
||||
console.log("Ignoring stored guest account: " + userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Restoring session for ${userId}`);
|
||||
await _doSetLoggedIn({
|
||||
userId: userId,
|
||||
|
@ -283,18 +313,14 @@ async function _restoreFromLocalStorage() {
|
|||
function _handleLoadSessionFailure(e) {
|
||||
console.error("Unable to load session", e);
|
||||
|
||||
const def = Promise.defer();
|
||||
const SessionRestoreErrorDialog =
|
||||
sdk.getComponent('views.dialogs.SessionRestoreErrorDialog');
|
||||
|
||||
Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
|
||||
const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
|
||||
error: e.message,
|
||||
onFinished: (success) => {
|
||||
def.resolve(success);
|
||||
},
|
||||
});
|
||||
|
||||
return def.promise.then((success) => {
|
||||
return modal.finished.then(([success]) => {
|
||||
if (success) {
|
||||
// user clicked continue.
|
||||
_clearStorage();
|
||||
|
@ -324,6 +350,37 @@ export function setLoggedIn(credentials) {
|
|||
return _doSetLoggedIn(credentials, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates an existing session by using the credentials provided. This will
|
||||
* not clear any local storage, unlike setLoggedIn().
|
||||
*
|
||||
* Stops the existing Matrix client (without clearing its data) and starts a
|
||||
* new one in its place. This additionally starts all other react-sdk services
|
||||
* which use the new Matrix client.
|
||||
*
|
||||
* If the credentials belong to a different user from the session already stored,
|
||||
* the old session will be cleared automatically.
|
||||
*
|
||||
* @param {MatrixClientCreds} credentials The credentials to use
|
||||
*
|
||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||
*/
|
||||
export function hydrateSession(credentials) {
|
||||
const oldUserId = MatrixClientPeg.get().getUserId();
|
||||
const oldDeviceId = MatrixClientPeg.get().getDeviceId();
|
||||
|
||||
stopMatrixClient(); // unsets MatrixClientPeg.get()
|
||||
localStorage.removeItem("mx_soft_logout");
|
||||
_isLoggingOut = false;
|
||||
|
||||
const overwrite = credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId;
|
||||
if (overwrite) {
|
||||
console.warn("Clearing all data: Old session belongs to a different user/device");
|
||||
}
|
||||
|
||||
return _doSetLoggedIn(credentials, overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* fires on_logging_in, optionally clears localstorage, persists new credentials
|
||||
* to localstorage, starts the new client.
|
||||
|
@ -336,11 +393,14 @@ export function setLoggedIn(credentials) {
|
|||
async function _doSetLoggedIn(credentials, clearStorage) {
|
||||
credentials.guest = Boolean(credentials.guest);
|
||||
|
||||
const softLogout = isSoftLogout();
|
||||
|
||||
console.log(
|
||||
"setLoggedIn: mxid: " + credentials.userId +
|
||||
" deviceId: " + credentials.deviceId +
|
||||
" guest: " + credentials.guest +
|
||||
" hs: " + credentials.homeserverUrl,
|
||||
" hs: " + credentials.homeserverUrl +
|
||||
" softLogout: " + softLogout,
|
||||
);
|
||||
|
||||
// This is dispatched to indicate that the user is still in the process of logging in
|
||||
|
@ -360,7 +420,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
// If there's an inconsistency between account data in local storage and the
|
||||
// crypto store, we'll be generally confused when handling encrypted data.
|
||||
// Show a modal recommending a full reset of storage.
|
||||
if (results.dataInLocalStorage && !results.dataInCryptoStore) {
|
||||
if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
|
||||
const signOut = await _showStorageEvictedDialog();
|
||||
if (signOut) {
|
||||
await _clearStorage();
|
||||
|
@ -398,7 +458,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
|
||||
dis.dispatch({ action: 'on_logged_in' });
|
||||
|
||||
await startMatrixClient();
|
||||
await startMatrixClient(/*startSyncing=*/!softLogout);
|
||||
return MatrixClientPeg.get();
|
||||
}
|
||||
|
||||
|
@ -417,7 +477,9 @@ class AbortLoginAndRebuildStorage extends Error { }
|
|||
|
||||
function _persistCredentialsToLocalStorage(credentials) {
|
||||
localStorage.setItem("mx_hs_url", credentials.homeserverUrl);
|
||||
localStorage.setItem("mx_is_url", credentials.identityServerUrl);
|
||||
if (credentials.identityServerUrl) {
|
||||
localStorage.setItem("mx_is_url", credentials.identityServerUrl);
|
||||
}
|
||||
localStorage.setItem("mx_user_id", credentials.userId);
|
||||
localStorage.setItem("mx_access_token", credentials.accessToken);
|
||||
localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
|
||||
|
@ -446,12 +508,7 @@ export function logout() {
|
|||
// logout doesn't work for guest sessions
|
||||
// Also we sometimes want to re-log in a guest session
|
||||
// if we abort the login
|
||||
|
||||
// use settimeout to avoid racing with react unmounting components
|
||||
// which need a valid matrixclientpeg
|
||||
setTimeout(()=>{
|
||||
onLoggedOut();
|
||||
}, 0);
|
||||
onLoggedOut();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -468,7 +525,32 @@ export function logout() {
|
|||
console.log("Failed to call logout API: token will not be invalidated");
|
||||
onLoggedOut();
|
||||
},
|
||||
).done();
|
||||
);
|
||||
}
|
||||
|
||||
export function softLogout() {
|
||||
if (!MatrixClientPeg.get()) return;
|
||||
|
||||
// Track that we've detected and trapped a soft logout. This helps prevent other
|
||||
// parts of the app from starting if there's no point (ie: don't sync if we've
|
||||
// been soft logged out, despite having credentials and data for a MatrixClient).
|
||||
localStorage.setItem("mx_soft_logout", "true");
|
||||
|
||||
// Dev note: please keep this log line around. It can be useful for track down
|
||||
// random clients stopping in the middle of the logs.
|
||||
console.log("Soft logout initiated");
|
||||
_isLoggingOut = true; // to avoid repeated flags
|
||||
// Ensure that we dispatch a view change **before** stopping the client so
|
||||
// so that React components unmount first. This avoids React soft crashes
|
||||
// that can occur when components try to use a null client.
|
||||
dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out
|
||||
stopMatrixClient(/*unsetClient=*/false);
|
||||
|
||||
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
|
||||
}
|
||||
|
||||
export function isSoftLogout() {
|
||||
return localStorage.getItem("mx_soft_logout") === "true";
|
||||
}
|
||||
|
||||
export function isLoggingOut() {
|
||||
|
@ -478,8 +560,10 @@ export function isLoggingOut() {
|
|||
/**
|
||||
* Starts the matrix client and all other react-sdk services that
|
||||
* listen for events while a session is logged in.
|
||||
* @param {boolean} startSyncing True (default) to actually start
|
||||
* syncing the client.
|
||||
*/
|
||||
async function startMatrixClient() {
|
||||
async function startMatrixClient(startSyncing=true) {
|
||||
console.log(`Lifecycle: Starting MatrixClient`);
|
||||
|
||||
// dispatch this before starting the matrix client: it's used
|
||||
|
@ -490,32 +574,54 @@ async function startMatrixClient() {
|
|||
|
||||
Notifier.start();
|
||||
UserActivity.sharedInstance().start();
|
||||
Presence.start();
|
||||
TypingStore.sharedInstance().reset(); // just in case
|
||||
if (!SettingsStore.getValue("lowBandwidth")) {
|
||||
Presence.start();
|
||||
}
|
||||
DMRoomMap.makeShared().start();
|
||||
IntegrationManagers.sharedInstance().startWatching();
|
||||
ActiveWidgetStore.start();
|
||||
|
||||
await MatrixClientPeg.start();
|
||||
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
|
||||
// the thing just wastes CPU cycles, but should result in no actual functionality
|
||||
// being exposed to the user.
|
||||
Mjolnir.sharedInstance().start();
|
||||
|
||||
if (startSyncing) {
|
||||
await MatrixClientPeg.start();
|
||||
await EventIndexPeg.init();
|
||||
} else {
|
||||
console.warn("Caller requested only auxiliary services be started");
|
||||
await MatrixClientPeg.assign();
|
||||
}
|
||||
|
||||
// dispatch that we finished starting up to wire up any other bits
|
||||
// of the matrix client that cannot be set prior to starting up.
|
||||
dis.dispatch({action: 'client_started'});
|
||||
|
||||
if (isSoftLogout()) {
|
||||
softLogout();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Stops a running client and all related services, and clears persistent
|
||||
* storage. Used after a session has been logged out.
|
||||
*/
|
||||
export function onLoggedOut() {
|
||||
export async function onLoggedOut() {
|
||||
_isLoggingOut = false;
|
||||
// Ensure that we dispatch a view change **before** stopping the client so
|
||||
// so that React components unmount first. This avoids React soft crashes
|
||||
// that can occur when components try to use a null client.
|
||||
dis.dispatch({action: 'on_logged_out'}, true);
|
||||
stopMatrixClient();
|
||||
_clearStorage().done();
|
||||
dis.dispatch({action: 'on_logged_out'});
|
||||
await _clearStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise} promise which resolves once the stores have been cleared
|
||||
*/
|
||||
function _clearStorage() {
|
||||
async function _clearStorage() {
|
||||
Analytics.logout();
|
||||
|
||||
if (window.localStorage) {
|
||||
|
@ -527,22 +633,34 @@ function _clearStorage() {
|
|||
// we'll never make any requests, so can pass a bogus HS URL
|
||||
baseUrl: "",
|
||||
});
|
||||
return cli.clearStores();
|
||||
|
||||
await EventIndexPeg.deleteEventIndex();
|
||||
await cli.clearStores();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all the background processes related to the current client.
|
||||
* @param {boolean} unsetClient True (default) to abandon the client
|
||||
* on MatrixClientPeg after stopping.
|
||||
*/
|
||||
export function stopMatrixClient() {
|
||||
export function stopMatrixClient(unsetClient=true) {
|
||||
Notifier.stop();
|
||||
UserActivity.sharedInstance().stop();
|
||||
TypingStore.sharedInstance().reset();
|
||||
Presence.stop();
|
||||
ActiveWidgetStore.stop();
|
||||
IntegrationManagers.sharedInstance().stopWatching();
|
||||
Mjolnir.sharedInstance().stop();
|
||||
if (DMRoomMap.shared()) DMRoomMap.shared().stop();
|
||||
EventIndexPeg.stop();
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.stopClient();
|
||||
cli.removeAllListeners();
|
||||
MatrixClientPeg.unset();
|
||||
|
||||
if (unsetClient) {
|
||||
MatrixClientPeg.unset();
|
||||
EventIndexPeg.unset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
11
src/Login.js
11
src/Login.js
|
@ -2,6 +2,7 @@
|
|||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -87,32 +88,23 @@ export default class Login {
|
|||
const isEmail = username.indexOf("@") > 0;
|
||||
|
||||
let identifier;
|
||||
let legacyParams; // parameters added to support old HSes
|
||||
if (phoneCountry && phoneNumber) {
|
||||
identifier = {
|
||||
type: 'm.id.phone',
|
||||
country: phoneCountry,
|
||||
number: phoneNumber,
|
||||
};
|
||||
// No legacy support for phone number login
|
||||
} else if (isEmail) {
|
||||
identifier = {
|
||||
type: 'm.id.thirdparty',
|
||||
medium: 'email',
|
||||
address: username,
|
||||
};
|
||||
legacyParams = {
|
||||
medium: 'email',
|
||||
address: username,
|
||||
};
|
||||
} else {
|
||||
identifier = {
|
||||
type: 'm.id.user',
|
||||
user: username,
|
||||
};
|
||||
legacyParams = {
|
||||
user: username,
|
||||
};
|
||||
}
|
||||
|
||||
const loginParams = {
|
||||
|
@ -120,7 +112,6 @@ export default class Login {
|
|||
identifier: identifier,
|
||||
initial_device_display_name: this._defaultDeviceDisplayName,
|
||||
};
|
||||
Object.assign(loginParams, legacyParams);
|
||||
|
||||
const tryFallbackHs = (originalError) => {
|
||||
return sendLoginRequest(
|
||||
|
|
|
@ -16,9 +16,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import {MatrixClient, MemoryStore} from 'matrix-js-sdk';
|
||||
|
||||
import utils from 'matrix-js-sdk/lib/utils';
|
||||
import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline';
|
||||
|
@ -27,11 +25,11 @@ import sdk from './index';
|
|||
import createMatrixClient from './utils/createMatrixClient';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import MatrixActionCreators from './actions/MatrixActionCreators';
|
||||
import {phasedRollOutExpiredForUser} from "./PhasedRollOut";
|
||||
import Modal from './Modal';
|
||||
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
|
||||
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
|
||||
import * as StorageManager from './utils/StorageManager';
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
|
||||
interface MatrixClientCreds {
|
||||
homeserverUrl: string,
|
||||
|
@ -51,6 +49,7 @@ interface MatrixClientCreds {
|
|||
class MatrixClientPeg {
|
||||
constructor() {
|
||||
this.matrixClient = null;
|
||||
this._justRegisteredUserId = null;
|
||||
|
||||
// These are the default options used when when the
|
||||
// client is started in 'start'. These can be altered
|
||||
|
@ -86,6 +85,31 @@ class MatrixClientPeg {
|
|||
}
|
||||
|
||||
/**
|
||||
* If we've registered a user ID we set this to the ID of the
|
||||
* user we've just registered. If they then go & log in, we
|
||||
* can send them to the welcome user (obviously this doesn't
|
||||
* guarentee they'll get a chat with the welcome user).
|
||||
*
|
||||
* @param {string} uid The user ID of the user we've just registered
|
||||
*/
|
||||
setJustRegisteredUserId(uid) {
|
||||
this._justRegisteredUserId = uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current user has just been registered by this
|
||||
* client as determined by setJustRegisteredUserId()
|
||||
*
|
||||
* @returns {bool} True if user has just been registered
|
||||
*/
|
||||
currentUserIsJustRegistered() {
|
||||
return (
|
||||
this.matrixClient &&
|
||||
this.matrixClient.credentials.userId === this._justRegisteredUserId
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Replace this MatrixClientPeg's client with a client instance that has
|
||||
* homeserver / identity server URLs and active credentials
|
||||
*/
|
||||
|
@ -94,7 +118,7 @@ class MatrixClientPeg {
|
|||
this._createClient(creds);
|
||||
}
|
||||
|
||||
async start() {
|
||||
async assign() {
|
||||
for (const dbType of ['indexeddb', 'memory']) {
|
||||
try {
|
||||
const promise = this.matrixClient.store.startup();
|
||||
|
@ -104,8 +128,8 @@ class MatrixClientPeg {
|
|||
} catch (err) {
|
||||
if (dbType === 'indexeddb') {
|
||||
console.error('Error starting matrixclient store - falling back to memory store', err);
|
||||
this.matrixClient.store = new Matrix.MemoryStore({
|
||||
localStorage: global.localStorage,
|
||||
this.matrixClient.store = new MemoryStore({
|
||||
localStorage: global.localStorage,
|
||||
});
|
||||
} else {
|
||||
console.error('Failed to start memory store!', err);
|
||||
|
@ -119,8 +143,9 @@ class MatrixClientPeg {
|
|||
// try to initialise e2e on the new client
|
||||
try {
|
||||
// check that we have a version of the js-sdk which includes initCrypto
|
||||
if (this.matrixClient.initCrypto) {
|
||||
if (!SettingsStore.getValue("lowBandwidth") && this.matrixClient.initCrypto) {
|
||||
await this.matrixClient.initCrypto();
|
||||
StorageManager.setCryptoInitialised(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.name === 'InvalidCryptoStoreError') {
|
||||
|
@ -145,6 +170,12 @@ class MatrixClientPeg {
|
|||
MatrixActionCreators.start(this.matrixClient);
|
||||
MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient;
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async start() {
|
||||
const opts = await this.assign();
|
||||
|
||||
console.log(`MatrixClientPeg: really starting MatrixClient`);
|
||||
await this.get().startClient(opts);
|
||||
console.log(`MatrixClientPeg: MatrixClient started`);
|
||||
|
@ -161,12 +192,12 @@ class MatrixClientPeg {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Return the server name of the user's homeserver
|
||||
* Throws an error if unable to deduce the homeserver name
|
||||
* (eg. if the user is not logged in)
|
||||
*/
|
||||
getHomeServerName() {
|
||||
getHomeserverName() {
|
||||
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
|
||||
if (matches === null || matches.length < 1) {
|
||||
throw new Error("Failed to derive homeserver name from user ID!");
|
||||
|
@ -183,9 +214,22 @@ class MatrixClientPeg {
|
|||
deviceId: creds.deviceId,
|
||||
timelineSupport: true,
|
||||
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
|
||||
verificationMethods: [verificationMethods.SAS]
|
||||
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
|
||||
verificationMethods: [verificationMethods.SAS],
|
||||
unstableClientRelationAggregation: true,
|
||||
identityServer: new IdentityAuthClient(),
|
||||
};
|
||||
|
||||
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
|
||||
// TODO: Cross-signing keys are temporarily in memory only. A
|
||||
// separate task in the cross-signing project will build from here.
|
||||
const keys = [];
|
||||
opts.cryptoCallbacks = {
|
||||
getCrossSigningKey: k => keys[k],
|
||||
saveCrossSigningKeys: newKeys => Object.assign(keys, newKeys),
|
||||
};
|
||||
}
|
||||
|
||||
this.matrixClient = createMatrixClient(opts);
|
||||
|
||||
// we're going to add eventlisteners for each matrix event tile, so the
|
||||
|
|
131
src/Modal.js
131
src/Modal.js
|
@ -15,15 +15,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
|
||||
'use strict';
|
||||
|
||||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import Analytics from './Analytics';
|
||||
import sdk from './index';
|
||||
import dis from './dispatcher';
|
||||
import { _t } from './languageHandler';
|
||||
import {defer} from "./utils/promise";
|
||||
|
||||
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
|
||||
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
|
||||
|
@ -32,7 +32,7 @@ const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
|
|||
* Wrap an asynchronous loader function with a react component which shows a
|
||||
* spinner until the real component loads.
|
||||
*/
|
||||
const AsyncWrapper = React.createClass({
|
||||
const AsyncWrapper = createReactClass({
|
||||
propTypes: {
|
||||
/** A promise which resolves with the real component
|
||||
*/
|
||||
|
@ -156,15 +156,79 @@ class ModalManager {
|
|||
return this.createDialog(...rest);
|
||||
}
|
||||
|
||||
appendTrackedDialog(analyticsAction, analyticsInfo, ...rest) {
|
||||
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
|
||||
return this.appendDialog(...rest);
|
||||
}
|
||||
|
||||
createDialog(Element, ...rest) {
|
||||
return this.createDialogAsync(Promise.resolve(Element), ...rest);
|
||||
}
|
||||
|
||||
appendDialog(Element, ...rest) {
|
||||
return this.appendDialogAsync(Promise.resolve(Element), ...rest);
|
||||
}
|
||||
|
||||
createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) {
|
||||
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
|
||||
return this.createDialogAsync(...rest);
|
||||
}
|
||||
|
||||
appendTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) {
|
||||
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
|
||||
return this.appendDialogAsync(...rest);
|
||||
}
|
||||
|
||||
_buildModal(prom, props, className) {
|
||||
const modal = {};
|
||||
|
||||
// never call this from onFinished() otherwise it will loop
|
||||
const [closeDialog, onFinishedProm] = this._getCloseFn(modal, props);
|
||||
|
||||
// don't attempt to reuse the same AsyncWrapper for different dialogs,
|
||||
// otherwise we'll get confused.
|
||||
const modalCount = this._counter++;
|
||||
|
||||
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
|
||||
// property set here so you can't close the dialog from a button click!
|
||||
modal.elem = (
|
||||
<AsyncWrapper key={modalCount} prom={prom} {...props}
|
||||
onFinished={closeDialog} />
|
||||
);
|
||||
modal.onFinished = props ? props.onFinished : null;
|
||||
modal.className = className;
|
||||
|
||||
return {modal, closeDialog, onFinishedProm};
|
||||
}
|
||||
|
||||
_getCloseFn(modal, props) {
|
||||
const deferred = defer();
|
||||
return [(...args) => {
|
||||
deferred.resolve(args);
|
||||
if (props && props.onFinished) props.onFinished.apply(null, args);
|
||||
const i = this._modals.indexOf(modal);
|
||||
if (i >= 0) {
|
||||
this._modals.splice(i, 1);
|
||||
}
|
||||
|
||||
if (this._priorityModal === modal) {
|
||||
this._priorityModal = null;
|
||||
|
||||
// XXX: This is destructive
|
||||
this._modals = [];
|
||||
}
|
||||
|
||||
if (this._staticModal === modal) {
|
||||
this._staticModal = null;
|
||||
|
||||
// XXX: This is destructive
|
||||
this._modals = [];
|
||||
}
|
||||
|
||||
this._reRender();
|
||||
}, deferred.promise];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal view.
|
||||
*
|
||||
|
@ -195,46 +259,7 @@ class ModalManager {
|
|||
* @returns {object} Object with 'close' parameter being a function that will close the dialog
|
||||
*/
|
||||
createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) {
|
||||
const modal = {};
|
||||
|
||||
// never call this from onFinished() otherwise it will loop
|
||||
//
|
||||
const closeDialog = (...args) => {
|
||||
if (props && props.onFinished) props.onFinished.apply(null, args);
|
||||
const i = this._modals.indexOf(modal);
|
||||
if (i >= 0) {
|
||||
this._modals.splice(i, 1);
|
||||
}
|
||||
|
||||
if (this._priorityModal === modal) {
|
||||
this._priorityModal = null;
|
||||
|
||||
// XXX: This is destructive
|
||||
this._modals = [];
|
||||
}
|
||||
|
||||
if (this._staticModal === modal) {
|
||||
this._staticModal = null;
|
||||
|
||||
// XXX: This is destructive
|
||||
this._modals = [];
|
||||
}
|
||||
|
||||
this._reRender();
|
||||
};
|
||||
|
||||
// don't attempt to reuse the same AsyncWrapper for different dialogs,
|
||||
// otherwise we'll get confused.
|
||||
const modalCount = this._counter++;
|
||||
|
||||
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
|
||||
// property set here so you can't close the dialog from a button click!
|
||||
modal.elem = (
|
||||
<AsyncWrapper key={modalCount} prom={prom} {...props}
|
||||
onFinished={closeDialog} />
|
||||
);
|
||||
modal.onFinished = props ? props.onFinished : null;
|
||||
modal.className = className;
|
||||
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className);
|
||||
|
||||
if (isPriorityModal) {
|
||||
// XXX: This is destructive
|
||||
|
@ -247,7 +272,21 @@ class ModalManager {
|
|||
}
|
||||
|
||||
this._reRender();
|
||||
return {close: closeDialog};
|
||||
return {
|
||||
close: closeDialog,
|
||||
finished: onFinishedProm,
|
||||
};
|
||||
}
|
||||
|
||||
appendDialogAsync(prom, props, className) {
|
||||
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className);
|
||||
|
||||
this._modals.push(modal);
|
||||
this._reRender();
|
||||
return {
|
||||
close: closeDialog,
|
||||
finished: onFinishedProm,
|
||||
};
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
|
|
|
@ -85,7 +85,11 @@ const Notifier = {
|
|||
msg = '';
|
||||
}
|
||||
|
||||
const avatarUrl = ev.sender ? Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop') : null;
|
||||
let avatarUrl = null;
|
||||
if (ev.sender && !SettingsStore.getValue("lowBandwidth")) {
|
||||
avatarUrl = Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop');
|
||||
}
|
||||
|
||||
const notif = plaf.displayNotification(title, msg, avatarUrl, room);
|
||||
|
||||
// if displayNotification returns non-null, the platform supports
|
||||
|
@ -96,10 +100,55 @@ const Notifier = {
|
|||
}
|
||||
},
|
||||
|
||||
_playAudioNotification: function(ev, room) {
|
||||
const e = document.getElementById("messageAudio");
|
||||
if (e) {
|
||||
e.play();
|
||||
getSoundForRoom: async function(roomId) {
|
||||
// We do no caching here because the SDK caches setting
|
||||
// and the browser will cache the sound.
|
||||
const content = SettingsStore.getValue("notificationSound", roomId);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!content.url) {
|
||||
console.warn(`${roomId} has custom notification sound event, but no url key`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!content.url.startsWith("mxc://")) {
|
||||
console.warn(`${roomId} has custom notification sound event, but url is not a mxc url`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ideally in here we could use MSC1310 to detect the type of file, and reject it.
|
||||
|
||||
return {
|
||||
url: MatrixClientPeg.get().mxcUrlToHttp(content.url),
|
||||
name: content.name,
|
||||
type: content.type,
|
||||
size: content.size,
|
||||
};
|
||||
},
|
||||
|
||||
_playAudioNotification: async function(ev, room) {
|
||||
const sound = await this.getSoundForRoom(room.roomId);
|
||||
console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
|
||||
|
||||
try {
|
||||
const selector = document.querySelector(sound ? `audio[src='${sound.url}']` : "#messageAudio");
|
||||
let audioElement = selector;
|
||||
if (!selector) {
|
||||
if (!sound) {
|
||||
console.error("No audio element or sound to play for notification");
|
||||
return;
|
||||
}
|
||||
audioElement = new Audio(sound.url);
|
||||
if (sound.type) {
|
||||
audioElement.type = sound.type;
|
||||
}
|
||||
document.body.appendChild(audioElement);
|
||||
}
|
||||
await audioElement.play();
|
||||
} catch (ex) {
|
||||
console.warn("Caught error when trying to fetch room notification sound:", ex);
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -149,12 +198,13 @@ const Notifier = {
|
|||
|
||||
if (enable) {
|
||||
// Attempt to get permission from user
|
||||
plaf.requestNotificationPermission().done((result) => {
|
||||
plaf.requestNotificationPermission().then((result) => {
|
||||
if (result !== 'granted') {
|
||||
// The permission request was dismissed or denied
|
||||
// TODO: Support alternative branding in messaging
|
||||
const description = result === 'denied'
|
||||
? _t('Riot does not have permission to send you notifications - please check your browser settings')
|
||||
? _t('Riot does not have permission to send you notifications - ' +
|
||||
'please check your browser settings')
|
||||
: _t('Riot was not given permission to send notifications - please try again');
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -36,7 +37,11 @@ class PasswordReset {
|
|||
idBaseUrl: identityUrl,
|
||||
});
|
||||
this.clientSecret = this.client.generateClientSecret();
|
||||
this.identityServerDomain = identityUrl.split("://")[1];
|
||||
this.identityServerDomain = identityUrl ? identityUrl.split("://")[1] : null;
|
||||
}
|
||||
|
||||
doesServerRequireIdServerParam() {
|
||||
return this.client.doesServerRequireIdServerParam();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -68,15 +73,21 @@ class PasswordReset {
|
|||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
|
||||
*/
|
||||
checkEmailLinkClicked() {
|
||||
return this.client.setPassword({
|
||||
type: "m.login.email.identity",
|
||||
threepid_creds: {
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
id_server: this.identityServerDomain,
|
||||
},
|
||||
}, this.password).catch(function(err) {
|
||||
async checkEmailLinkClicked() {
|
||||
const creds = {
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
};
|
||||
if (await this.doesServerRequireIdServerParam()) {
|
||||
creds.id_server = this.identityServerDomain;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.setPassword({
|
||||
type: "m.login.email.identity",
|
||||
threepid_creds: creds,
|
||||
}, this.password);
|
||||
} catch (err) {
|
||||
if (err.httpStatus === 401) {
|
||||
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
|
||||
} else if (err.httpStatus === 404) {
|
||||
|
@ -86,7 +97,7 @@ class PasswordReset {
|
|||
err.message += ` (Status ${err.httpStatus})`;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
const MatrixClientPeg = require("./MatrixClientPeg");
|
||||
const dis = require("./dispatcher");
|
||||
import MatrixClientPeg from "./MatrixClientPeg";
|
||||
import dis from "./dispatcher";
|
||||
import Timer from './utils/Timer';
|
||||
|
||||
// Time in ms after that a user is considered as unavailable/away
|
||||
|
@ -24,7 +24,6 @@ const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
|
|||
const PRESENCE_STATES = ["online", "offline", "unavailable"];
|
||||
|
||||
class Presence {
|
||||
|
||||
constructor() {
|
||||
this._activitySignal = null;
|
||||
this._unavailableTimer = null;
|
||||
|
@ -43,7 +42,7 @@ class Presence {
|
|||
try {
|
||||
await this._unavailableTimer.finished();
|
||||
this.setState("unavailable");
|
||||
} catch(e) { /* aborted, stop got called */ }
|
||||
} catch (e) { /* aborted, stop got called */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,7 +87,7 @@ class Presence {
|
|||
if (PRESENCE_STATES.indexOf(newState) === -1) {
|
||||
throw new Error("Bad presence state: " + newState);
|
||||
}
|
||||
const old_state = this.state;
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
|
@ -97,10 +96,10 @@ class Presence {
|
|||
|
||||
try {
|
||||
await MatrixClientPeg.get().setPresence(this.state);
|
||||
console.log("Presence: %s", newState);
|
||||
} catch(err) {
|
||||
console.info("Presence: %s", newState);
|
||||
} catch (err) {
|
||||
console.error("Failed to set presence: %s", err);
|
||||
this.state = old_state;
|
||||
this.state = oldState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ module.exports = {
|
|||
},
|
||||
resend: function(event) {
|
||||
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
|
||||
MatrixClientPeg.get().resendEvent(event, room).done(function(res) {
|
||||
MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
|
||||
dis.dispatch({
|
||||
action: 'message_sent',
|
||||
event: event,
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
Copyright 2015 - 2017 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as emojione from 'emojione';
|
||||
|
||||
|
||||
export function unicodeToEmojiUri(str) {
|
||||
const mappedUnicode = emojione.mapUnicodeToShort();
|
||||
|
||||
// remove any zero width joiners/spaces used in conjugate emojis as the emojione URIs don't contain them
|
||||
return str.replace(emojione.regUnicode, function(unicodeChar) {
|
||||
if ((typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap))) {
|
||||
// if the unicodeChar doesn't exist just return the entire match
|
||||
return unicodeChar;
|
||||
} else {
|
||||
// get the unicode codepoint from the actual char
|
||||
const unicode = emojione.jsEscapeMap[unicodeChar];
|
||||
|
||||
const short = mappedUnicode[unicode];
|
||||
const fname = emojione.emojioneList[short].fname;
|
||||
|
||||
return emojione.imagePathSVG+fname+'.svg'+emojione.cacheBustParam;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -26,10 +26,10 @@ export function levelRoleMap(usersDefault) {
|
|||
}
|
||||
|
||||
export function textualPowerLevel(level, usersDefault) {
|
||||
const LEVEL_ROLE_MAP = this.levelRoleMap(usersDefault);
|
||||
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
|
||||
if (LEVEL_ROLE_MAP[level]) {
|
||||
return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`);
|
||||
return LEVEL_ROLE_MAP[level];
|
||||
} else {
|
||||
return level;
|
||||
return _t("Custom (%(level)s)", {level});
|
||||
}
|
||||
}
|
||||
|
|
35
src/RoomAliasCache.js
Normal file
35
src/RoomAliasCache.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is meant to be a cache of room alias to room ID so that moving between
|
||||
* rooms happens smoothly (for example using browser back / forward buttons).
|
||||
*
|
||||
* For the moment, it's in memory only and so only applies for the current
|
||||
* session for simplicity, but could be extended further in the future.
|
||||
*
|
||||
* A similar thing could also be achieved via `pushState` with a state object,
|
||||
* but keeping it separate like this seems easier in case we do want to extend.
|
||||
*/
|
||||
const aliasToIDMap = new Map();
|
||||
|
||||
export function storeRoomAliasInCache(alias, id) {
|
||||
aliasToIDMap.set(alias, id);
|
||||
}
|
||||
|
||||
export function getCachedRoomIDForAlias(alias) {
|
||||
return aliasToIDMap.get(alias);
|
||||
}
|
|
@ -42,27 +42,43 @@ function inviteMultipleToRoom(roomId, addrs) {
|
|||
|
||||
export function showStartChatInviteDialog() {
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
|
||||
Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, {
|
||||
title: _t('Start a chat'),
|
||||
description: _t("Who would you like to communicate with?"),
|
||||
placeholder: _t("Email, name or matrix ID"),
|
||||
placeholder: (validAddressTypes) => {
|
||||
// The set of valid address type can be mutated inside the dialog
|
||||
// when you first have no IS but agree to use one in the dialog.
|
||||
if (validAddressTypes.includes('email')) {
|
||||
return _t("Email, name or Matrix ID");
|
||||
}
|
||||
return _t("Name or Matrix ID");
|
||||
},
|
||||
validAddressTypes: ['mx-user-id', 'email'],
|
||||
button: _t("Start Chat"),
|
||||
onFinished: _onStartChatFinished,
|
||||
});
|
||||
onFinished: _onStartDmFinished,
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
}
|
||||
|
||||
export function showRoomInviteDialog(roomId) {
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
|
||||
Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, {
|
||||
title: _t('Invite new room members'),
|
||||
description: _t('Who would you like to add to this room?'),
|
||||
button: _t('Send Invites'),
|
||||
placeholder: _t("Email, name or matrix ID"),
|
||||
placeholder: (validAddressTypes) => {
|
||||
// The set of valid address type can be mutated inside the dialog
|
||||
// when you first have no IS but agree to use one in the dialog.
|
||||
if (validAddressTypes.includes('email')) {
|
||||
return _t("Email, name or Matrix ID");
|
||||
}
|
||||
return _t("Name or Matrix ID");
|
||||
},
|
||||
validAddressTypes: ['mx-user-id', 'email'],
|
||||
onFinished: (shouldInvite, addrs) => {
|
||||
_onRoomInviteFinished(roomId, shouldInvite, addrs);
|
||||
},
|
||||
});
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -83,7 +99,8 @@ export function isValid3pidInvite(event) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function _onStartChatFinished(shouldInvite, addrs) {
|
||||
// TODO: Immutable DMs replaces this
|
||||
function _onStartDmFinished(shouldInvite, addrs) {
|
||||
if (!shouldInvite) return;
|
||||
|
||||
const addrTexts = addrs.map((addr) => addr.address);
|
||||
|
@ -91,32 +108,19 @@ function _onStartChatFinished(shouldInvite, addrs) {
|
|||
if (_isDmChat(addrTexts)) {
|
||||
const rooms = _getDirectMessageRooms(addrTexts[0]);
|
||||
if (rooms.length > 0) {
|
||||
// A Direct Message room already exists for this user, so select a
|
||||
// room from a list that is similar to the one in MemberInfo panel
|
||||
const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog");
|
||||
const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, {
|
||||
userId: addrTexts[0],
|
||||
onNewDMClick: () => {
|
||||
dis.dispatch({
|
||||
action: 'start_chat',
|
||||
user_id: addrTexts[0],
|
||||
});
|
||||
close(true);
|
||||
},
|
||||
onExistingRoomSelected: (roomId) => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
});
|
||||
close(true);
|
||||
},
|
||||
}).close;
|
||||
// A Direct Message room already exists for this user, so reuse it
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: rooms[0],
|
||||
should_peek: false,
|
||||
joining: false,
|
||||
});
|
||||
} else {
|
||||
// Start a new DM chat
|
||||
createRoom({dmUserId: addrTexts[0]}).catch((err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, {
|
||||
title: _t("Failed to invite user"),
|
||||
Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, {
|
||||
title: _t("Failed to start chat"),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
});
|
||||
});
|
||||
|
@ -125,8 +129,8 @@ function _onStartChatFinished(shouldInvite, addrs) {
|
|||
// Start a new DM chat
|
||||
createRoom({dmUserId: addrTexts[0]}).catch((err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, {
|
||||
title: _t("Failed to invite user"),
|
||||
Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, {
|
||||
title: _t("Failed to start chat"),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
});
|
||||
});
|
||||
|
@ -168,6 +172,7 @@ function _onRoomInviteFinished(roomId, shouldInvite, addrs) {
|
|||
});
|
||||
}
|
||||
|
||||
// TODO: Immutable DMs replaces this
|
||||
function _isDmChat(addrTexts) {
|
||||
if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') {
|
||||
return true;
|
||||
|
@ -198,10 +203,13 @@ function _showAnyInviteErrors(addrs, room, inviter) {
|
|||
}
|
||||
|
||||
if (errorList.length > 0) {
|
||||
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
|
||||
const description = <div>{errorList.map(e => <div key={e}>{e}</div>)}</div>;
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, {
|
||||
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
|
||||
description: errorList.join(<br />),
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -220,4 +228,3 @@ function _getDirectMessageRooms(addr) {
|
|||
});
|
||||
return rooms;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,7 +17,6 @@ limitations under the License.
|
|||
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
export const ALL_MESSAGES_LOUD = 'all_messages_loud';
|
||||
export const ALL_MESSAGES = 'all_messages';
|
||||
|
@ -26,13 +26,33 @@ export const MUTE = 'mute';
|
|||
export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD];
|
||||
export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY];
|
||||
|
||||
function _shouldShowNotifBadge(roomNotifState) {
|
||||
const showBadgeInStates = [ALL_MESSAGES, ALL_MESSAGES_LOUD];
|
||||
return showBadgeInStates.indexOf(roomNotifState) > -1;
|
||||
export function shouldShowNotifBadge(roomNotifState) {
|
||||
return BADGE_STATES.includes(roomNotifState);
|
||||
}
|
||||
|
||||
function _shouldShowMentionBadge(roomNotifState) {
|
||||
return roomNotifState !== MUTE;
|
||||
export function shouldShowMentionBadge(roomNotifState) {
|
||||
return MENTION_BADGE_STATES.includes(roomNotifState);
|
||||
}
|
||||
|
||||
export function countRoomsWithNotif(rooms) {
|
||||
return rooms.reduce((result, room, index) => {
|
||||
const roomNotifState = getRoomNotifsState(room.roomId);
|
||||
const highlight = room.getUnreadNotificationCount('highlight') > 0;
|
||||
const notificationCount = room.getUnreadNotificationCount();
|
||||
|
||||
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
|
||||
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
|
||||
const isInvite = room.hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite');
|
||||
const badges = notifBadges || mentionBadges || isInvite;
|
||||
|
||||
if (badges) {
|
||||
result.count++;
|
||||
if (highlight) {
|
||||
result.highlight = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, {count: 0, highlight: false});
|
||||
}
|
||||
|
||||
export function aggregateNotificationCount(rooms) {
|
||||
|
@ -41,8 +61,8 @@ export function aggregateNotificationCount(rooms) {
|
|||
const highlight = room.getUnreadNotificationCount('highlight') > 0;
|
||||
const notificationCount = room.getUnreadNotificationCount();
|
||||
|
||||
const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState);
|
||||
const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState);
|
||||
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
|
||||
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
|
||||
const badges = notifBadges || mentionBadges;
|
||||
|
||||
if (badges) {
|
||||
|
@ -60,8 +80,8 @@ export function getRoomHasBadge(room) {
|
|||
const highlight = room.getUnreadNotificationCount('highlight') > 0;
|
||||
const notificationCount = room.getUnreadNotificationCount();
|
||||
|
||||
const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState);
|
||||
const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState);
|
||||
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
|
||||
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
|
||||
|
||||
return notifBadges || mentionBadges;
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
/**
|
||||
* Given a room object, return the alias we should use for it,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,19 +15,60 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import Promise from 'bluebird';
|
||||
import url from 'url';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
|
||||
const request = require('browser-request');
|
||||
|
||||
const SdkConfig = require('./SdkConfig');
|
||||
const MatrixClientPeg = require('./MatrixClientPeg');
|
||||
|
||||
import * as Matrix from 'matrix-js-sdk';
|
||||
|
||||
// The version of the integration manager API we're intending to work with
|
||||
const imApiVersion = "1.1";
|
||||
|
||||
class ScalarAuthClient {
|
||||
constructor() {
|
||||
export default class ScalarAuthClient {
|
||||
constructor(apiUrl, uiUrl) {
|
||||
this.apiUrl = apiUrl;
|
||||
this.uiUrl = uiUrl;
|
||||
this.scalarToken = null;
|
||||
// `undefined` to allow `startTermsFlow` to fallback to a default
|
||||
// callback if this is unset.
|
||||
this.termsInteractionCallback = undefined;
|
||||
|
||||
// We try and store the token on a per-manager basis, but need a fallback
|
||||
// for the default manager.
|
||||
const configApiUrl = SdkConfig.get()['integrations_rest_url'];
|
||||
const configUiUrl = SdkConfig.get()['integrations_ui_url'];
|
||||
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
|
||||
}
|
||||
|
||||
_writeTokenToStore() {
|
||||
window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken);
|
||||
if (this.isDefaultManager) {
|
||||
// We remove the old token from storage to migrate upwards. This is safe
|
||||
// to do because even if the user switches to /app when this is on /develop
|
||||
// they'll at worst register for a new token.
|
||||
window.localStorage.removeItem("mx_scalar_token"); // no-op when not present
|
||||
}
|
||||
}
|
||||
|
||||
_readTokenFromStore() {
|
||||
let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl);
|
||||
if (!token && this.isDefaultManager) {
|
||||
token = window.localStorage.getItem("mx_scalar_token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
_readToken() {
|
||||
if (this.scalarToken) return this.scalarToken;
|
||||
return this._readTokenFromStore();
|
||||
}
|
||||
|
||||
setTermsInteractionCallback(callback) {
|
||||
this.termsInteractionCallback = callback;
|
||||
}
|
||||
|
||||
connect() {
|
||||
|
@ -39,31 +81,25 @@ class ScalarAuthClient {
|
|||
return this.scalarToken != null; // undef or null
|
||||
}
|
||||
|
||||
// Returns a scalar_token string
|
||||
// Returns a promise that resolves to a scalar_token string
|
||||
getScalarToken() {
|
||||
const token = window.localStorage.getItem("mx_scalar_token");
|
||||
const token = this._readToken();
|
||||
|
||||
if (!token) {
|
||||
return this.registerForToken();
|
||||
} else {
|
||||
return this.validateToken(token).then(userId => {
|
||||
const me = MatrixClientPeg.get().getUserId();
|
||||
if (userId !== me) {
|
||||
throw new Error("Scalar token is owned by someone else: " + me);
|
||||
return this._checkToken(token).catch((e) => {
|
||||
if (e instanceof TermsNotSignedError) {
|
||||
// retrying won't help this
|
||||
throw e;
|
||||
}
|
||||
return token;
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
|
||||
// Something went wrong - try to get a new token.
|
||||
console.warn("Registering for new scalar token");
|
||||
return this.registerForToken();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validateToken(token) {
|
||||
const url = SdkConfig.get().integrations_rest_url + "/account";
|
||||
_getAccountName(token) {
|
||||
const url = this.apiUrl + "/account";
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
request({
|
||||
|
@ -74,8 +110,10 @@ class ScalarAuthClient {
|
|||
}, (err, response, body) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (body && body.errcode === 'M_TERMS_NOT_SIGNED') {
|
||||
reject(new TermsNotSignedError());
|
||||
} else if (response.statusCode / 100 !== 2) {
|
||||
reject({statusCode: response.statusCode});
|
||||
reject(body);
|
||||
} else if (!body || !body.user_id) {
|
||||
reject(new Error("Missing user_id in response"));
|
||||
} else {
|
||||
|
@ -85,26 +123,70 @@ class ScalarAuthClient {
|
|||
});
|
||||
}
|
||||
|
||||
registerForToken() {
|
||||
// Get openid bearer token from the HS as the first part of our dance
|
||||
return MatrixClientPeg.get().getOpenIdToken().then((token_object) => {
|
||||
// Now we can send that to scalar and exchange it for a scalar token
|
||||
return this.exchangeForScalarToken(token_object);
|
||||
}).then((token_object) => {
|
||||
window.localStorage.setItem("mx_scalar_token", token_object);
|
||||
return token_object;
|
||||
_checkToken(token) {
|
||||
return this._getAccountName(token).then(userId => {
|
||||
const me = MatrixClientPeg.get().getUserId();
|
||||
if (userId !== me) {
|
||||
throw new Error("Scalar token is owned by someone else: " + me);
|
||||
}
|
||||
return token;
|
||||
}).catch((e) => {
|
||||
if (e instanceof TermsNotSignedError) {
|
||||
console.log("Integration manager requires new terms to be agreed to");
|
||||
// The terms endpoints are new and so live on standard _matrix prefixes,
|
||||
// but IM rest urls are currently configured with paths, so remove the
|
||||
// path from the base URL before passing it to the js-sdk
|
||||
|
||||
// We continue to use the full URL for the calls done by
|
||||
// matrix-react-sdk, but the standard terms API called
|
||||
// by the js-sdk lives on the standard _matrix path. This means we
|
||||
// don't support running IMs on a non-root path, but it's the only
|
||||
// realistic way of transitioning to _matrix paths since configs in
|
||||
// the wild contain bits of the API path.
|
||||
|
||||
// Once we've fully transitioned to _matrix URLs, we can give people
|
||||
// a grace period to update their configs, then use the rest url as
|
||||
// a regular base url.
|
||||
const parsedImRestUrl = url.parse(this.apiUrl);
|
||||
parsedImRestUrl.path = '';
|
||||
parsedImRestUrl.pathname = '';
|
||||
return startTermsFlow([new Service(
|
||||
Matrix.SERVICE_TYPES.IM,
|
||||
parsedImRestUrl.format(),
|
||||
token,
|
||||
)], this.termsInteractionCallback).then(() => {
|
||||
return token;
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
exchangeForScalarToken(openid_token_object) {
|
||||
const scalar_rest_url = SdkConfig.get().integrations_rest_url;
|
||||
registerForToken() {
|
||||
// Get openid bearer token from the HS as the first part of our dance
|
||||
return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => {
|
||||
// Now we can send that to scalar and exchange it for a scalar token
|
||||
return this.exchangeForScalarToken(tokenObject);
|
||||
}).then((token) => {
|
||||
// Validate it (this mostly checks to see if the IM needs us to agree to some terms)
|
||||
return this._checkToken(token);
|
||||
}).then((token) => {
|
||||
this.scalarToken = token;
|
||||
this._writeTokenToStore();
|
||||
return token;
|
||||
});
|
||||
}
|
||||
|
||||
exchangeForScalarToken(openidTokenObject) {
|
||||
const scalarRestUrl = this.apiUrl;
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
request({
|
||||
method: 'POST',
|
||||
uri: scalar_rest_url+'/register',
|
||||
uri: scalarRestUrl + '/register',
|
||||
qs: {v: imApiVersion},
|
||||
body: openid_token_object,
|
||||
body: openidTokenObject,
|
||||
json: true,
|
||||
}, (err, response, body) => {
|
||||
if (err) {
|
||||
|
@ -121,7 +203,7 @@ class ScalarAuthClient {
|
|||
}
|
||||
|
||||
getScalarPageTitle(url) {
|
||||
let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup';
|
||||
let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup';
|
||||
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
|
||||
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
|
||||
|
||||
|
@ -157,7 +239,7 @@ class ScalarAuthClient {
|
|||
* @return {Promise} Resolves on completion
|
||||
*/
|
||||
disableWidgetAssets(widgetType, widgetId) {
|
||||
let url = SdkConfig.get().integrations_rest_url + '/widgets/set_assets_state';
|
||||
let url = this.apiUrl + '/widgets/set_assets_state';
|
||||
url = this.getStarterLink(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
request({
|
||||
|
@ -186,7 +268,7 @@ class ScalarAuthClient {
|
|||
getScalarInterfaceUrlForRoom(room, screen, id) {
|
||||
const roomId = room.roomId;
|
||||
const roomName = room.name;
|
||||
let url = SdkConfig.get().integrations_ui_url;
|
||||
let url = this.uiUrl;
|
||||
url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
|
||||
url += "&room_id=" + encodeURIComponent(roomId);
|
||||
url += "&room_name=" + encodeURIComponent(roomName);
|
||||
|
@ -204,5 +286,3 @@ class ScalarAuthClient {
|
|||
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScalarAuthClient;
|
||||
|
|
|
@ -232,13 +232,13 @@ Example:
|
|||
}
|
||||
*/
|
||||
|
||||
import SdkConfig from './SdkConfig';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import dis from './dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
import { _t } from './languageHandler';
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
|
||||
function sendResponse(event, res) {
|
||||
const data = JSON.parse(JSON.stringify(event.data));
|
||||
|
@ -279,7 +279,7 @@ function inviteUser(event, roomId, userId) {
|
|||
}
|
||||
}
|
||||
|
||||
client.invite(roomId, userId).done(function() {
|
||||
client.invite(roomId, userId).then(function() {
|
||||
sendResponse(event, {
|
||||
success: true,
|
||||
});
|
||||
|
@ -398,7 +398,7 @@ function setPlumbingState(event, roomId, status) {
|
|||
sendError(event, _t('You need to be logged in.'));
|
||||
return;
|
||||
}
|
||||
client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).done(() => {
|
||||
client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).then(() => {
|
||||
sendResponse(event, {
|
||||
success: true,
|
||||
});
|
||||
|
@ -414,7 +414,7 @@ function setBotOptions(event, roomId, userId) {
|
|||
sendError(event, _t('You need to be logged in.'));
|
||||
return;
|
||||
}
|
||||
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => {
|
||||
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).then(() => {
|
||||
sendResponse(event, {
|
||||
success: true,
|
||||
});
|
||||
|
@ -444,7 +444,7 @@ function setBotPower(event, roomId, userId, level) {
|
|||
},
|
||||
);
|
||||
|
||||
client.setPowerLevel(roomId, userId, level, powerEvent).done(() => {
|
||||
client.setPowerLevel(roomId, userId, level, powerEvent).then(() => {
|
||||
sendResponse(event, {
|
||||
success: true,
|
||||
});
|
||||
|
@ -546,20 +546,30 @@ const onMessage = function(event) {
|
|||
// This means the URL could contain a path (like /develop) and still be used
|
||||
// to validate event origins, which do not specify paths.
|
||||
// (See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)
|
||||
//
|
||||
// All strings start with the empty string, so for sanity return if the length
|
||||
// of the event origin is 0.
|
||||
//
|
||||
let configUrl;
|
||||
try {
|
||||
if (!openManagerUrl) openManagerUrl = IntegrationManagers.sharedInstance().getPrimaryManager().uiUrl;
|
||||
configUrl = new URL(openManagerUrl);
|
||||
} catch (e) {
|
||||
// No integrations UI URL, ignore silently.
|
||||
return;
|
||||
}
|
||||
let eventOriginUrl;
|
||||
try {
|
||||
eventOriginUrl = new URL(event.origin);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
// TODO -- Scalar postMessage API should be namespaced with event.data.api field
|
||||
// Fix following "if" statement to respond only to specific API messages.
|
||||
const url = SdkConfig.get().integrations_ui_url;
|
||||
if (
|
||||
event.origin.length === 0 ||
|
||||
!url.startsWith(event.origin + '/') ||
|
||||
configUrl.origin !== eventOriginUrl.origin ||
|
||||
!event.data.action ||
|
||||
event.data.api // Ignore messages with specific API set
|
||||
) {
|
||||
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
|
||||
// don't log this - debugging APIs and browser add-ons like to spam
|
||||
// postMessage which floods the log otherwise
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.action === "close_scalar") {
|
||||
|
@ -647,6 +657,7 @@ const onMessage = function(event) {
|
|||
};
|
||||
|
||||
let listenerCount = 0;
|
||||
let openManagerUrl = null;
|
||||
module.exports = {
|
||||
startListening: function() {
|
||||
if (listenerCount === 0) {
|
||||
|
@ -669,4 +680,8 @@ module.exports = {
|
|||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
setOpenManagerUrl: function(url) {
|
||||
openManagerUrl = url;
|
||||
},
|
||||
};
|
||||
|
|
138
src/Searching.js
Normal file
138
src/Searching.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import EventIndexPeg from "./indexing/EventIndexPeg";
|
||||
import MatrixClientPeg from "./MatrixClientPeg";
|
||||
|
||||
function serverSideSearch(term, roomId = undefined) {
|
||||
let filter;
|
||||
if (roomId !== undefined) {
|
||||
// XXX: it's unintuitive that the filter for searching doesn't have
|
||||
// the same shape as the v2 filter API :(
|
||||
filter = {
|
||||
rooms: [roomId],
|
||||
};
|
||||
}
|
||||
|
||||
const searchPromise = MatrixClientPeg.get().searchRoomEvents({
|
||||
filter,
|
||||
term,
|
||||
});
|
||||
|
||||
return searchPromise;
|
||||
}
|
||||
|
||||
async function combinedSearch(searchTerm) {
|
||||
// Create two promises, one for the local search, one for the
|
||||
// server-side search.
|
||||
const serverSidePromise = serverSideSearch(searchTerm);
|
||||
const localPromise = localSearch(searchTerm);
|
||||
|
||||
// Wait for both promises to resolve.
|
||||
await Promise.all([serverSidePromise, localPromise]);
|
||||
|
||||
// Get both search results.
|
||||
const localResult = await localPromise;
|
||||
const serverSideResult = await serverSidePromise;
|
||||
|
||||
// Combine the search results into one result.
|
||||
const result = {};
|
||||
|
||||
// Our localResult and serverSideResult are both ordered by
|
||||
// recency separately, when we combine them the order might not
|
||||
// be the right one so we need to sort them.
|
||||
const compare = (a, b) => {
|
||||
const aEvent = a.context.getEvent().event;
|
||||
const bEvent = b.context.getEvent().event;
|
||||
|
||||
if (aEvent.origin_server_ts >
|
||||
bEvent.origin_server_ts) return -1;
|
||||
if (aEvent.origin_server_ts <
|
||||
bEvent.origin_server_ts) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
result.count = localResult.count + serverSideResult.count;
|
||||
result.results = localResult.results.concat(
|
||||
serverSideResult.results).sort(compare);
|
||||
result.highlights = localResult.highlights.concat(
|
||||
serverSideResult.highlights);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function localSearch(searchTerm, roomId = undefined) {
|
||||
const searchArgs = {
|
||||
search_term: searchTerm,
|
||||
before_limit: 1,
|
||||
after_limit: 1,
|
||||
order_by_recency: true,
|
||||
room_id: undefined,
|
||||
};
|
||||
|
||||
if (roomId !== undefined) {
|
||||
searchArgs.room_id = roomId;
|
||||
}
|
||||
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
const localResult = await eventIndex.search(searchArgs);
|
||||
|
||||
const response = {
|
||||
search_categories: {
|
||||
room_events: localResult,
|
||||
},
|
||||
};
|
||||
|
||||
const emptyResult = {
|
||||
results: [],
|
||||
highlights: [],
|
||||
};
|
||||
|
||||
const result = MatrixClientPeg.get()._processRoomEventsSearch(
|
||||
emptyResult, response);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function eventIndexSearch(term, roomId = undefined) {
|
||||
let searchPromise;
|
||||
|
||||
if (roomId !== undefined) {
|
||||
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
|
||||
// The search is for a single encrypted room, use our local
|
||||
// search method.
|
||||
searchPromise = localSearch(term, roomId);
|
||||
} else {
|
||||
// The search is for a single non-encrypted room, use the
|
||||
// server-side search.
|
||||
searchPromise = serverSideSearch(term, roomId);
|
||||
}
|
||||
} else {
|
||||
// Search across all rooms, combine a server side search and a
|
||||
// local search.
|
||||
searchPromise = combinedSearch(term);
|
||||
}
|
||||
|
||||
return searchPromise;
|
||||
}
|
||||
|
||||
export default function eventSearch(term, roomId = undefined) {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
if (eventIndex === null) return serverSideSearch(term, roomId);
|
||||
else return eventIndexSearch(term, roomId);
|
||||
}
|
60
src/SendHistoryManager.js
Normal file
60
src/SendHistoryManager.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
//@flow
|
||||
/*
|
||||
Copyright 2017 Aviral Dasgupta
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import _clamp from 'lodash/clamp';
|
||||
|
||||
export default class SendHistoryManager {
|
||||
history: Array<HistoryItem> = [];
|
||||
prefix: string;
|
||||
lastIndex: number = 0; // used for indexing the storage
|
||||
currentIndex: number = 0; // used for indexing the loaded validated history Array
|
||||
|
||||
constructor(roomId: string, prefix: string) {
|
||||
this.prefix = prefix + roomId;
|
||||
|
||||
// TODO: Performance issues?
|
||||
let index = 0;
|
||||
let itemJSON;
|
||||
|
||||
while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) {
|
||||
try {
|
||||
const serializedParts = JSON.parse(itemJSON);
|
||||
this.history.push(serializedParts);
|
||||
} catch (e) {
|
||||
console.warn("Throwing away unserialisable history", e);
|
||||
break;
|
||||
}
|
||||
++index;
|
||||
}
|
||||
this.lastIndex = this.history.length - 1;
|
||||
// reset currentIndex to account for any unserialisable history
|
||||
this.currentIndex = this.lastIndex + 1;
|
||||
}
|
||||
|
||||
save(editorModel: Object) {
|
||||
const serializedParts = editorModel.serializeParts();
|
||||
this.history.push(serializedParts);
|
||||
this.currentIndex = this.history.length;
|
||||
this.lastIndex += 1;
|
||||
sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts));
|
||||
}
|
||||
|
||||
getItem(offset: number): ?HistoryItem {
|
||||
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
|
||||
return this.history[this.currentIndex];
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -19,24 +20,54 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import dis from './dispatcher';
|
||||
import Tinter from './Tinter';
|
||||
import sdk from './index';
|
||||
import {_t, _td} from './languageHandler';
|
||||
import Modal from './Modal';
|
||||
import SettingsStore, {SettingLevel} from './settings/SettingsStore';
|
||||
import {MATRIXTO_URL_PATTERN} from "./linkify-matrix";
|
||||
import * as querystring from "querystring";
|
||||
import MultiInviter from './utils/MultiInviter';
|
||||
import { linkifyAndSanitizeHtml } from './HtmlUtils';
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import WidgetUtils from "./utils/WidgetUtils";
|
||||
import {textToHtmlRainbow} from "./utils/colour";
|
||||
import { getAddressType } from './UserAddress';
|
||||
import { abbreviateUrl } from './utils/UrlUtils';
|
||||
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
|
||||
import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks";
|
||||
|
||||
const singleMxcUpload = async () => {
|
||||
return new Promise((resolve) => {
|
||||
const fileSelector = document.createElement('input');
|
||||
fileSelector.setAttribute('type', 'file');
|
||||
fileSelector.onchange = (ev) => {
|
||||
const file = ev.target.files[0];
|
||||
|
||||
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
|
||||
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
|
||||
file,
|
||||
onFinished: (shouldContinue) => {
|
||||
resolve(shouldContinue ? MatrixClientPeg.get().uploadContent(file) : null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
fileSelector.click();
|
||||
});
|
||||
};
|
||||
|
||||
export const CommandCategories = {
|
||||
"messages": _td("Messages"),
|
||||
"actions": _td("Actions"),
|
||||
"admin": _td("Admin"),
|
||||
"advanced": _td("Advanced"),
|
||||
"other": _td("Other"),
|
||||
};
|
||||
|
||||
class Command {
|
||||
constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -83,8 +114,17 @@ export const CommandMap = {
|
|||
}
|
||||
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
plain: new Command({
|
||||
name: 'plain',
|
||||
args: '<message>',
|
||||
description: _td('Sends a message as plain text, without interpreting it as markdown'),
|
||||
runFn: function(roomId, messages) {
|
||||
return success(MatrixClientPeg.get().sendTextMessage(roomId, messages));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
||||
ddg: new Command({
|
||||
name: 'ddg',
|
||||
args: '<query>',
|
||||
|
@ -98,6 +138,7 @@ export const CommandMap = {
|
|||
});
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
hideCompletionAfterSpace: true,
|
||||
}),
|
||||
|
||||
|
@ -107,8 +148,13 @@ export const CommandMap = {
|
|||
description: _td('Upgrades a room to a new version'),
|
||||
runFn: function(roomId, args) {
|
||||
if (args) {
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(roomId);
|
||||
if (!room.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) {
|
||||
return reject(_t("You do not have the required permissions to use this command."));
|
||||
}
|
||||
|
||||
const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
|
||||
QuestionDialog, {
|
||||
title: _t('Room upgrade confirmation'),
|
||||
description: (
|
||||
|
@ -166,16 +212,17 @@ export const CommandMap = {
|
|||
</div>
|
||||
),
|
||||
button: _t("Upgrade"),
|
||||
onFinished: (confirm) => {
|
||||
if (!confirm) return;
|
||||
|
||||
MatrixClientPeg.get().upgradeRoom(roomId, args);
|
||||
},
|
||||
});
|
||||
return success();
|
||||
|
||||
return success(finished.then(([confirm]) => {
|
||||
if (!confirm) return;
|
||||
|
||||
return cli.upgradeRoom(roomId, args);
|
||||
}));
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
nick: new Command({
|
||||
|
@ -188,10 +235,11 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
roomnick: new Command({
|
||||
name: 'roomnick',
|
||||
myroomnick: new Command({
|
||||
name: 'myroomnick',
|
||||
args: '<display_name>',
|
||||
description: _td('Changes your display nickname in the current room only'),
|
||||
runFn: function(roomId, args) {
|
||||
|
@ -206,31 +254,70 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
tint: new Command({
|
||||
name: 'tint',
|
||||
args: '<color1> [<color2>]',
|
||||
description: _td('Changes colour scheme of current room'),
|
||||
roomavatar: new Command({
|
||||
name: 'roomavatar',
|
||||
args: '[<mxc_url>]',
|
||||
description: _td('Changes the avatar of the current room'),
|
||||
runFn: function(roomId, args) {
|
||||
if (args) {
|
||||
const matches = args.match(/^(#([\da-fA-F]{3}|[\da-fA-F]{6}))( +(#([\da-fA-F]{3}|[\da-fA-F]{6})))?$/);
|
||||
if (matches) {
|
||||
Tinter.tint(matches[1], matches[4]);
|
||||
const colorScheme = {};
|
||||
colorScheme.primary_color = matches[1];
|
||||
if (matches[4]) {
|
||||
colorScheme.secondary_color = matches[4];
|
||||
} else {
|
||||
colorScheme.secondary_color = colorScheme.primary_color;
|
||||
}
|
||||
return success(
|
||||
SettingsStore.setValue('roomColor', roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
|
||||
);
|
||||
}
|
||||
let promise = Promise.resolve(args);
|
||||
if (!args) {
|
||||
promise = singleMxcUpload();
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
|
||||
return success(promise.then((url) => {
|
||||
if (!url) return;
|
||||
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', {url}, '');
|
||||
}));
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
myroomavatar: new Command({
|
||||
name: 'myroomavatar',
|
||||
args: '[<mxc_url>]',
|
||||
description: _td('Changes your avatar in this current room only'),
|
||||
runFn: function(roomId, args) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(roomId);
|
||||
const userId = cli.getUserId();
|
||||
|
||||
let promise = Promise.resolve(args);
|
||||
if (!args) {
|
||||
promise = singleMxcUpload();
|
||||
}
|
||||
|
||||
return success(promise.then((url) => {
|
||||
if (!url) return;
|
||||
const ev = room.currentState.getStateEvents('m.room.member', userId);
|
||||
const content = {
|
||||
...ev ? ev.getContent() : { membership: 'join' },
|
||||
avatar_url: url,
|
||||
};
|
||||
return cli.sendStateEvent(roomId, 'm.room.member', content, userId);
|
||||
}));
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
myavatar: new Command({
|
||||
name: 'myavatar',
|
||||
args: '[<mxc_url>]',
|
||||
description: _td('Changes your avatar in all rooms'),
|
||||
runFn: function(roomId, args) {
|
||||
let promise = Promise.resolve(args);
|
||||
if (!args) {
|
||||
promise = singleMxcUpload();
|
||||
}
|
||||
|
||||
return success(promise.then((url) => {
|
||||
if (!url) return;
|
||||
return MatrixClientPeg.get().setAvatarUrl(url);
|
||||
}));
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
topic: new Command({
|
||||
|
@ -256,6 +343,7 @@ export const CommandMap = {
|
|||
});
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
roomname: new Command({
|
||||
|
@ -268,6 +356,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
invite: new Command({
|
||||
|
@ -280,17 +369,53 @@ export const CommandMap = {
|
|||
if (matches) {
|
||||
// We use a MultiInviter to re-use the invite logic, even though
|
||||
// we're only inviting one user.
|
||||
const userId = matches[1];
|
||||
const address = matches[1];
|
||||
// If we need an identity server but don't have one, things
|
||||
// get a bit more complex here, but we try to show something
|
||||
// meaningful.
|
||||
let finished = Promise.resolve();
|
||||
if (
|
||||
getAddressType(address) === 'email' &&
|
||||
!MatrixClientPeg.get().getIdentityServerUrl()
|
||||
) {
|
||||
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
|
||||
if (defaultIdentityServerUrl) {
|
||||
({ finished } = Modal.createTrackedDialog('Slash Commands', 'Identity server',
|
||||
QuestionDialog, {
|
||||
title: _t("Use an identity server"),
|
||||
description: <p>{_t(
|
||||
"Use an identity server to invite by email. " +
|
||||
"Click continue to use the default identity server " +
|
||||
"(%(defaultIdentityServerName)s) or manage in Settings.",
|
||||
{
|
||||
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
|
||||
},
|
||||
)}</p>,
|
||||
button: _t("Continue"),
|
||||
},
|
||||
));
|
||||
} else {
|
||||
return reject(_t("Use an identity server to invite by email. Manage in Settings."));
|
||||
}
|
||||
}
|
||||
const inviter = new MultiInviter(roomId);
|
||||
return success(inviter.invite([userId]).then(() => {
|
||||
if (inviter.getCompletionState(userId) !== "invited") {
|
||||
throw new Error(inviter.getErrorText(userId));
|
||||
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 inviter.invite([address]);
|
||||
}).then(() => {
|
||||
if (inviter.getCompletionState(address) !== "invited") {
|
||||
throw new Error(inviter.getErrorText(address));
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
join: new Command({
|
||||
|
@ -314,7 +439,19 @@ export const CommandMap = {
|
|||
const params = args.split(' ');
|
||||
if (params.length < 1) return reject(this.getUsage());
|
||||
|
||||
const matrixToMatches = params[0].match(MATRIXTO_URL_PATTERN);
|
||||
let isPermalink = false;
|
||||
if (params[0].startsWith("http:") || params[0].startsWith("https:")) {
|
||||
// It's at least a URL - try and pull out a hostname to check against the
|
||||
// permalink handler
|
||||
const parsedUrl = new URL(params[0]);
|
||||
const hostname = parsedUrl.host || parsedUrl.hostname; // takes first non-falsey value
|
||||
|
||||
// if we're using a Riot permalink handler, this will catch it before we get much further.
|
||||
// see below where we make assumptions about parsing the URL.
|
||||
if (isPermalinkHost(hostname)) {
|
||||
isPermalink = true;
|
||||
}
|
||||
}
|
||||
if (params[0][0] === '#') {
|
||||
let roomAlias = params[0];
|
||||
if (!roomAlias.includes(':')) {
|
||||
|
@ -336,34 +473,31 @@ export const CommandMap = {
|
|||
room_id: roomId,
|
||||
opts: {
|
||||
// These are passed down to the js-sdk's /join call
|
||||
server_name: viaServers,
|
||||
viaServers: viaServers,
|
||||
},
|
||||
via_servers: viaServers, // for the rejoin button
|
||||
auto_join: true,
|
||||
});
|
||||
return success();
|
||||
} else if (matrixToMatches) {
|
||||
let entity = matrixToMatches[1];
|
||||
let eventId = null;
|
||||
let viaServers = [];
|
||||
} else if (isPermalink) {
|
||||
const permalinkParts = parsePermalink(params[0]);
|
||||
|
||||
if (entity[0] !== '!' && entity[0] !== '#') return reject(this.getUsage());
|
||||
|
||||
if (entity.indexOf('?') !== -1) {
|
||||
const parts = entity.split('?');
|
||||
entity = parts[0];
|
||||
|
||||
const parsed = querystring.parse(parts[1]);
|
||||
viaServers = parsed["via"];
|
||||
if (typeof viaServers === 'string') viaServers = [viaServers];
|
||||
// This check technically isn't needed because we already did our
|
||||
// safety checks up above. However, for good measure, let's be sure.
|
||||
if (!permalinkParts) {
|
||||
return reject(this.getUsage());
|
||||
}
|
||||
|
||||
// We quietly support event ID permalinks too
|
||||
if (entity.indexOf('/$') !== -1) {
|
||||
const parts = entity.split("/$");
|
||||
entity = parts[0];
|
||||
eventId = `$${parts[1]}`;
|
||||
// If for some reason someone wanted to join a group or user, we should
|
||||
// stop them now.
|
||||
if (!permalinkParts.roomIdOrAlias) {
|
||||
return reject(this.getUsage());
|
||||
}
|
||||
|
||||
const entity = permalinkParts.roomIdOrAlias;
|
||||
const viaServers = permalinkParts.viaServers;
|
||||
const eventId = permalinkParts.eventId;
|
||||
|
||||
const dispatch = {
|
||||
action: 'view_room',
|
||||
auto_join: true,
|
||||
|
@ -378,10 +512,14 @@ export const CommandMap = {
|
|||
}
|
||||
|
||||
if (viaServers) {
|
||||
// For the join
|
||||
dispatch["opts"] = {
|
||||
// These are passed down to the js-sdk's /join call
|
||||
server_name: viaServers,
|
||||
viaServers: viaServers,
|
||||
};
|
||||
|
||||
// For if the join fails (rejoin button)
|
||||
dispatch['via_servers'] = viaServers;
|
||||
}
|
||||
|
||||
dis.dispatch(dispatch);
|
||||
|
@ -390,6 +528,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
part: new Command({
|
||||
|
@ -437,6 +576,7 @@ export const CommandMap = {
|
|||
}),
|
||||
);
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
kick: new Command({
|
||||
|
@ -452,6 +592,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
// Ban a user from the room with an optional reason
|
||||
|
@ -468,13 +609,14 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
// Unban a user from ythe room
|
||||
unban: new Command({
|
||||
name: 'unban',
|
||||
args: '<user-id>',
|
||||
description: _td('Unbans user with given id'),
|
||||
description: _td('Unbans user with given ID'),
|
||||
runFn: function(roomId, args) {
|
||||
if (args) {
|
||||
const matches = args.match(/^(\S+)$/);
|
||||
|
@ -485,6 +627,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
ignore: new Command({
|
||||
|
@ -515,6 +658,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
unignore: new Command({
|
||||
|
@ -546,6 +690,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
|
||||
// Define the power level of a user
|
||||
|
@ -574,6 +719,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
// Reset the power level of a user
|
||||
|
@ -595,6 +741,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
devtools: new Command({
|
||||
|
@ -605,6 +752,7 @@ export const CommandMap = {
|
|||
Modal.createDialog(DevtoolsDialog, {roomId});
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
}),
|
||||
|
||||
addwidget: new Command({
|
||||
|
@ -625,6 +773,7 @@ export const CommandMap = {
|
|||
return reject(_t("You cannot modify widgets in this room."));
|
||||
}
|
||||
},
|
||||
category: CommandCategories.admin,
|
||||
}),
|
||||
|
||||
// Verify a user, device, and pubkey tuple
|
||||
|
@ -694,6 +843,7 @@ export const CommandMap = {
|
|||
}
|
||||
return reject(this.getUsage());
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
}),
|
||||
|
||||
// Command definitions for autocompletion ONLY:
|
||||
|
@ -703,6 +853,7 @@ export const CommandMap = {
|
|||
name: 'me',
|
||||
args: '<message>',
|
||||
description: _td('Displays action'),
|
||||
category: CommandCategories.messages,
|
||||
hideCompletionAfterSpace: true,
|
||||
}),
|
||||
|
||||
|
@ -717,6 +868,41 @@ export const CommandMap = {
|
|||
}
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
}),
|
||||
|
||||
rainbow: new Command({
|
||||
name: "rainbow",
|
||||
description: _td("Sends the given message coloured as a rainbow"),
|
||||
args: '<message>',
|
||||
runFn: function(roomId, args) {
|
||||
if (!args) return reject(this.getUserId());
|
||||
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args)));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
||||
rainbowme: new Command({
|
||||
name: "rainbowme",
|
||||
description: _td("Sends the given emote coloured as a rainbow"),
|
||||
args: '<message>',
|
||||
runFn: function(roomId, args) {
|
||||
if (!args) return reject(this.getUserId());
|
||||
return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args)));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
||||
help: new Command({
|
||||
name: "help",
|
||||
description: _td("Displays list of commands with usages and descriptions"),
|
||||
runFn: function() {
|
||||
const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog');
|
||||
|
||||
Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog);
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
}),
|
||||
};
|
||||
/* eslint-enable babel/no-invalid-this */
|
||||
|
@ -727,6 +913,7 @@ const aliases = {
|
|||
j: "join",
|
||||
newballsplease: "discardsession",
|
||||
goto: "join", // because it handles event permalinks magically
|
||||
roomnick: "myroomnick",
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Value } from 'slate';
|
||||
import {Value} from 'slate';
|
||||
|
||||
import _clamp from 'lodash/clamp';
|
||||
|
||||
|
@ -47,7 +47,7 @@ class HistoryItem {
|
|||
}
|
||||
}
|
||||
|
||||
export default class ComposerHistoryManager {
|
||||
export default class SlateComposerHistoryManager {
|
||||
history: Array<HistoryItem> = [];
|
||||
prefix: string;
|
||||
lastIndex: number = 0; // used for indexing the storage
|
181
src/Terms.js
Normal file
181
src/Terms.js
Normal file
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import sdk from './';
|
||||
import Modal from './Modal';
|
||||
|
||||
export class TermsNotSignedError extends Error {}
|
||||
|
||||
/**
|
||||
* Class representing a service that may have terms & conditions that
|
||||
* require agreement from the user before the user can use that service.
|
||||
*/
|
||||
export class Service {
|
||||
/**
|
||||
* @param {MatrixClient.SERVICE_TYPES} serviceType The type of service
|
||||
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
|
||||
* @param {string} accessToken The user's access token for the service
|
||||
*/
|
||||
constructor(serviceType, baseUrl, accessToken) {
|
||||
this.serviceType = serviceType;
|
||||
this.baseUrl = baseUrl;
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a flow where the user is presented with terms & conditions for some services
|
||||
*
|
||||
* @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken'
|
||||
* @param {function} interactionCallback Function called with:
|
||||
* * an array of { service: {Service}, policies: {terms response from API} }
|
||||
* * an array of URLs the user has already agreed to
|
||||
* Must return a Promise which resolves with a list of URLs of documents agreed to
|
||||
* @returns {Promise} resolves when the user agreed to all necessary terms or rejects
|
||||
* if they cancel.
|
||||
*/
|
||||
export async function startTermsFlow(
|
||||
services,
|
||||
interactionCallback = dialogTermsInteractionCallback,
|
||||
) {
|
||||
const termsPromises = services.map(
|
||||
(s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl),
|
||||
);
|
||||
|
||||
/*
|
||||
* a /terms response looks like:
|
||||
* {
|
||||
* "policies": {
|
||||
* "terms_of_service": {
|
||||
* "version": "2.0",
|
||||
* "en": {
|
||||
* "name": "Terms of Service",
|
||||
* "url": "https://example.org/somewhere/terms-2.0-en.html"
|
||||
* },
|
||||
* "fr": {
|
||||
* "name": "Conditions d'utilisation",
|
||||
* "url": "https://example.org/somewhere/terms-2.0-fr.html"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
const terms = await Promise.all(termsPromises);
|
||||
const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; });
|
||||
|
||||
// fetch the set of agreed policy URLs from account data
|
||||
const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms');
|
||||
let agreedUrlSet;
|
||||
if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) {
|
||||
agreedUrlSet = new Set();
|
||||
} else {
|
||||
agreedUrlSet = new Set(currentAcceptedTerms.getContent().accepted);
|
||||
}
|
||||
|
||||
// remove any policies the user has already agreed to and any services where
|
||||
// they've already agreed to all the policies
|
||||
// NB. it could be nicer to show the user stuff they've already agreed to,
|
||||
// but then they'd assume they can un-check the boxes to un-agree to a policy,
|
||||
// but that is not a thing the API supports, so probably best to just show
|
||||
// things they've not agreed to yet.
|
||||
const unagreedPoliciesAndServicePairs = [];
|
||||
for (const {service, policies} of policiesAndServicePairs) {
|
||||
const unagreedPolicies = {};
|
||||
for (const [policyName, policy] of Object.entries(policies)) {
|
||||
let policyAgreed = false;
|
||||
for (const lang of Object.keys(policy)) {
|
||||
if (lang === 'version') continue;
|
||||
if (agreedUrlSet.has(policy[lang].url)) {
|
||||
policyAgreed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!policyAgreed) unagreedPolicies[policyName] = policy;
|
||||
}
|
||||
if (Object.keys(unagreedPolicies).length > 0) {
|
||||
unagreedPoliciesAndServicePairs.push({service, policies: unagreedPolicies});
|
||||
}
|
||||
}
|
||||
|
||||
// if there's anything left to agree to, prompt the user
|
||||
const numAcceptedBeforeAgreement = agreedUrlSet.size;
|
||||
if (unagreedPoliciesAndServicePairs.length > 0) {
|
||||
const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, [...agreedUrlSet]);
|
||||
console.log("User has agreed to URLs", newlyAgreedUrls);
|
||||
// Merge with previously agreed URLs
|
||||
newlyAgreedUrls.forEach(url => agreedUrlSet.add(url));
|
||||
} else {
|
||||
console.log("User has already agreed to all required policies");
|
||||
}
|
||||
|
||||
// We only ever add to the set of URLs, so if anything has changed then we'd see a different length
|
||||
if (agreedUrlSet.size !== numAcceptedBeforeAgreement) {
|
||||
const newAcceptedTerms = {accepted: Array.from(agreedUrlSet)};
|
||||
await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms);
|
||||
}
|
||||
|
||||
const agreePromises = policiesAndServicePairs.map((policiesAndService) => {
|
||||
// filter the agreed URL list for ones that are actually for this service
|
||||
// (one URL may be used for multiple services)
|
||||
// Not a particularly efficient loop but probably fine given the numbers involved
|
||||
const urlsForService = Array.from(agreedUrlSet).filter((url) => {
|
||||
for (const policy of Object.values(policiesAndService.policies)) {
|
||||
for (const lang of Object.keys(policy)) {
|
||||
if (lang === 'version') continue;
|
||||
if (policy[lang].url === url) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (urlsForService.length === 0) return Promise.resolve();
|
||||
|
||||
return MatrixClientPeg.get().agreeToTerms(
|
||||
policiesAndService.service.serviceType,
|
||||
policiesAndService.service.baseUrl,
|
||||
policiesAndService.service.accessToken,
|
||||
urlsForService,
|
||||
);
|
||||
});
|
||||
return Promise.all(agreePromises);
|
||||
}
|
||||
|
||||
export function dialogTermsInteractionCallback(
|
||||
policiesAndServicePairs,
|
||||
agreedUrls,
|
||||
extraClassNames,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("Terms that need agreement", policiesAndServicePairs);
|
||||
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");
|
||||
|
||||
Modal.createTrackedDialog('Terms of Service', '', TermsDialog, {
|
||||
policiesAndServicePairs,
|
||||
agreedUrls,
|
||||
onFinished: (done, agreedUrls) => {
|
||||
if (!done) {
|
||||
reject(new TermsNotSignedError());
|
||||
return;
|
||||
}
|
||||
resolve(agreedUrls);
|
||||
},
|
||||
}, classNames("mx_TermsDialog", extraClassNames));
|
||||
});
|
||||
}
|
|
@ -18,6 +18,7 @@ import CallHandler from './CallHandler';
|
|||
import { _t } from './languageHandler';
|
||||
import * as Roles from './Roles';
|
||||
import {isValid3pidInvite} from "./RoomInvite";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
|
||||
function textForMemberEvent(ev) {
|
||||
// XXX: SYJS-16 "sender is sometimes null for join messages"
|
||||
|
@ -74,9 +75,11 @@ function textForMemberEvent(ev) {
|
|||
return _t('%(senderName)s changed their profile picture.', {senderName});
|
||||
} else if (!prevContent.avatar_url && content.avatar_url) {
|
||||
return _t('%(senderName)s set a profile picture.', {senderName});
|
||||
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
|
||||
// This is a null rejoin, it will only be visible if the Labs option is enabled
|
||||
return _t("%(senderName)s made no change.", {senderName});
|
||||
} else {
|
||||
// suppress null rejoins
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
|
||||
|
@ -97,15 +100,14 @@ function textForMemberEvent(ev) {
|
|||
}
|
||||
} else if (prevContent.membership === "ban") {
|
||||
return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName});
|
||||
} else if (prevContent.membership === "join") {
|
||||
return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason;
|
||||
} else if (prevContent.membership === "invite") {
|
||||
return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', {
|
||||
senderName,
|
||||
targetName,
|
||||
}) + ' ' + reason;
|
||||
} else {
|
||||
return _t('%(targetName)s left the room.', {targetName});
|
||||
// sender is not target and made the target leave, if not from invite/ban then this is a kick
|
||||
return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -414,7 +416,8 @@ function textForEncryptionEvent(event) {
|
|||
// Currently will only display a change if a user's power level is changed
|
||||
function textForPowerEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
if (!event.getPrevContent() || !event.getPrevContent().users) {
|
||||
if (!event.getPrevContent() || !event.getPrevContent().users ||
|
||||
!event.getContent() || !event.getContent().users) {
|
||||
return '';
|
||||
}
|
||||
const userDefault = event.getContent().users_default || 0;
|
||||
|
|
|
@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import Promise from "bluebird";
|
||||
|
||||
// const OUTBOUND_API_NAME = 'toWidget';
|
||||
|
||||
// Initiate requests using the "toWidget" postMessage API and handle responses
|
||||
|
|
|
@ -34,6 +34,8 @@ module.exports = {
|
|||
return false;
|
||||
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') {
|
||||
return false;
|
||||
}
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
return EventTile.haveTileForEvent(ev);
|
||||
|
@ -70,20 +72,20 @@ module.exports = {
|
|||
const ev = room.timeline[i];
|
||||
|
||||
if (ev.getId() == readUpToId) {
|
||||
// If we've read up to this event, there's nothing more recents
|
||||
// If we've read up to this event, there's nothing more recent
|
||||
// that counts and we can stop looking because the user's read
|
||||
// this and everything before.
|
||||
return false;
|
||||
} else if (!shouldHideEvent(ev) && this.eventTriggersUnreadCount(ev)) {
|
||||
// We've found a message that counts before we hit
|
||||
// the read marker, so this room is definitely unread.
|
||||
// the user's read receipt, so this room is definitely unread.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// If we got here, we didn't find a message that counted but didn't
|
||||
// find the read marker either, so we guess and say that the room
|
||||
// is unread on the theory that false positives are better than
|
||||
// false negatives here.
|
||||
// If we got here, we didn't find a message that counted but didn't find
|
||||
// the user's read receipt either, so we guess and say that the room is
|
||||
// unread on the theory that false positives are better than false
|
||||
// negatives here.
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -14,12 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import Promise from 'bluebird';
|
||||
const Matrix = require("matrix-js-sdk");
|
||||
const Room = Matrix.Room;
|
||||
const CallHandler = require('./CallHandler');
|
||||
import {createNewMatrixCall, Room} from "matrix-js-sdk";
|
||||
import CallHandler from './CallHandler';
|
||||
import MatrixClientPeg from "./MatrixClientPeg";
|
||||
|
||||
// FIXME: this is Riot (Vector) specific code, but will be removed shortly when
|
||||
// we switch over to jitsi entirely for video conferencing.
|
||||
|
@ -45,7 +42,7 @@ ConferenceCall.prototype.setup = function() {
|
|||
// return a call for *this* room to be placed. We also tack on
|
||||
// confUserId to speed up lookups (else we'd need to loop every room
|
||||
// looking for a 1:1 room with this conf user ID!)
|
||||
const call = Matrix.createNewMatrixCall(self.client, room.roomId);
|
||||
const call = createNewMatrixCall(self.client, room.roomId);
|
||||
call.confUserId = self.confUserId;
|
||||
call.groupRoomId = self.groupRoomId;
|
||||
return call;
|
||||
|
@ -84,7 +81,7 @@ ConferenceCall.prototype._getConferenceUserRoom = function() {
|
|||
preset: "private_chat",
|
||||
invite: [this.confUserId],
|
||||
}).then(function(res) {
|
||||
return new Room(res.room_id, null, client.getUserId());
|
||||
return new Room(res.room_id, null, MatrixClientPeg.get().getUserId());
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const React = require('react');
|
||||
const ReactDom = require('react-dom');
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
const Velocity = require('velocity-animate');
|
||||
|
||||
/**
|
||||
|
@ -10,7 +11,7 @@ const Velocity = require('velocity-animate');
|
|||
* from DOM order. This makes it a lot simpler and lighter: if you need fully
|
||||
* automatic positional animation, look at react-shuffle or similar libraries.
|
||||
*/
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'Velociraptor',
|
||||
|
||||
propTypes: {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
const MatrixClientPeg = require("./MatrixClientPeg");
|
||||
import MatrixClientPeg from "./MatrixClientPeg";
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
module.exports = {
|
||||
|
@ -33,6 +33,9 @@ module.exports = {
|
|||
/**
|
||||
* Given a Room object and, optionally, a list of userID strings
|
||||
* to exclude, return a list of user objects who are typing.
|
||||
* @param {Room} room: room object to get users from.
|
||||
* @param {string[]} exclude: list of user mxids to exclude.
|
||||
* @returns {string[]} list of user objects who are typing.
|
||||
*/
|
||||
usersTyping: function(room, exclude) {
|
||||
const whoIsTyping = [];
|
||||
|
@ -46,7 +49,7 @@ module.exports = {
|
|||
const userId = memberKeys[i];
|
||||
|
||||
if (room.currentState.members[userId].typing) {
|
||||
if (exclude.indexOf(userId) == -1) {
|
||||
if (exclude.indexOf(userId) === -1) {
|
||||
whoIsTyping.push(room.currentState.members[userId]);
|
||||
}
|
||||
}
|
||||
|
@ -60,16 +63,19 @@ module.exports = {
|
|||
if (whoIsTyping.length > limit) {
|
||||
othersCount = whoIsTyping.length - limit + 1;
|
||||
}
|
||||
if (whoIsTyping.length == 0) {
|
||||
if (whoIsTyping.length === 0) {
|
||||
return '';
|
||||
} else if (whoIsTyping.length == 1) {
|
||||
} else if (whoIsTyping.length === 1) {
|
||||
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
|
||||
}
|
||||
const names = whoIsTyping.map(function(m) {
|
||||
return m.name;
|
||||
});
|
||||
if (othersCount>=1) {
|
||||
return _t('%(names)s and %(count)s others are typing …', {names: names.slice(0, limit - 1).join(', '), count: othersCount});
|
||||
return _t('%(names)s and %(count)s others are typing …', {
|
||||
names: names.slice(0, limit - 1).join(', '),
|
||||
count: othersCount,
|
||||
});
|
||||
} else {
|
||||
const lastPerson = names.pop();
|
||||
return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson});
|
||||
|
|
|
@ -15,12 +15,13 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
const React = require("react");
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
const sdk = require('../../../index');
|
||||
const MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'EncryptedEventDialog',
|
||||
|
||||
propTypes: {
|
||||
|
|
|
@ -17,20 +17,21 @@ limitations under the License.
|
|||
import FileSaver from 'file-saver';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
import * as Matrix from 'matrix-js-sdk';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
|
||||
import sdk from '../../../index';
|
||||
|
||||
const PHASE_EDIT = 1;
|
||||
const PHASE_EXPORTING = 2;
|
||||
|
||||
export default React.createClass({
|
||||
export default createReactClass({
|
||||
displayName: 'ExportE2eKeysDialog',
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
|
|
|
@ -16,8 +16,9 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import * as Matrix from 'matrix-js-sdk';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
|
||||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
@ -37,11 +38,11 @@ function readFileAsArrayBuffer(file) {
|
|||
const PHASE_EDIT = 1;
|
||||
const PHASE_IMPORTING = 2;
|
||||
|
||||
export default React.createClass({
|
||||
export default createReactClass({
|
||||
displayName: 'ImportE2eKeysDialog',
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import sdk from '../../../../index';
|
||||
import MatrixClientPeg from '../../../../MatrixClientPeg';
|
||||
import { scorePassword } from '../../../../utils/PasswordScorer';
|
||||
|
@ -48,7 +49,7 @@ function selectText(target) {
|
|||
* Walks the user through the process of creating an e2e key backup
|
||||
* on the server.
|
||||
*/
|
||||
export default React.createClass({
|
||||
export default createReactClass({
|
||||
getInitialState: function() {
|
||||
return {
|
||||
phase: PHASE_PASSPHRASE,
|
||||
|
@ -445,7 +446,7 @@ export default React.createClass({
|
|||
<p>{_t(
|
||||
"Your keys are being backed up (the first backup could take a few minutes).",
|
||||
)}</p>
|
||||
<DialogButtons primaryButton={_t('Okay')}
|
||||
<DialogButtons primaryButton={_t('OK')}
|
||||
onPrimaryButtonClick={this._onDone}
|
||||
hasCancel={false}
|
||||
/>
|
||||
|
|
|
@ -26,7 +26,7 @@ import RoomProvider from './RoomProvider';
|
|||
import UserProvider from './UserProvider';
|
||||
import EmojiProvider from './EmojiProvider';
|
||||
import NotifProvider from './NotifProvider';
|
||||
import Promise from 'bluebird';
|
||||
import {timeout} from "../utils/promise";
|
||||
|
||||
export type SelectionRange = {
|
||||
beginning: boolean, // whether the selection is in the first block of the editor or not
|
||||
|
@ -77,23 +77,16 @@ export default class Autocompleter {
|
|||
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(
|
||||
// Array of inspections of promises that might timeout. Instead of allowing a
|
||||
// single timeout to reject the Promise.all, reflect each one and once they've all
|
||||
// settled, filter for the fulfilled ones
|
||||
this.providers.map(provider =>
|
||||
provider
|
||||
.getCompletions(query, selection, force)
|
||||
.timeout(PROVIDER_COMPLETION_TIMEOUT)
|
||||
.reflect(),
|
||||
),
|
||||
);
|
||||
const completionsList = await Promise.all(this.providers.map(provider => {
|
||||
return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT);
|
||||
}));
|
||||
|
||||
// map then filter to maintain the index for the map-operation, for this.providers to line up
|
||||
return completionsList.map((completions, i) => {
|
||||
if (!completions || !completions.length) return;
|
||||
|
||||
return completionsList.filter(
|
||||
(inspection) => inspection.isFulfilled(),
|
||||
).map((completionsState, i) => {
|
||||
return {
|
||||
completions: completionsState.value(),
|
||||
completions,
|
||||
provider: this.providers[i],
|
||||
|
||||
/* the currently matched "command" the completer tried to complete
|
||||
|
@ -102,6 +95,6 @@ export default class Autocompleter {
|
|||
*/
|
||||
command: this.providers[i].getCurrentCommand(query, selection, force),
|
||||
};
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,6 +64,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
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}
|
||||
|
@ -77,8 +78,10 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_block">
|
||||
{ completions }
|
||||
</div>;
|
||||
return (
|
||||
<div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import QueryMatcher from './QueryMatcher';
|
|||
import {PillCompletion} from './Components';
|
||||
import sdk from '../index';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import {makeGroupPermalink} from "../matrix-to";
|
||||
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
|
||||
import type {Completion, SelectionRange} from "./Autocompleter";
|
||||
import FlairStore from "../stores/FlairStore";
|
||||
|
||||
|
@ -84,6 +84,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
]).map(({avatarUrl, groupId, name}) => ({
|
||||
completion: groupId,
|
||||
suffix: ' ',
|
||||
type: "community",
|
||||
href: makeGroupPermalink(groupId),
|
||||
component: (
|
||||
<PillCompletion initialComponent={
|
||||
|
@ -104,8 +105,14 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
|
||||
{ completions }
|
||||
</div>;
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
aria-label={_t("Community Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ export class TextualCompletion extends React.Component {
|
|||
...restProps
|
||||
} = this.props;
|
||||
return (
|
||||
<div className={classNames('mx_Autocomplete_Completion_block', className)} {...restProps}>
|
||||
<div className={classNames('mx_Autocomplete_Completion_block', className)} role="option" {...restProps}>
|
||||
<span className="mx_Autocomplete_Completion_title">{ title }</span>
|
||||
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
|
||||
<span className="mx_Autocomplete_Completion_description">{ description }</span>
|
||||
|
@ -60,7 +60,7 @@ export class PillCompletion extends React.Component {
|
|||
...restProps
|
||||
} = this.props;
|
||||
return (
|
||||
<div className={classNames('mx_Autocomplete_Completion_pill', className)} {...restProps}>
|
||||
<div className={classNames('mx_Autocomplete_Completion_pill', className)} role="option" {...restProps}>
|
||||
{ initialComponent }
|
||||
<span className="mx_Autocomplete_Completion_title">{ title }</span>
|
||||
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
|
||||
|
|
|
@ -97,8 +97,14 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_block">
|
||||
{ completions }
|
||||
</div>;
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
aria-label={_t("DuckDuckGo Results")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,47 +19,31 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import {shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
|
||||
import QueryMatcher from './QueryMatcher';
|
||||
import sdk from '../index';
|
||||
import {PillCompletion} from './Components';
|
||||
import type {Completion, SelectionRange} from './Autocompleter';
|
||||
import _uniq from 'lodash/uniq';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { shortcodeToUnicode } from '../HtmlUtils';
|
||||
|
||||
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
|
||||
import EmojiData from '../stripped-emoji.json';
|
||||
|
||||
const LIMIT = 20;
|
||||
const CATEGORY_ORDER = [
|
||||
'people',
|
||||
'food',
|
||||
'objects',
|
||||
'activity',
|
||||
'nature',
|
||||
'travel',
|
||||
'flags',
|
||||
'regional',
|
||||
'symbols',
|
||||
'modifier',
|
||||
];
|
||||
|
||||
// Match for ":wink:" or ascii-style ";-)" provided by emojione
|
||||
// (^|\s|(emojiUnicode)) to make sure we're either at the start of the string or there's a
|
||||
// whitespace character or an emoji before the emoji. The reason for unicodeRegexp is
|
||||
// that we need to support inputting multiple emoji with no space between them.
|
||||
const EMOJI_REGEX = new RegExp('(?:^|\\s|' + unicodeRegexp + ')(' + asciiRegexp + '|:[+-\\w]*:?)$', 'g');
|
||||
|
||||
// We also need to match the non-zero-length prefixes to remove them from the final match,
|
||||
// and update the range so that we don't replace the whitespace or the previous emoji.
|
||||
const MATCH_PREFIX_REGEX = new RegExp('(\\s|' + unicodeRegexp + ')');
|
||||
// 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 = Object.keys(EmojiData).map((key) => EmojiData[key]).sort(
|
||||
(a, b) => {
|
||||
if (a.category === b.category) {
|
||||
return a.emoji_order - b.emoji_order;
|
||||
}
|
||||
return CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
|
||||
return a.category - b.category;
|
||||
},
|
||||
).map((a, index) => {
|
||||
return {
|
||||
|
@ -101,26 +85,20 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
return []; // don't give any suggestions if the user doesn't want them
|
||||
}
|
||||
|
||||
const EmojiText = sdk.getComponent('views.elements.EmojiText');
|
||||
|
||||
let completions = [];
|
||||
const {command, range} = this.getCurrentCommand(query, selection);
|
||||
if (command) {
|
||||
let matchedString = command[0];
|
||||
|
||||
// Remove prefix of any length (single whitespace or unicode emoji)
|
||||
const prefixMatch = MATCH_PREFIX_REGEX.exec(matchedString);
|
||||
if (prefixMatch) {
|
||||
matchedString = matchedString.slice(prefixMatch[0].length);
|
||||
range.start += prefixMatch[0].length;
|
||||
}
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString);
|
||||
|
||||
// Do second match with shouldMatchWordsOnly in order to match against 'name'
|
||||
completions = completions.concat(this.nameMatcher.match(matchedString));
|
||||
|
||||
const sorters = [];
|
||||
// First, sort by score (Infinity if matchedString not in shortname)
|
||||
// make sure that emoticons come first
|
||||
sorters.push((c) => score(matchedString, c.aliases_ascii));
|
||||
|
||||
// then sort by score (Infinity if matchedString not in shortname)
|
||||
sorters.push((c) => score(matchedString, c.shortname));
|
||||
// If the matchedString is not empty, sort by length of shortname. Example:
|
||||
// matchedString = ":bookmark"
|
||||
|
@ -133,12 +111,14 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
completions = _sortBy(_uniq(completions), sorters);
|
||||
|
||||
completions = completions.map((result) => {
|
||||
const {shortname} = result;
|
||||
const unicode = shortnameToUnicode(shortname);
|
||||
const { shortname } = result;
|
||||
const unicode = shortcodeToUnicode(shortname);
|
||||
return {
|
||||
completion: unicode,
|
||||
component: (
|
||||
<PillCompletion title={shortname} initialComponent={<EmojiText style={{maxWidth: '1em'}}>{ unicode }</EmojiText>} />
|
||||
<PillCompletion title={shortname} aria-label={unicode} initialComponent={
|
||||
<span style={{maxWidth: '1em'}}>{ unicode }</span>
|
||||
} />
|
||||
),
|
||||
range,
|
||||
};
|
||||
|
@ -152,8 +132,10 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_pill">
|
||||
{ completions }
|
||||
</div>;
|
||||
return (
|
||||
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||
return [{
|
||||
completion: '@room',
|
||||
completionId: '@room',
|
||||
type: "at-room",
|
||||
suffix: ' ',
|
||||
component: (
|
||||
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
|
||||
|
@ -57,8 +58,14 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
|
||||
{ completions }
|
||||
</div>;
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
aria-label={_t("Notification Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ import {PillCompletion} from './Components';
|
|||
import {getDisplayAliasForRoom} from '../Rooms';
|
||||
import sdk from '../index';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
import {makeRoomPermalink} from "../matrix-to";
|
||||
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
|
||||
import type {Completion, SelectionRange} from "./Autocompleter";
|
||||
|
||||
const ROOM_REGEX = /\B#\S*/g;
|
||||
|
@ -89,6 +89,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
return {
|
||||
completion: displayAlias,
|
||||
completionId: displayAlias,
|
||||
type: "room",
|
||||
suffix: ' ',
|
||||
href: makeRoomPermalink(displayAlias),
|
||||
component: (
|
||||
|
@ -108,8 +109,14 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
|
||||
{ completions }
|
||||
</div>;
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
aria-label={_t("Room Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ import _sortBy from 'lodash/sortBy';
|
|||
import MatrixClientPeg from '../MatrixClientPeg';
|
||||
|
||||
import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk';
|
||||
import {makeUserPermalink} from "../matrix-to";
|
||||
import {makeUserPermalink} from "../utils/permalinks/Permalinks";
|
||||
import type {Completion, SelectionRange} from "./Autocompleter";
|
||||
|
||||
const USER_REGEX = /\B@\S*/g;
|
||||
|
@ -114,6 +114,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
// relies on the length of the entity === length of the text in the decoration.
|
||||
completion: user.rawDisplayName,
|
||||
completionId: user.userId,
|
||||
type: "user",
|
||||
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
|
||||
href: makeUserPermalink(user.userId),
|
||||
component: (
|
||||
|
@ -163,9 +164,11 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_pill">
|
||||
{ completions }
|
||||
</div>;
|
||||
return (
|
||||
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
shouldForceComplete(): boolean {
|
||||
|
|
58
src/boundThreepids.js
Normal file
58
src/boundThreepids.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
|
||||
export async function getThreepidsWithBindStatus(client, filterMedium) {
|
||||
const userId = client.getUserId();
|
||||
|
||||
let { threepids } = await client.getThreePids();
|
||||
if (filterMedium) {
|
||||
threepids = threepids.filter((a) => a.medium === filterMedium);
|
||||
}
|
||||
|
||||
// Check bind status assuming we have an IS and terms are agreed
|
||||
if (threepids.length > 0 && !!client.getIdentityServerUrl()) {
|
||||
try {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const identityAccessToken = await authClient.getAccessToken({ check: false });
|
||||
|
||||
// Restructure for lookup query
|
||||
const query = threepids.map(({ medium, address }) => [medium, address]);
|
||||
const lookupResults = await client.bulkLookupThreePids(query, identityAccessToken);
|
||||
|
||||
// Record which are already bound
|
||||
for (const [medium, address, mxid] of lookupResults.threepids) {
|
||||
if (mxid !== userId) {
|
||||
continue;
|
||||
}
|
||||
if (filterMedium && medium !== filterMedium) {
|
||||
continue;
|
||||
}
|
||||
const threepid = threepids.find(e => e.medium === medium && e.address === address);
|
||||
if (!threepid) continue;
|
||||
threepid.bound = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore terms errors here and assume other flows handle this
|
||||
if (!(e.errcode === "M_TERMS_NOT_SIGNED")) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return threepids;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,15 +15,15 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const React = require('react');
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'CompatibilityPage',
|
||||
propTypes: {
|
||||
onAccept: React.PropTypes.func,
|
||||
onAccept: PropTypes.func,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,11 +16,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {focusCapturedRef} from "../../utils/Accessibility";
|
||||
import {KeyCode} from "../../Keyboard";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
|
@ -48,7 +50,6 @@ export default class ContextualMenu extends React.Component {
|
|||
menuWidth: PropTypes.number,
|
||||
menuHeight: PropTypes.number,
|
||||
chevronOffset: PropTypes.number,
|
||||
menuColour: PropTypes.string,
|
||||
chevronFace: PropTypes.string, // top, bottom, left, right or none
|
||||
// Function to be called on menu close
|
||||
onFinished: PropTypes.func,
|
||||
|
@ -67,7 +68,7 @@ export default class ContextualMenu extends React.Component {
|
|||
// on resize callback
|
||||
windowResize: PropTypes.func,
|
||||
// method to close menu
|
||||
closeMenu: PropTypes.func,
|
||||
closeMenu: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -84,6 +85,9 @@ export default class ContextualMenu extends React.Component {
|
|||
// We don't need to clean up when unmounting, so ignore
|
||||
if (!element) return;
|
||||
|
||||
// For screen readers to find the thing
|
||||
focusCapturedRef(element);
|
||||
|
||||
this.setState({
|
||||
contextMenuRect: element.getBoundingClientRect(),
|
||||
});
|
||||
|
@ -111,6 +115,14 @@ export default class ContextualMenu extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_onKeyDown = (ev) => {
|
||||
if (ev.keyCode === KeyCode.ESCAPE) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const position = {};
|
||||
let chevronFace = null;
|
||||
|
@ -157,25 +169,6 @@ export default class ContextualMenu extends React.Component {
|
|||
chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted);
|
||||
}
|
||||
|
||||
// To override the default chevron colour, if it's been set
|
||||
let chevronCSS = "";
|
||||
if (props.menuColour) {
|
||||
chevronCSS = `
|
||||
.mx_ContextualMenu_chevron_left:after {
|
||||
border-right-color: ${props.menuColour};
|
||||
}
|
||||
.mx_ContextualMenu_chevron_right:after {
|
||||
border-left-color: ${props.menuColour};
|
||||
}
|
||||
.mx_ContextualMenu_chevron_top:after {
|
||||
border-left-color: ${props.menuColour};
|
||||
}
|
||||
.mx_ContextualMenu_chevron_bottom:after {
|
||||
border-left-color: ${props.menuColour};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const chevron = hasChevron ?
|
||||
<div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} /> :
|
||||
undefined;
|
||||
|
@ -183,11 +176,14 @@ export default class ContextualMenu extends React.Component {
|
|||
|
||||
const menuClasses = classNames({
|
||||
'mx_ContextualMenu': true,
|
||||
'mx_ContextualMenu_noChevron': chevronFace === 'none',
|
||||
'mx_ContextualMenu_left': chevronFace === 'left',
|
||||
'mx_ContextualMenu_right': chevronFace === 'right',
|
||||
'mx_ContextualMenu_top': chevronFace === 'top',
|
||||
'mx_ContextualMenu_bottom': chevronFace === 'bottom',
|
||||
'mx_ContextualMenu_left': !hasChevron && position.left,
|
||||
'mx_ContextualMenu_right': !hasChevron && position.right,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
|
||||
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
|
||||
});
|
||||
|
||||
const menuStyle = {};
|
||||
|
@ -199,10 +195,6 @@ export default class ContextualMenu extends React.Component {
|
|||
menuStyle.height = props.menuHeight;
|
||||
}
|
||||
|
||||
if (props.menuColour) {
|
||||
menuStyle["backgroundColor"] = props.menuColour;
|
||||
}
|
||||
|
||||
if (!isNaN(Number(props.menuPaddingTop))) {
|
||||
menuStyle["paddingTop"] = props.menuPaddingTop;
|
||||
}
|
||||
|
@ -226,14 +218,13 @@ export default class ContextualMenu extends React.Component {
|
|||
|
||||
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
|
||||
// property set here so you can't close the menu from a button click!
|
||||
return <div className={className} style={{...position, ...wrapperStyle}}>
|
||||
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}>
|
||||
return <div className={className} style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
|
||||
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} tabIndex={0}>
|
||||
{ chevron }
|
||||
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
|
||||
</div>
|
||||
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
|
||||
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
|
||||
<style>{ chevronCSS }</style>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,8 @@ import request from 'browser-request';
|
|||
import { _t } from '../../languageHandler';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import classnames from 'classnames';
|
||||
|
||||
|
@ -44,6 +46,8 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._dispatcherRef = null;
|
||||
|
||||
this.state = {
|
||||
page: '',
|
||||
};
|
||||
|
@ -82,19 +86,31 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
this.setState({ page: body });
|
||||
},
|
||||
);
|
||||
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
// HACK: Workaround for the context's MatrixClient not being set up at render time.
|
||||
if (payload.action === 'client_started') {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const client = this.context.matrixClient;
|
||||
// HACK: Workaround for the context's MatrixClient not updating.
|
||||
const client = this.context.matrixClient || MatrixClientPeg.get();
|
||||
const isGuest = client ? client.isGuest() : true;
|
||||
const className = this.props.className;
|
||||
const classes = classnames({
|
||||
[className]: true,
|
||||
[`${className}_guest`]: isGuest,
|
||||
[`${className}_loggedIn`]: !!client,
|
||||
});
|
||||
|
||||
const content = <div className={`${className}_body`}
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
|
@ -25,7 +26,7 @@ import { _t } from '../../languageHandler';
|
|||
/*
|
||||
* Component which shows the filtered file using a TimelinePanel
|
||||
*/
|
||||
const FilePanel = React.createClass({
|
||||
const FilePanel = createReactClass({
|
||||
displayName: 'FilePanel',
|
||||
|
||||
propTypes: {
|
||||
|
@ -38,23 +39,10 @@ const FilePanel = React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
componentDidMount: function() {
|
||||
this.updateTimelineSet(this.props.roomId);
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(nextProps) {
|
||||
if (nextProps.roomId !== this.props.roomId) {
|
||||
// otherwise we race between re-rendering the TimelinePanel and setting the new timelineSet.
|
||||
//
|
||||
// FIXME: this race only happens because of the promise returned by getOrCreateFilter().
|
||||
// We should only need to create the containsUrl filter once per login session, so in practice
|
||||
// it shouldn't be being done here at all. Then we could just update the timelineSet directly
|
||||
// without resetting it first, and speed up room-change.
|
||||
this.setState({ timelineSet: null });
|
||||
this.updateTimelineSet(nextProps.roomId);
|
||||
}
|
||||
},
|
||||
|
||||
updateTimelineSet: function(roomId) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(roomId);
|
||||
|
@ -116,20 +104,21 @@ const FilePanel = React.createClass({
|
|||
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
|
||||
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
|
||||
return (
|
||||
<TimelinePanel key={"filepanel_" + this.props.roomId}
|
||||
className="mx_FilePanel"
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview = {false}
|
||||
tileShape="file_grid"
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={_t('There are no visible files in this room')}
|
||||
/>
|
||||
<div className="mx_FilePanel" role="tabpanel">
|
||||
<TimelinePanel key={"filepanel_" + this.props.roomId}
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview = {false}
|
||||
tileShape="file_grid"
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={_t('There are no visible files in this room')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="mx_FilePanel">
|
||||
<div className="mx_FilePanel" role="tabpanel">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,22 +16,18 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../languageHandler";
|
||||
|
||||
export default class GenericErrorPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
message: PropTypes.string.isRequired,
|
||||
title: PropTypes.object.isRequired, // jsx for title
|
||||
message: PropTypes.object.isRequired, // jsx to display
|
||||
};
|
||||
|
||||
render() {
|
||||
return <div className='mx_GenericErrorPage'>
|
||||
<div className='mx_GenericErrorPage_box'>
|
||||
<h1>{_t("Error loading Riot")}</h1>
|
||||
<h1>{this.props.title}</h1>
|
||||
<p>{this.props.message}</p>
|
||||
<p>{_t(
|
||||
"If this is unexpected, please contact your system administrator " +
|
||||
"or technical support representative.",
|
||||
)}</p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd.
|
||||
Copyright 2017, 2018 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,8 +17,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import Promise from 'bluebird';
|
||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
|
@ -34,8 +35,9 @@ import classnames from 'classnames';
|
|||
import GroupStore from '../../stores/GroupStore';
|
||||
import FlairStore from '../../stores/FlairStore';
|
||||
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to";
|
||||
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
|
||||
import {Group} from "matrix-js-sdk";
|
||||
import {allSettled, sleep} from "../../utils/promise";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
`<h1>HTML for your community's page</h1>
|
||||
|
@ -66,7 +68,7 @@ const UserSummaryType = PropTypes.shape({
|
|||
}).isRequired,
|
||||
});
|
||||
|
||||
const CategoryRoomList = React.createClass({
|
||||
const CategoryRoomList = createReactClass({
|
||||
displayName: 'CategoryRoomList',
|
||||
|
||||
props: {
|
||||
|
@ -96,11 +98,10 @@ const CategoryRoomList = React.createClass({
|
|||
onFinished: (success, addrs) => {
|
||||
if (!success) return;
|
||||
const errorList = [];
|
||||
Promise.all(addrs.map((addr) => {
|
||||
allSettled(addrs.map((addr) => {
|
||||
return GroupStore
|
||||
.addRoomToGroupSummary(this.props.groupId, addr.address)
|
||||
.catch(() => { errorList.push(addr.address); })
|
||||
.reflect();
|
||||
.catch(() => { errorList.push(addr.address); });
|
||||
})).then(() => {
|
||||
if (errorList.length === 0) {
|
||||
return;
|
||||
|
@ -118,7 +119,7 @@ const CategoryRoomList = React.createClass({
|
|||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
@ -155,7 +156,7 @@ const CategoryRoomList = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
const FeaturedRoom = React.createClass({
|
||||
const FeaturedRoom = createReactClass({
|
||||
displayName: 'FeaturedRoom',
|
||||
|
||||
props: {
|
||||
|
@ -243,7 +244,7 @@ const FeaturedRoom = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
const RoleUserList = React.createClass({
|
||||
const RoleUserList = createReactClass({
|
||||
displayName: 'RoleUserList',
|
||||
|
||||
props: {
|
||||
|
@ -265,7 +266,7 @@ const RoleUserList = React.createClass({
|
|||
Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, {
|
||||
title: _t('Add users to the community summary'),
|
||||
description: _t("Who would you like to add to this summary?"),
|
||||
placeholder: _t("Name or matrix ID"),
|
||||
placeholder: _t("Name or Matrix ID"),
|
||||
button: _t("Add to summary"),
|
||||
validAddressTypes: ['mx-user-id'],
|
||||
groupId: this.props.groupId,
|
||||
|
@ -273,11 +274,10 @@ const RoleUserList = React.createClass({
|
|||
onFinished: (success, addrs) => {
|
||||
if (!success) return;
|
||||
const errorList = [];
|
||||
Promise.all(addrs.map((addr) => {
|
||||
allSettled(addrs.map((addr) => {
|
||||
return GroupStore
|
||||
.addUserToGroupSummary(addr.address)
|
||||
.catch(() => { errorList.push(addr.address); })
|
||||
.reflect();
|
||||
.catch(() => { errorList.push(addr.address); });
|
||||
})).then(() => {
|
||||
if (errorList.length === 0) {
|
||||
return;
|
||||
|
@ -295,7 +295,7 @@ const RoleUserList = React.createClass({
|
|||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
@ -326,7 +326,7 @@ const RoleUserList = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
const FeaturedUser = React.createClass({
|
||||
const FeaturedUser = createReactClass({
|
||||
displayName: 'FeaturedUser',
|
||||
|
||||
props: {
|
||||
|
@ -342,7 +342,6 @@ const FeaturedUser = React.createClass({
|
|||
dis.dispatch({
|
||||
action: 'view_start_chat_or_reuse',
|
||||
user_id: this.props.summaryInfo.user_id,
|
||||
go_home_on_cancel: false,
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -399,7 +398,7 @@ const FeaturedUser = React.createClass({
|
|||
const GROUP_JOINPOLICY_OPEN = "open";
|
||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||
|
||||
export default React.createClass({
|
||||
export default createReactClass({
|
||||
displayName: 'GroupView',
|
||||
|
||||
propTypes: {
|
||||
|
@ -408,10 +407,6 @@ export default React.createClass({
|
|||
groupIsNew: PropTypes.bool,
|
||||
},
|
||||
|
||||
childContextTypes: {
|
||||
groupStore: PropTypes.instanceOf(GroupStore),
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
summary: null,
|
||||
|
@ -641,7 +636,7 @@ export default React.createClass({
|
|||
title: _t('Error'),
|
||||
description: _t('Failed to upload image'),
|
||||
});
|
||||
}).done();
|
||||
});
|
||||
},
|
||||
|
||||
_onJoinableChange: function(ev) {
|
||||
|
@ -680,7 +675,7 @@ export default React.createClass({
|
|||
this.setState({
|
||||
avatarChanged: false,
|
||||
});
|
||||
}).done();
|
||||
});
|
||||
},
|
||||
|
||||
_saveGroup: async function() {
|
||||
|
@ -695,7 +690,7 @@ export default React.createClass({
|
|||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
await sleep(500);
|
||||
|
||||
GroupStore.acceptGroupInvite(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
|
@ -714,7 +709,7 @@ export default React.createClass({
|
|||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
await sleep(500);
|
||||
|
||||
GroupStore.leaveGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
|
@ -738,7 +733,7 @@ export default React.createClass({
|
|||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
await sleep(500);
|
||||
|
||||
GroupStore.joinGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
|
@ -790,7 +785,7 @@ export default React.createClass({
|
|||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
await sleep(500);
|
||||
|
||||
GroupStore.leaveGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
|
@ -861,9 +856,9 @@ export default React.createClass({
|
|||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
|
||||
const TooltipButton = sdk.getComponent('elements.TooltipButton');
|
||||
|
||||
const roomsHelpNode = this.state.editing ? <ToolTipButton helpText={
|
||||
const roomsHelpNode = this.state.editing ? <TooltipButton helpText={
|
||||
_t(
|
||||
'These rooms are displayed to community members on the community page. '+
|
||||
'Community members can join the rooms by clicking on them.',
|
||||
|
@ -1226,7 +1221,7 @@ export default React.createClass({
|
|||
blurToCancel={false}
|
||||
initialValue={this.state.profileForm.name}
|
||||
onValueChanged={this._onNameChange}
|
||||
tabIndex="1"
|
||||
tabIndex="0"
|
||||
dir="auto" />;
|
||||
|
||||
shortDescNode = <EditableText ref="descriptionEditor"
|
||||
|
@ -1236,7 +1231,7 @@ export default React.createClass({
|
|||
blurToCancel={false}
|
||||
initialValue={this.state.profileForm.short_description}
|
||||
onValueChanged={this._onShortDescChange}
|
||||
tabIndex="2"
|
||||
tabIndex="0"
|
||||
dir="auto" />;
|
||||
} else {
|
||||
const onGroupHeaderItemClick = this.state.isUserMember ? this._onEditClick : null;
|
||||
|
|
|
@ -19,7 +19,7 @@ import PropTypes from "prop-types";
|
|||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
export default class IndicatorScrollbar extends React.Component {
|
||||
static PropTypes = {
|
||||
static propTypes = {
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
|
||||
// by the parent element.
|
||||
|
@ -38,6 +38,8 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
this.checkOverflow = this.checkOverflow.bind(this);
|
||||
this._scrollElement = null;
|
||||
this._autoHideScrollbar = null;
|
||||
this._likelyTrackpadUser = null;
|
||||
this._checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
|
||||
|
||||
this.state = {
|
||||
leftIndicatorOffset: 0,
|
||||
|
@ -129,9 +131,39 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
// the harshness of the scroll behaviour. Should be a value between 0 and 1.
|
||||
const yRetention = 1.0;
|
||||
|
||||
if (Math.abs(e.deltaX) < xyThreshold) {
|
||||
// whenever we see horizontal scrolling, assume the user is on a trackpad
|
||||
// for at least the next 1 minute.
|
||||
const now = new Date().getTime();
|
||||
if (Math.abs(e.deltaX) > 0) {
|
||||
this._likelyTrackpadUser = true;
|
||||
this._checkAgainForTrackpad = now + (1 * 60 * 1000);
|
||||
} else {
|
||||
// if we haven't seen any horizontal scrolling for a while, assume
|
||||
// the user might have plugged in a mousewheel
|
||||
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) {
|
||||
this._likelyTrackpadUser = false;
|
||||
}
|
||||
}
|
||||
|
||||
// don't mess with the horizontal scroll for trackpad users
|
||||
// See https://github.com/vector-im/riot-web/issues/10005
|
||||
if (this._likelyTrackpadUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(e.deltaX) <= xyThreshold) { // we are vertically scrolling.
|
||||
// HACK: We increase the amount of scroll to counteract smooth scrolling browsers.
|
||||
// Smooth scrolling browsers (Firefox) use the relative area to determine the scroll
|
||||
// amount, which means the likely small area of content results in a small amount of
|
||||
// movement - not what people expect. We pick arbitrary values for when to apply more
|
||||
// scroll, and how much to apply. On Windows 10, Chrome scrolls 100 units whereas
|
||||
// Firefox scrolls just 3 due to smooth scrolling.
|
||||
|
||||
const additionalScroll = e.deltaY < 0 ? -50 : 50;
|
||||
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
this._scrollElement.scrollLeft += e.deltaY * yRetention;
|
||||
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
|
||||
this._scrollElement.scrollLeft += val * yRetention;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,11 +19,14 @@ import Matrix from 'matrix-js-sdk';
|
|||
const InteractiveAuth = Matrix.InteractiveAuth;
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents';
|
||||
|
||||
export default React.createClass({
|
||||
import sdk from '../../index';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'InteractiveAuth',
|
||||
|
||||
propTypes: {
|
||||
|
@ -60,7 +64,7 @@ export default React.createClass({
|
|||
inputs: PropTypes.object,
|
||||
|
||||
// As js-sdk interactive-auth
|
||||
makeRegistrationUrl: PropTypes.func,
|
||||
requestEmailToken: PropTypes.func,
|
||||
sessionId: PropTypes.string,
|
||||
clientSecret: PropTypes.string,
|
||||
emailSid: PropTypes.string,
|
||||
|
@ -90,12 +94,14 @@ export default React.createClass({
|
|||
this._authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
doRequest: this._requestCallback,
|
||||
busyChanged: this._onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this._authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this._requestEmailToken,
|
||||
});
|
||||
|
||||
this._authLogic.attemptAuth().then((result) => {
|
||||
|
@ -115,7 +121,7 @@ export default React.createClass({
|
|||
this.setState({
|
||||
errorText: msg,
|
||||
});
|
||||
}).done();
|
||||
});
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
|
@ -133,6 +139,19 @@ export default React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
_requestEmailToken: async function(...args) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
return await this.props.requestEmailToken(...args);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
tryContinue: function() {
|
||||
if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) {
|
||||
this.refs.stageComponent.tryContinue();
|
||||
|
@ -150,27 +169,26 @@ export default React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_requestCallback: function(auth, background) {
|
||||
const makeRequestPromise = this.props.makeRequest(auth);
|
||||
_requestCallback: function(auth) {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
},
|
||||
|
||||
// if it's a background request, just do it: we don't want
|
||||
// it to affect the state of our UI.
|
||||
if (background) return makeRequestPromise;
|
||||
|
||||
// otherwise, manage the state of the spinner and error messages
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
return makeRequestPromise.finally(() => {
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
_onBusyChanged: function(busy) {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_setFocus: function() {
|
||||
|
@ -185,7 +203,14 @@ export default React.createClass({
|
|||
|
||||
_renderCurrentStage: function() {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) return null;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
|
@ -202,7 +227,6 @@ export default React.createClass({
|
|||
stageState={this.state.stageState}
|
||||
fail={this._onAuthStageFailed}
|
||||
setEmailSid={this._setEmailSid}
|
||||
makeRegistrationUrl={this.props.makeRegistrationUrl}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,13 +15,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import { Key } from '../../Keyboard';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||
|
@ -30,7 +30,7 @@ import {_t} from "../../languageHandler";
|
|||
import Analytics from "../../Analytics";
|
||||
|
||||
|
||||
const LeftPanel = React.createClass({
|
||||
const LeftPanel = createReactClass({
|
||||
displayName: 'LeftPanel',
|
||||
|
||||
// NB. If you add props, don't forget to update
|
||||
|
@ -53,16 +53,19 @@ const LeftPanel = React.createClass({
|
|||
componentWillMount: function() {
|
||||
this.focusedElement = null;
|
||||
|
||||
this._settingWatchRef = SettingsStore.watchSetting(
|
||||
"feature_room_breadcrumbs", null, this._onBreadcrumbsChanged);
|
||||
this._breadcrumbsWatcherRef = SettingsStore.watchSetting(
|
||||
"breadcrumbs", null, this._onBreadcrumbsChanged);
|
||||
this._tagPanelWatcherRef = SettingsStore.watchSetting(
|
||||
"TagPanel.enableTagPanel", null, () => this.forceUpdate());
|
||||
|
||||
const useBreadcrumbs = SettingsStore.isFeatureEnabled("feature_room_breadcrumbs");
|
||||
const useBreadcrumbs = !!SettingsStore.getValue("breadcrumbs");
|
||||
Analytics.setBreadcrumbs(useBreadcrumbs);
|
||||
this.setState({breadcrumbs: useBreadcrumbs});
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
SettingsStore.unwatchSetting(this._settingWatchRef);
|
||||
SettingsStore.unwatchSetting(this._breadcrumbsWatcherRef);
|
||||
SettingsStore.unwatchSetting(this._tagPanelWatcherRef);
|
||||
},
|
||||
|
||||
shouldComponentUpdate: function(nextProps, nextState) {
|
||||
|
@ -82,6 +85,9 @@ const LeftPanel = React.createClass({
|
|||
if (this.state.searchFilter !== nextState.searchFilter) {
|
||||
return true;
|
||||
}
|
||||
if (this.state.searchExpanded !== nextState.searchExpanded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
@ -110,37 +116,38 @@ const LeftPanel = React.createClass({
|
|||
this.focusedElement = null;
|
||||
},
|
||||
|
||||
_onKeyDown: function(ev) {
|
||||
_onFilterKeyDown: function(ev) {
|
||||
if (!this.focusedElement) return;
|
||||
let handled = true;
|
||||
|
||||
switch (ev.keyCode) {
|
||||
case KeyCode.TAB:
|
||||
this._onMoveFocus(ev.shiftKey);
|
||||
break;
|
||||
case KeyCode.UP:
|
||||
this._onMoveFocus(true);
|
||||
break;
|
||||
case KeyCode.DOWN:
|
||||
this._onMoveFocus(false);
|
||||
break;
|
||||
case KeyCode.ENTER:
|
||||
this._onMoveFocus(false);
|
||||
if (this.focusedElement) {
|
||||
this.focusedElement.click();
|
||||
switch (ev.key) {
|
||||
// On enter of rooms filter select and activate first room if such one exists
|
||||
case Key.ENTER: {
|
||||
const firstRoom = ev.target.closest(".mx_LeftPanel").querySelector(".mx_RoomTile");
|
||||
if (firstRoom) {
|
||||
firstRoom.click();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_onMoveFocus: function(up) {
|
||||
_onKeyDown: function(ev) {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.TAB:
|
||||
this._onMoveFocus(ev, ev.shiftKey);
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this._onMoveFocus(ev, true, true);
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this._onMoveFocus(ev, false, true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_onMoveFocus: function(ev, up, trap) {
|
||||
let element = this.focusedElement;
|
||||
|
||||
// unclear why this isn't needed
|
||||
|
@ -174,28 +181,24 @@ const LeftPanel = React.createClass({
|
|||
|
||||
if (element) {
|
||||
classes = element.classList;
|
||||
if (classes.contains("mx_LeftPanel")) { // we hit the top
|
||||
element = up ? element.lastElementChild : element.firstElementChild;
|
||||
descending = true;
|
||||
}
|
||||
}
|
||||
} while (element && !(
|
||||
classes.contains("mx_RoomTile") ||
|
||||
classes.contains("mx_textinput_search")));
|
||||
classes.contains("mx_RoomSubList_label") ||
|
||||
classes.contains("mx_LeftPanel_filterRooms")));
|
||||
|
||||
if (element) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
element.focus();
|
||||
this.focusedElement = element;
|
||||
this.focusedDescending = descending;
|
||||
} else if (trap) {
|
||||
// if navigation is via up/down arrow-keys, trap in the widget so it doesn't send to composer
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
onHideClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'hide_left_panel',
|
||||
});
|
||||
},
|
||||
|
||||
onSearch: function(term) {
|
||||
this.setState({ searchFilter: term });
|
||||
},
|
||||
|
@ -204,12 +207,23 @@ const LeftPanel = React.createClass({
|
|||
if (source === "keyboard") {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
}
|
||||
this.setState({searchExpanded: false});
|
||||
},
|
||||
|
||||
collectRoomList: function(ref) {
|
||||
this._roomList = ref;
|
||||
},
|
||||
|
||||
_onSearchFocus: function() {
|
||||
this.setState({searchExpanded: true});
|
||||
},
|
||||
|
||||
_onSearchBlur: function(event) {
|
||||
if (event.target.value.length === 0) {
|
||||
this.setState({searchExpanded: false});
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const RoomList = sdk.getComponent('rooms.RoomList');
|
||||
const RoomBreadcrumbs = sdk.getComponent('rooms.RoomBreadcrumbs');
|
||||
|
@ -218,6 +232,7 @@ const LeftPanel = React.createClass({
|
|||
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
|
||||
const SearchBox = sdk.getComponent('structures.SearchBox');
|
||||
const CallPreview = sdk.getComponent('voip.CallPreview');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const tagPanelEnabled = SettingsStore.getValue("TagPanel.enableTagPanel");
|
||||
let tagPanelContainer;
|
||||
|
@ -241,11 +256,25 @@ const LeftPanel = React.createClass({
|
|||
},
|
||||
);
|
||||
|
||||
let exploreButton;
|
||||
if (!this.props.collapsed) {
|
||||
exploreButton = (
|
||||
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
|
||||
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const searchBox = (<SearchBox
|
||||
className="mx_LeftPanel_filterRooms"
|
||||
enableRoomSearchFocus={true}
|
||||
placeholder={ _t('Filter room names') }
|
||||
blurredPlaceholder={ _t('Filter') }
|
||||
placeholder={ _t('Filter rooms…') }
|
||||
onKeyDown={this._onFilterKeyDown}
|
||||
onSearch={ this.onSearch }
|
||||
onCleared={ this.onSearchCleared }
|
||||
onFocus={this._onSearchFocus}
|
||||
onBlur={this._onSearchBlur}
|
||||
collapsed={this.props.collapsed} />);
|
||||
|
||||
let breadcrumbs;
|
||||
|
@ -256,12 +285,18 @@ const LeftPanel = React.createClass({
|
|||
return (
|
||||
<div className={containerClasses}>
|
||||
{ tagPanelContainer }
|
||||
<aside className={"mx_LeftPanel dark-panel"} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
|
||||
<TopLeftMenuButton collapsed={ this.props.collapsed } />
|
||||
<aside className="mx_LeftPanel dark-panel">
|
||||
<TopLeftMenuButton collapsed={this.props.collapsed} />
|
||||
{ breadcrumbs }
|
||||
{ searchBox }
|
||||
<CallPreview ConferenceHandler={VectorConferenceHandler} />
|
||||
<div className="mx_LeftPanel_exploreAndFilterRow" onKeyDown={this._onKeyDown} onFocus={this._onFocus} onBlur={this._onBlur}>
|
||||
{ exploreButton }
|
||||
{ searchBox }
|
||||
</div>
|
||||
<RoomList
|
||||
onKeyDown={this._onKeyDown}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
ref={this.collectRoomList}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
|
|
|
@ -16,14 +16,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as Matrix from 'matrix-js-sdk';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
|
||||
import { Key, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import CallMediaHandler from '../../CallMediaHandler';
|
||||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import sessionStore from '../../stores/SessionStore';
|
||||
|
@ -41,6 +43,13 @@ import {Resizer, CollapseDistributor} from '../../resizer';
|
|||
// NB. this is just for server notices rather than pinned messages in general.
|
||||
const MAX_PINNED_NOTICES_PER_ROOM = 2;
|
||||
|
||||
function canElementReceiveInput(el) {
|
||||
return el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT" ||
|
||||
!!el.getAttribute("contenteditable");
|
||||
}
|
||||
|
||||
/**
|
||||
* This is what our MatrixChat shows when we are logged in. The precise view is
|
||||
* determined by the page_type property.
|
||||
|
@ -50,11 +59,11 @@ const MAX_PINNED_NOTICES_PER_ROOM = 2;
|
|||
*
|
||||
* Components mounted below us can access the matrix client via the react context.
|
||||
*/
|
||||
const LoggedInView = React.createClass({
|
||||
const LoggedInView = createReactClass({
|
||||
displayName: 'LoggedInView',
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
page_type: PropTypes.string.isRequired,
|
||||
onRoomCreated: PropTypes.func,
|
||||
|
||||
|
@ -70,7 +79,7 @@ const LoggedInView = React.createClass({
|
|||
},
|
||||
|
||||
childContextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient),
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient),
|
||||
authCache: PropTypes.object,
|
||||
},
|
||||
|
||||
|
@ -105,7 +114,7 @@ const LoggedInView = React.createClass({
|
|||
|
||||
CallMediaHandler.loadDevices();
|
||||
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
|
||||
this._sessionStore = sessionStore;
|
||||
this._sessionStoreToken = this._sessionStore.addListener(
|
||||
|
@ -118,6 +127,8 @@ const LoggedInView = React.createClass({
|
|||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
fixupColorFonts();
|
||||
},
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
|
@ -133,7 +144,7 @@ const LoggedInView = React.createClass({
|
|||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
document.removeEventListener('keydown', this._onNativeKeyDown, false);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
|
@ -269,6 +280,58 @@ const LoggedInView = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_onPaste: function(ev) {
|
||||
let canReceiveInput = false;
|
||||
let element = ev.target;
|
||||
// test for all parents because the target can be a child of a contenteditable element
|
||||
while (!canReceiveInput && element) {
|
||||
canReceiveInput = canElementReceiveInput(element);
|
||||
element = element.parentElement;
|
||||
}
|
||||
if (!canReceiveInput) {
|
||||
// refocusing during a paste event will make the
|
||||
// paste end up in the newly focused element,
|
||||
// so dispatch synchronously before paste happens
|
||||
dis.dispatch({action: 'focus_composer'}, true);
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
SOME HACKERY BELOW:
|
||||
React optimizes event handlers, by always attaching only 1 handler to the document for a given type.
|
||||
It then internally determines the order in which React event handlers should be called,
|
||||
emulating the capture and bubbling phases the DOM also has.
|
||||
|
||||
But, as the native handler for React is always attached on the document,
|
||||
it will always run last for bubbling (first for capturing) handlers,
|
||||
and thus React basically has its own event phases, and will always run
|
||||
after (before for capturing) any native other event handlers (as they tend to be attached last).
|
||||
|
||||
So ideally one wouldn't mix React and native event handlers to have bubbling working as expected,
|
||||
but we do need a native event handler here on the document,
|
||||
to get keydown events when there is no focused element (target=body).
|
||||
|
||||
We also do need bubbling here to give child components a chance to call `stopPropagation()`,
|
||||
for keydown events it can handle itself, and shouldn't be redirected to the composer.
|
||||
|
||||
So we listen with React on this component to get any events on focused elements, and get bubbling working as expected.
|
||||
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) {
|
||||
// events caught while bubbling up on the root element
|
||||
// of this component, so something must be focused.
|
||||
this._onKeyDown(ev);
|
||||
},
|
||||
|
||||
_onNativeKeyDown: function(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) {
|
||||
/*
|
||||
|
@ -287,34 +350,26 @@ const LoggedInView = React.createClass({
|
|||
|
||||
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;
|
||||
|
||||
switch (ev.keyCode) {
|
||||
case KeyCode.UP:
|
||||
case KeyCode.DOWN:
|
||||
if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
const action = ev.keyCode == KeyCode.UP ?
|
||||
'view_prev_room' : 'view_next_room';
|
||||
dis.dispatch({action: action});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyCode.PAGE_UP:
|
||||
case KeyCode.PAGE_DOWN:
|
||||
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
switch (ev.key) {
|
||||
case Key.PAGE_UP:
|
||||
case Key.PAGE_DOWN:
|
||||
if (!hasModifier) {
|
||||
this._onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyCode.HOME:
|
||||
case KeyCode.END:
|
||||
case Key.HOME:
|
||||
case Key.END:
|
||||
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||
this._onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
case KeyCode.KEY_K:
|
||||
case Key.K:
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'focus_room_filter',
|
||||
|
@ -322,11 +377,44 @@ const LoggedInView = React.createClass({
|
|||
handled = true;
|
||||
}
|
||||
break;
|
||||
case Key.BACKTICK:
|
||||
if (ev.key !== "`") break;
|
||||
|
||||
// Ideally this would be CTRL+P for "Profile", but that's
|
||||
// taken by the print dialog. CTRL+I for "Information"
|
||||
// was previously chosen but conflicted with italics in
|
||||
// composer, so CTRL+` it is
|
||||
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_top_left_menu',
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
} else if (!hasModifier) {
|
||||
const isClickShortcut = ev.target !== document.body &&
|
||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||
|
||||
// XXX: Remove after CIDER replaces Slate completely: https://github.com/vector-im/riot-web/issues/11036
|
||||
// If using Slate, consume the Backspace without first focusing as it causes an implosion
|
||||
if (ev.key === Key.BACKSPACE && !SettingsStore.getValue("useCiderComposer")) {
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
dis.dispatch({action: 'focus_composer'}, true);
|
||||
ev.stopPropagation();
|
||||
// we should *not* preventDefault() here as
|
||||
// that would prevent typing in the now-focussed composer
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -437,6 +525,7 @@ const LoggedInView = React.createClass({
|
|||
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
|
||||
const GroupView = sdk.getComponent('structures.GroupView');
|
||||
const MyGroups = sdk.getComponent('structures.MyGroups');
|
||||
const ToastContainer = sdk.getComponent('structures.ToastContainer');
|
||||
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
|
||||
const CookieBar = sdk.getComponent('globals.CookieBar');
|
||||
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
|
||||
|
@ -538,8 +627,9 @@ const LoggedInView = React.createClass({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
|
||||
<div onPaste={this._onPaste} onKeyDown={this._onReactKeyDown} className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
|
||||
{ topBar }
|
||||
<ToastContainer />
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
<div ref={this._setResizeContainerRef} className={bodyClasses}>
|
||||
<LeftPanel
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -79,9 +80,12 @@ export default class MainSplit extends React.Component {
|
|||
const wasPanelSet = this.props.panel && !prevProps.panel;
|
||||
const wasPanelCleared = !this.props.panel && prevProps.panel;
|
||||
|
||||
if (wasExpanded || wasPanelSet) {
|
||||
if (this.resizeContainer && (wasExpanded || wasPanelSet)) {
|
||||
// The resizer can only be created when **both** expanded and the panel is
|
||||
// set. Once both are true, the container ref will mount, which is required
|
||||
// for the resizer to work.
|
||||
this._createResizer();
|
||||
} else if (wasCollapsed || wasPanelCleared) {
|
||||
} else if (this.resizer && (wasCollapsed || wasPanelCleared)) {
|
||||
this.resizer.detach();
|
||||
this.resizer = null;
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -24,16 +25,18 @@ import {wantsDateSeparator} from '../../DateUtils';
|
|||
import sdk from '../../index';
|
||||
|
||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
|
||||
const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite';
|
||||
|
||||
/* (almost) stateless UI component which builds the event tiles in the room timeline.
|
||||
*/
|
||||
module.exports = React.createClass({
|
||||
displayName: 'MessagePanel',
|
||||
|
||||
propTypes: {
|
||||
export default class MessagePanel extends React.Component {
|
||||
static propTypes = {
|
||||
// true to give the component a 'display: none' style.
|
||||
hidden: PropTypes.bool,
|
||||
|
||||
|
@ -51,6 +54,10 @@ module.exports = React.createClass({
|
|||
// ID of an event to highlight. If undefined, no event will be highlighted.
|
||||
highlightedEventId: PropTypes.string,
|
||||
|
||||
// The room these events are all in together, if any.
|
||||
// (The notification panel won't have a room here, for example.)
|
||||
room: PropTypes.object,
|
||||
|
||||
// Should we show URL Previews
|
||||
showUrlPreview: PropTypes.bool,
|
||||
|
||||
|
@ -92,58 +99,111 @@ module.exports = React.createClass({
|
|||
|
||||
// show timestamps always
|
||||
alwaysShowTimestamps: PropTypes.bool,
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
// the event after which we put a visible unread marker on the last
|
||||
// render cycle; null if readMarkerVisible was false or the RM was
|
||||
// suppressed (eg because it was at the end of the timeline)
|
||||
this.currentReadMarkerEventId = null;
|
||||
// helper function to access relations for an event
|
||||
getRelationsForEvent: PropTypes.func,
|
||||
|
||||
// the event after which we are showing a disappearing read marker
|
||||
// animation
|
||||
this.currentGhostEventId = null;
|
||||
// whether to show reactions for an event
|
||||
showReactions: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
// previous positions the read marker has been in, so we can
|
||||
// display 'ghost' read markers that are animating away
|
||||
ghostReadMarkers: [],
|
||||
};
|
||||
|
||||
// opaque readreceipt info for each userId; used by ReadReceiptMarker
|
||||
// to manage its animations
|
||||
this._readReceiptMap = {};
|
||||
|
||||
// Remember the read marker ghost node so we can do the cleanup that
|
||||
// Velocity requires
|
||||
this._readMarkerGhostNode = null;
|
||||
// Track read receipts by event ID. For each _shown_ event ID, we store
|
||||
// the list of read receipts to display:
|
||||
// [
|
||||
// {
|
||||
// userId: string,
|
||||
// member: RoomMember,
|
||||
// ts: number,
|
||||
// },
|
||||
// ]
|
||||
// This is recomputed on each render. It's only stored on the component
|
||||
// for ease of passing the data around since it's computed in one pass
|
||||
// over all events.
|
||||
this._readReceiptsByEvent = {};
|
||||
|
||||
this._isMounted = true;
|
||||
},
|
||||
// Track read receipts by user ID. For each user ID we've ever shown a
|
||||
// a read receipt for, we store an object:
|
||||
// {
|
||||
// lastShownEventId: string,
|
||||
// receipt: {
|
||||
// userId: string,
|
||||
// member: RoomMember,
|
||||
// ts: number,
|
||||
// },
|
||||
// }
|
||||
// so that we can always keep receipts displayed by reverting back to
|
||||
// the last shown event for that user ID when needed. This may feel like
|
||||
// it duplicates the receipt storage in the room, but at this layer, we
|
||||
// are tracking _shown_ event IDs, which the JS SDK knows nothing about.
|
||||
// This is recomputed on each render, using the data from the previous
|
||||
// render as our fallback for any user IDs we can't match a receipt to a
|
||||
// displayed event in the current render cycle.
|
||||
this._readReceiptsByUserId = {};
|
||||
|
||||
// Cache hidden events setting on mount since Settings is expensive to
|
||||
// query, and we check this in a hot code path.
|
||||
this._showHiddenEventsInTimeline =
|
||||
SettingsStore.getValue("showHiddenEventsInTimeline");
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this._isMounted = false;
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.readMarkerVisible && this.props.readMarkerEventId !== prevProps.readMarkerEventId) {
|
||||
const ghostReadMarkers = this.state.ghostReadMarkers;
|
||||
ghostReadMarkers.push(prevProps.readMarkerEventId);
|
||||
this.setState({
|
||||
ghostReadMarkers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* get the DOM node representing the given event */
|
||||
getNodeForEventId: function(eventId) {
|
||||
getNodeForEventId(eventId) {
|
||||
if (!this.eventNodes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.eventNodes[eventId];
|
||||
},
|
||||
}
|
||||
|
||||
/* return true if the content is fully scrolled down right now; else false.
|
||||
*/
|
||||
isAtBottom: function() {
|
||||
isAtBottom() {
|
||||
return this.refs.scrollPanel
|
||||
&& this.refs.scrollPanel.isAtBottom();
|
||||
},
|
||||
}
|
||||
|
||||
/* get the current scroll state. See ScrollPanel.getScrollState for
|
||||
* details.
|
||||
*
|
||||
* returns null if we are not mounted.
|
||||
*/
|
||||
getScrollState: function() {
|
||||
getScrollState() {
|
||||
if (!this.refs.scrollPanel) { return null; }
|
||||
return this.refs.scrollPanel.getScrollState();
|
||||
},
|
||||
}
|
||||
|
||||
// returns one of:
|
||||
//
|
||||
|
@ -151,7 +211,7 @@ module.exports = React.createClass({
|
|||
// -1: read marker is above the window
|
||||
// 0: read marker is within the window
|
||||
// +1: read marker is below the window
|
||||
getReadMarkerPosition: function() {
|
||||
getReadMarkerPosition() {
|
||||
const readMarker = this.refs.readMarkerNode;
|
||||
const messageWrapper = this.refs.scrollPanel;
|
||||
|
||||
|
@ -171,45 +231,45 @@ module.exports = React.createClass({
|
|||
} else {
|
||||
return 1;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/* jump to the top of the content.
|
||||
*/
|
||||
scrollToTop: function() {
|
||||
scrollToTop() {
|
||||
if (this.refs.scrollPanel) {
|
||||
this.refs.scrollPanel.scrollToTop();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/* jump to the bottom of the content.
|
||||
*/
|
||||
scrollToBottom: function() {
|
||||
scrollToBottom() {
|
||||
if (this.refs.scrollPanel) {
|
||||
this.refs.scrollPanel.scrollToBottom();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Page up/down.
|
||||
*
|
||||
* @param {number} mult: -1 to page up, +1 to page down
|
||||
*/
|
||||
scrollRelative: function(mult) {
|
||||
scrollRelative(mult) {
|
||||
if (this.refs.scrollPanel) {
|
||||
this.refs.scrollPanel.scrollRelative(mult);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll up/down in response to a scroll key
|
||||
*
|
||||
* @param {KeyboardEvent} ev: the keyboard event to handle
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
handleScrollKey(ev) {
|
||||
if (this.refs.scrollPanel) {
|
||||
this.refs.scrollPanel.handleScrollKey(ev);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/* jump to the given event id.
|
||||
*
|
||||
|
@ -221,30 +281,41 @@ module.exports = React.createClass({
|
|||
* node (specifically, the bottom of it) will be positioned. If omitted, it
|
||||
* defaults to 0.
|
||||
*/
|
||||
scrollToEvent: function(eventId, pixelOffset, offsetBase) {
|
||||
scrollToEvent(eventId, pixelOffset, offsetBase) {
|
||||
if (this.refs.scrollPanel) {
|
||||
this.refs.scrollPanel.scrollToToken(eventId, pixelOffset, offsetBase);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
scrollToEventIfNeeded(eventId) {
|
||||
const node = this.eventNodes[eventId];
|
||||
if (node) {
|
||||
node.scrollIntoView({block: "nearest", behavior: "instant"});
|
||||
}
|
||||
}
|
||||
|
||||
/* check the scroll state and send out pagination requests if necessary.
|
||||
*/
|
||||
checkFillState: function() {
|
||||
checkFillState() {
|
||||
if (this.refs.scrollPanel) {
|
||||
this.refs.scrollPanel.checkFillState();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_isUnmounting: function() {
|
||||
_isUnmounting() {
|
||||
return !this._isMounted;
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: Implement granular (per-room) hide options
|
||||
_shouldShowEvent: function(mxEv) {
|
||||
_shouldShowEvent(mxEv) {
|
||||
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) {
|
||||
return false; // ignored = no show (only happens if the ignore happens after an event was received)
|
||||
}
|
||||
|
||||
if (this._showHiddenEventsInTimeline) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
if (!EventTile.haveTileForEvent(mxEv)) {
|
||||
return false; // no tile = no show
|
||||
|
@ -254,15 +325,87 @@ module.exports = React.createClass({
|
|||
if (this.props.highlightedEventId === mxEv.getId()) return true;
|
||||
|
||||
return !shouldHideEvent(mxEv);
|
||||
},
|
||||
}
|
||||
|
||||
_getEventTiles: function() {
|
||||
_readMarkerForEvent(eventId, isLastEvent) {
|
||||
const visible = !isLastEvent && this.props.readMarkerVisible;
|
||||
|
||||
if (this.props.readMarkerEventId === eventId) {
|
||||
let hr;
|
||||
// if the read marker comes at the end of the timeline (except
|
||||
// for local echoes, which are excluded from RMs, because they
|
||||
// don't have useful event ids), we don't want to show it, but
|
||||
// we still want to create the <li/> for it so that the
|
||||
// algorithms which depend on its position on the screen aren't
|
||||
// confused.
|
||||
if (visible) {
|
||||
hr = <hr className="mx_RoomView_myReadMarker"
|
||||
style={{opacity: 1, width: '99%'}}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={"readMarker_"+eventId} ref="readMarkerNode"
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
} else if (this.state.ghostReadMarkers.includes(eventId)) {
|
||||
// We render 'ghost' read markers in the DOM while they
|
||||
// transition away. This allows the actual read marker
|
||||
// to be in the right place straight away without having
|
||||
// to wait for the transition to finish.
|
||||
// There are probably much simpler ways to do this transition,
|
||||
// possibly using react-transition-group which handles keeping
|
||||
// elements in the DOM whilst they transition out, although our
|
||||
// case is a little more complex because only some of the items
|
||||
// transition (ie. the read markers do but the event tiles do not)
|
||||
// and TransitionGroup requires that all its children are Transitions.
|
||||
const hr = <hr className="mx_RoomView_myReadMarker"
|
||||
ref={this._collectGhostReadMarker}
|
||||
onTransitionEnd={this._onGhostTransitionEnd}
|
||||
data-eventid={eventId}
|
||||
/>;
|
||||
|
||||
// give it a key which depends on the event id. That will ensure that
|
||||
// we get a new DOM node (restarting the animation) when the ghost
|
||||
// moves to a different event.
|
||||
return (
|
||||
<li key={"_readuptoghost_"+eventId}
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_collectGhostReadMarker = (node) => {
|
||||
if (node) {
|
||||
// now the element has appeared, change the style which will trigger the CSS transition
|
||||
requestAnimationFrame(() => {
|
||||
node.style.width = '10%';
|
||||
node.style.opacity = '0';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onGhostTransitionEnd = (ev) => {
|
||||
// we can now clean up the ghost element
|
||||
const finishedEventId = ev.target.dataset.eventid;
|
||||
this.setState({
|
||||
ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId),
|
||||
});
|
||||
};
|
||||
|
||||
_getEventTiles() {
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
|
||||
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
|
||||
|
||||
this.eventNodes = {};
|
||||
|
||||
let visible = false;
|
||||
let i;
|
||||
|
||||
// first figure out which is the last event in the list which we're
|
||||
|
@ -297,28 +440,104 @@ module.exports = React.createClass({
|
|||
|
||||
let prevEvent = null; // the last event we showed
|
||||
|
||||
// assume there is no read marker until proven otherwise
|
||||
let readMarkerVisible = false;
|
||||
|
||||
// if the readmarker has moved, cancel any active ghost.
|
||||
if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
|
||||
this.props.readMarkerVisible &&
|
||||
this.currentReadMarkerEventId !== this.props.readMarkerEventId) {
|
||||
this.currentGhostEventId = null;
|
||||
this._readReceiptsByEvent = {};
|
||||
if (this.props.showReadReceipts) {
|
||||
this._readReceiptsByEvent = this._getReadReceiptsByShownEvent();
|
||||
}
|
||||
|
||||
const isMembershipChange = (e) => e.getType() === 'm.room.member';
|
||||
|
||||
for (i = 0; i < this.props.events.length; i++) {
|
||||
const mxEv = this.props.events[i];
|
||||
const eventId = mxEv.getId();
|
||||
const last = (mxEv === lastShownEvent);
|
||||
|
||||
// Wrap initial room creation events into an EventListSummary
|
||||
// Grouping only events sent by the same user that sent the `m.room.create` and only until
|
||||
// the first non-state event or membership event which is not regarding the sender of the `m.room.create` event
|
||||
const shouldGroup = (ev) => {
|
||||
if (ev.getType() === "m.room.member"
|
||||
&& (ev.getStateKey() !== mxEv.getSender() || ev.getContent()["membership"] !== "join")) {
|
||||
return false;
|
||||
}
|
||||
if (ev.isState() && ev.getSender() === mxEv.getSender()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (mxEv.getType() === "m.room.create") {
|
||||
let summaryReadMarker = null;
|
||||
const ts1 = mxEv.getTs();
|
||||
|
||||
if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
|
||||
const dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1} /></li>;
|
||||
ret.push(dateSeparator);
|
||||
}
|
||||
|
||||
// If RM event is the first in the summary, append the RM after the summary
|
||||
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
|
||||
|
||||
// If this m.room.create event should be shown (room upgrade) then show it before the summary
|
||||
if (this._shouldShowEvent(mxEv)) {
|
||||
// pass in the mxEv as prevEvent as well so no extra DateSeparator is rendered
|
||||
ret.push(...this._getTilesForEvent(mxEv, mxEv, false));
|
||||
}
|
||||
|
||||
const summarisedEvents = []; // Don't add m.room.create here as we don't want it inside the summary
|
||||
for (;i + 1 < this.props.events.length; i++) {
|
||||
const collapsedMxEv = this.props.events[i + 1];
|
||||
|
||||
// Ignore redacted/hidden member events
|
||||
if (!this._shouldShowEvent(collapsedMxEv)) {
|
||||
// If this hidden event is the RM and in or at end of a summary put RM after the summary.
|
||||
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldGroup(collapsedMxEv) || this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If RM event is in the summary, mark it as such and the RM will be appended after the summary.
|
||||
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
|
||||
|
||||
summarisedEvents.push(collapsedMxEv);
|
||||
}
|
||||
|
||||
// At this point, i = the index of the last event in the summary sequence
|
||||
const eventTiles = summarisedEvents.map((e) => {
|
||||
// In order to prevent DateSeparators from appearing in the expanded form
|
||||
// of EventListSummary, render each member event as if the previous
|
||||
// one was itself. This way, the timestamp of the previous event === the
|
||||
// timestamp of the current event, and no DateSeparator is inserted.
|
||||
return this._getTilesForEvent(e, e, e === lastShownEvent);
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
|
||||
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
|
||||
const ev = this.props.events[i];
|
||||
ret.push(<EventListSummary
|
||||
key="roomcreationsummary"
|
||||
events={summarisedEvents}
|
||||
onToggle={this._onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={_t("%(creator)s created and configured the room.", {
|
||||
creator: ev.sender ? ev.sender.name : ev.getSender(),
|
||||
})}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>);
|
||||
|
||||
if (summaryReadMarker) {
|
||||
ret.push(summaryReadMarker);
|
||||
}
|
||||
|
||||
prevEvent = mxEv;
|
||||
continue;
|
||||
}
|
||||
|
||||
const wantTile = this._shouldShowEvent(mxEv);
|
||||
|
||||
// Wrap consecutive member events in a ListSummary, ignore if redacted
|
||||
if (isMembershipChange(mxEv) && wantTile) {
|
||||
let readMarkerInMels = false;
|
||||
let summaryReadMarker = null;
|
||||
const ts1 = mxEv.getTs();
|
||||
// Ensure that the key of the MemberEventListSummary does not change with new
|
||||
// member events. This will prevent it from being re-created unnecessarily, and
|
||||
|
@ -336,9 +555,7 @@ module.exports = React.createClass({
|
|||
}
|
||||
|
||||
// If RM event is the first in the MELS, append the RM after MELS
|
||||
if (mxEv.getId() === this.props.readMarkerEventId) {
|
||||
readMarkerInMels = true;
|
||||
}
|
||||
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
|
||||
|
||||
const summarisedEvents = [mxEv];
|
||||
for (;i + 1 < this.props.events.length; i++) {
|
||||
|
@ -347,9 +564,7 @@ module.exports = React.createClass({
|
|||
// Ignore redacted/hidden member events
|
||||
if (!this._shouldShowEvent(collapsedMxEv)) {
|
||||
// If this hidden event is the RM and in or at end of a MELS put RM after MELS.
|
||||
if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
|
||||
readMarkerInMels = true;
|
||||
}
|
||||
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -359,9 +574,7 @@ module.exports = React.createClass({
|
|||
}
|
||||
|
||||
// If RM event is in MELS mark it as such and the RM will be appended after MELS.
|
||||
if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
|
||||
readMarkerInMels = true;
|
||||
}
|
||||
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
|
||||
|
||||
summarisedEvents.push(collapsedMxEv);
|
||||
}
|
||||
|
@ -376,9 +589,9 @@ module.exports = React.createClass({
|
|||
// In order to prevent DateSeparators from appearing in the expanded form
|
||||
// of MemberEventListSummary, render each member event as if the previous
|
||||
// one was itself. This way, the timestamp of the previous event === the
|
||||
// timestamp of the current event, and no DateSeperator is inserted.
|
||||
// timestamp of the current event, and no DateSeparator is inserted.
|
||||
return this._getTilesForEvent(e, e, e === lastShownEvent);
|
||||
}).reduce((a, b) => a.concat(b));
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
|
||||
if (eventTiles.length === 0) {
|
||||
eventTiles = null;
|
||||
|
@ -392,8 +605,8 @@ module.exports = React.createClass({
|
|||
{ eventTiles }
|
||||
</MemberEventListSummary>);
|
||||
|
||||
if (readMarkerInMels) {
|
||||
ret.push(this._getReadMarkerTile(visible));
|
||||
if (summaryReadMarker) {
|
||||
ret.push(summaryReadMarker);
|
||||
}
|
||||
|
||||
prevEvent = mxEv;
|
||||
|
@ -408,48 +621,20 @@ module.exports = React.createClass({
|
|||
prevEvent = mxEv;
|
||||
}
|
||||
|
||||
let isVisibleReadMarker = false;
|
||||
|
||||
if (eventId === this.props.readMarkerEventId) {
|
||||
visible = this.props.readMarkerVisible;
|
||||
|
||||
// if the read marker comes at the end of the timeline (except
|
||||
// for local echoes, which are excluded from RMs, because they
|
||||
// don't have useful event ids), we don't want to show it, but
|
||||
// we still want to create the <li/> for it so that the
|
||||
// algorithms which depend on its position on the screen aren't
|
||||
// confused.
|
||||
if (i >= lastShownNonLocalEchoIndex) {
|
||||
visible = false;
|
||||
}
|
||||
ret.push(this._getReadMarkerTile(visible));
|
||||
readMarkerVisible = visible;
|
||||
isVisibleReadMarker = visible;
|
||||
}
|
||||
|
||||
// XXX: there should be no need for a ghost tile - we should just use a
|
||||
// a dispatch (user_activity_end) to start the RM animation.
|
||||
if (eventId === this.currentGhostEventId) {
|
||||
// if we're showing an animation, continue to show it.
|
||||
ret.push(this._getReadMarkerGhostTile());
|
||||
} else if (!isVisibleReadMarker &&
|
||||
eventId === this.currentReadMarkerEventId) {
|
||||
// there is currently a read-up-to marker at this point, but no
|
||||
// more. Show an animation of it disappearing.
|
||||
ret.push(this._getReadMarkerGhostTile());
|
||||
this.currentGhostEventId = eventId;
|
||||
}
|
||||
const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
|
||||
if (readMarker) ret.push(readMarker);
|
||||
}
|
||||
|
||||
this.currentReadMarkerEventId = readMarkerVisible ? this.props.readMarkerEventId : null;
|
||||
return ret;
|
||||
},
|
||||
}
|
||||
|
||||
_getTilesForEvent: function(prevEvent, mxEv, last) {
|
||||
_getTilesForEvent(prevEvent, mxEv, last) {
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const ret = [];
|
||||
|
||||
const isEditing = this.props.editState &&
|
||||
this.props.editState.getEvent().getId() === mxEv.getId();
|
||||
// is this a continuation of the previous message?
|
||||
let continuation = false;
|
||||
|
||||
|
@ -506,48 +691,54 @@ module.exports = React.createClass({
|
|||
// Local echos have a send "status".
|
||||
const scrollToken = mxEv.status ? undefined : eventId;
|
||||
|
||||
let readReceipts;
|
||||
if (this.props.showReadReceipts) {
|
||||
readReceipts = this._getReadReceiptsForEvent(mxEv);
|
||||
}
|
||||
const readReceipts = this._readReceiptsByEvent[eventId];
|
||||
|
||||
ret.push(
|
||||
<li key={eventId}
|
||||
ref={this._collectEventNode.bind(this, eventId)}
|
||||
data-scroll-tokens={scrollToken}>
|
||||
<EventTile mxEvent={mxEv} continuation={continuation}
|
||||
isRedacted={mxEv.isRedacted()}
|
||||
onHeightChanged={this._onHeightChanged}
|
||||
readReceipts={readReceipts}
|
||||
readReceiptMap={this._readReceiptMap}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
checkUnmounting={this._isUnmounting}
|
||||
eventSendStatus={mxEv.status}
|
||||
tileShape={this.props.tileShape}
|
||||
isTwelveHour={this.props.isTwelveHour}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
last={last} isSelectedEvent={highlight} />
|
||||
</li>,
|
||||
<li key={eventId}
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
</li>,
|
||||
);
|
||||
|
||||
return ret;
|
||||
},
|
||||
}
|
||||
|
||||
_wantsDateSeparator: function(prevEvent, nextEventDate) {
|
||||
_wantsDateSeparator(prevEvent, nextEventDate) {
|
||||
if (prevEvent == null) {
|
||||
// first event in the panel: depends if we could back-paginate from
|
||||
// here.
|
||||
return !this.props.suppressFirstDateSeparator;
|
||||
}
|
||||
return wantsDateSeparator(prevEvent.getDate(), nextEventDate);
|
||||
},
|
||||
}
|
||||
|
||||
// get a list of read receipts that should be shown next to this event
|
||||
// Get a list of read receipts that should be shown next to this event
|
||||
// Receipts are objects which have a 'userId', 'roomMember' and 'ts'.
|
||||
_getReadReceiptsForEvent: function(event) {
|
||||
_getReadReceiptsForEvent(event) {
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
|
||||
// get list of read receipts, sorted most recent first
|
||||
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
|
||||
const { room } = this.props;
|
||||
if (!room) {
|
||||
return null;
|
||||
}
|
||||
|
@ -566,81 +757,90 @@ module.exports = React.createClass({
|
|||
ts: r.data ? r.data.ts : 0,
|
||||
});
|
||||
});
|
||||
return receipts;
|
||||
}
|
||||
|
||||
return receipts.sort((r1, r2) => {
|
||||
return r2.ts - r1.ts;
|
||||
});
|
||||
},
|
||||
// Get an object that maps from event ID to a list of read receipts that
|
||||
// should be shown next to that event. If a hidden event has read receipts,
|
||||
// they are folded into the receipts of the last shown event.
|
||||
_getReadReceiptsByShownEvent() {
|
||||
const receiptsByEvent = {};
|
||||
const receiptsByUserId = {};
|
||||
|
||||
_getReadMarkerTile: function(visible) {
|
||||
let hr;
|
||||
if (visible) {
|
||||
hr = <hr className="mx_RoomView_myReadMarker"
|
||||
style={{opacity: 1, width: '99%'}}
|
||||
/>;
|
||||
let lastShownEventId;
|
||||
for (const event of this.props.events) {
|
||||
if (this._shouldShowEvent(event)) {
|
||||
lastShownEventId = event.getId();
|
||||
}
|
||||
if (!lastShownEventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingReceipts = receiptsByEvent[lastShownEventId] || [];
|
||||
const newReceipts = this._getReadReceiptsForEvent(event);
|
||||
receiptsByEvent[lastShownEventId] = existingReceipts.concat(newReceipts);
|
||||
|
||||
// Record these receipts along with their last shown event ID for
|
||||
// each associated user ID.
|
||||
for (const receipt of newReceipts) {
|
||||
receiptsByUserId[receipt.userId] = {
|
||||
lastShownEventId,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li key="_readupto" ref="readMarkerNode"
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
},
|
||||
|
||||
_startAnimation: function(ghostNode) {
|
||||
if (this._readMarkerGhostNode) {
|
||||
Velocity.Utilities.removeData(this._readMarkerGhostNode);
|
||||
// It's possible in some cases (for example, when a read receipt
|
||||
// advances before we have paginated in the new event that it's marking
|
||||
// received) that we can temporarily not have a matching event for
|
||||
// someone which had one in the last. By looking through our previous
|
||||
// mapping of receipts by user ID, we can cover recover any receipts
|
||||
// that would have been lost by using the same event ID from last time.
|
||||
for (const userId in this._readReceiptsByUserId) {
|
||||
if (receiptsByUserId[userId]) {
|
||||
continue;
|
||||
}
|
||||
const { lastShownEventId, receipt } = this._readReceiptsByUserId[userId];
|
||||
const existingReceipts = receiptsByEvent[lastShownEventId] || [];
|
||||
receiptsByEvent[lastShownEventId] = existingReceipts.concat(receipt);
|
||||
receiptsByUserId[userId] = { lastShownEventId, receipt };
|
||||
}
|
||||
this._readMarkerGhostNode = ghostNode;
|
||||
this._readReceiptsByUserId = receiptsByUserId;
|
||||
|
||||
if (ghostNode) {
|
||||
Velocity(ghostNode, {opacity: '0', width: '10%'},
|
||||
{duration: 400, easing: 'easeInSine',
|
||||
delay: 1000});
|
||||
// After grouping receipts by shown events, do another pass to sort each
|
||||
// receipt list.
|
||||
for (const eventId in receiptsByEvent) {
|
||||
receiptsByEvent[eventId].sort((r1, r2) => {
|
||||
return r2.ts - r1.ts;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_getReadMarkerGhostTile: function() {
|
||||
const hr = <hr className="mx_RoomView_myReadMarker"
|
||||
style={{opacity: 1, width: '99%'}}
|
||||
ref={this._startAnimation}
|
||||
/>;
|
||||
return receiptsByEvent;
|
||||
}
|
||||
|
||||
// give it a key which depends on the event id. That will ensure that
|
||||
// we get a new DOM node (restarting the animation) when the ghost
|
||||
// moves to a different event.
|
||||
return (
|
||||
<li key={"_readuptoghost_"+this.currentGhostEventId}
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
},
|
||||
|
||||
_collectEventNode: function(eventId, node) {
|
||||
_collectEventNode = (eventId, node) => {
|
||||
this.eventNodes[eventId] = node;
|
||||
},
|
||||
}
|
||||
|
||||
// once dynamic content in the events load, make the scrollPanel check the
|
||||
// scroll offsets.
|
||||
_onHeightChanged: function() {
|
||||
_onHeightChanged = () => {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
if (scrollPanel) {
|
||||
scrollPanel.checkScroll();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onTypingShown: function() {
|
||||
_onTypingShown = () => {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
// this will make the timeline grow, so checkScroll
|
||||
scrollPanel.checkScroll();
|
||||
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
|
||||
scrollPanel.preventShrinking();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_onTypingHidden: function() {
|
||||
_onTypingHidden = () => {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
if (scrollPanel) {
|
||||
// as hiding the typing notifications doesn't
|
||||
|
@ -651,9 +851,9 @@ module.exports = React.createClass({
|
|||
// reveal added padding to balance the notifs disappearing.
|
||||
scrollPanel.checkScroll();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
updateTimelineMinHeight: function() {
|
||||
updateTimelineMinHeight() {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
|
||||
if (scrollPanel) {
|
||||
|
@ -668,16 +868,16 @@ module.exports = React.createClass({
|
|||
scrollPanel.preventShrinking();
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
onTimelineReset: function() {
|
||||
onTimelineReset() {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
if (scrollPanel) {
|
||||
scrollPanel.clearPreventShrinking();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
|
@ -700,7 +900,7 @@ module.exports = React.createClass({
|
|||
);
|
||||
|
||||
let whoIsTyping;
|
||||
if (this.props.room) {
|
||||
if (this.props.room && !this.props.tileShape) {
|
||||
whoIsTyping = (<WhoIsTypingTile
|
||||
room={this.props.room}
|
||||
onShown={this._onTypingShown}
|
||||
|
@ -724,5 +924,5 @@ module.exports = React.createClass({
|
|||
{ bottomSpinner }
|
||||
</ScrollPanel>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,20 +16,17 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from '../../dispatcher';
|
||||
import withMatrixClient from '../../wrappers/withMatrixClient';
|
||||
import AccessibleButton from '../views/elements/AccessibleButton';
|
||||
|
||||
export default withMatrixClient(React.createClass({
|
||||
export default createReactClass({
|
||||
displayName: 'MyGroups',
|
||||
|
||||
propTypes: {
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
groups: null,
|
||||
|
@ -36,6 +34,10 @@ export default withMatrixClient(React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
contextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._fetch();
|
||||
},
|
||||
|
@ -45,7 +47,7 @@ export default withMatrixClient(React.createClass({
|
|||
},
|
||||
|
||||
_fetch: function() {
|
||||
this.props.matrixClient.getJoinedGroups().done((result) => {
|
||||
this.context.matrixClient.getJoinedGroups().then((result) => {
|
||||
this.setState({groups: result.groups, error: null});
|
||||
}, (err) => {
|
||||
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') {
|
||||
|
@ -146,4 +148,4 @@ export default withMatrixClient(React.createClass({
|
|||
</div>
|
||||
</div>;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,18 +15,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
const React = require('react');
|
||||
const ReactDOM = require("react-dom");
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../languageHandler';
|
||||
const Matrix = require("matrix-js-sdk");
|
||||
const sdk = require('../../index');
|
||||
const MatrixClientPeg = require("../../MatrixClientPeg");
|
||||
const dis = require("../../dispatcher");
|
||||
|
||||
/*
|
||||
* Component which shows the global notification list using a TimelinePanel
|
||||
*/
|
||||
const NotificationPanel = React.createClass({
|
||||
const NotificationPanel = createReactClass({
|
||||
displayName: 'NotificationPanel',
|
||||
|
||||
propTypes: {
|
||||
|
@ -39,20 +38,21 @@ const NotificationPanel = React.createClass({
|
|||
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
|
||||
if (timelineSet) {
|
||||
return (
|
||||
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
|
||||
className="mx_NotificationPanel"
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview = {false}
|
||||
tileShape="notif"
|
||||
empty={_t('You have no visible notifications')}
|
||||
/>
|
||||
<div className="mx_NotificationPanel" role="tabpanel">
|
||||
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview={false}
|
||||
tileShape="notif"
|
||||
empty={_t('You have no visible notifications')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
console.error("No notifTimelineSet available!");
|
||||
return (
|
||||
<div className="mx_NotificationPanel">
|
||||
<div className="mx_NotificationPanel" role="tabpanel">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd
|
|||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -26,13 +27,14 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||
import RateLimitedFunc from '../../ratelimitedfunc';
|
||||
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
export default class RightPanel extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
roomId: React.PropTypes.string, // if showing panels for a given room, this is set
|
||||
groupId: React.PropTypes.string, // if showing panels for a given group, this is set
|
||||
user: React.PropTypes.object,
|
||||
roomId: PropTypes.string, // if showing panels for a given room, this is set
|
||||
groupId: PropTypes.string, // if showing panels for a given group, this is set
|
||||
user: PropTypes.object,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -164,6 +166,7 @@ export default class RightPanel extends React.Component {
|
|||
render() {
|
||||
const MemberList = sdk.getComponent('rooms.MemberList');
|
||||
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
|
||||
const UserInfo = sdk.getComponent('right_panel.UserInfo');
|
||||
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
|
||||
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
|
||||
const FilePanel = sdk.getComponent('structures.FilePanel');
|
||||
|
@ -182,14 +185,46 @@ export default class RightPanel extends React.Component {
|
|||
} else if (this.state.phase === RightPanel.Phase.GroupRoomList) {
|
||||
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) {
|
||||
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
|
||||
if (SettingsStore.isFeatureEnabled("feature_dm_verification")) {
|
||||
const onClose = () => {
|
||||
dis.dispatch({
|
||||
action: "view_user",
|
||||
member: null,
|
||||
});
|
||||
};
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
roomId={this.props.roomId}
|
||||
key={this.props.roomId || this.state.member.userId}
|
||||
onClose={onClose}
|
||||
/>;
|
||||
} else {
|
||||
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
|
||||
}
|
||||
} else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) {
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
|
||||
} else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) {
|
||||
panel = <GroupMemberInfo
|
||||
groupMember={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.user_id} />;
|
||||
if (SettingsStore.isFeatureEnabled("feature_dm_verification")) {
|
||||
const onClose = () => {
|
||||
dis.dispatch({
|
||||
action: "view_user",
|
||||
member: null,
|
||||
});
|
||||
};
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.userId}
|
||||
onClose={onClose} />;
|
||||
} else {
|
||||
panel = (
|
||||
<GroupMemberInfo
|
||||
groupMember={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.user_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) {
|
||||
panel = <GroupRoomInfo
|
||||
groupRoomId={this.state.groupRoomId}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,9 +16,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const React = require('react');
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
const MatrixClientPeg = require('../../MatrixClientPeg');
|
||||
const ContentRepo = require("matrix-js-sdk").ContentRepo;
|
||||
|
@ -25,7 +26,7 @@ const sdk = require('../../index');
|
|||
const dis = require('../../dispatcher');
|
||||
|
||||
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
|
||||
import Promise from 'bluebird';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
|
@ -37,12 +38,12 @@ function track(action) {
|
|||
Analytics.trackEvent('RoomDirectory', action);
|
||||
}
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'RoomDirectory',
|
||||
|
||||
propTypes: {
|
||||
config: React.PropTypes.object,
|
||||
onFinished: React.PropTypes.func.isRequired,
|
||||
config: PropTypes.object,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
@ -65,7 +66,7 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
childContextTypes: {
|
||||
matrixClient: React.PropTypes.object,
|
||||
matrixClient: PropTypes.object,
|
||||
},
|
||||
|
||||
getChildContext: function() {
|
||||
|
@ -87,7 +88,7 @@ module.exports = React.createClass({
|
|||
this.setState({protocolsLoading: false});
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().done((response) => {
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
}, (err) => {
|
||||
|
@ -133,19 +134,23 @@ module.exports = React.createClass({
|
|||
publicRooms: [],
|
||||
loading: true,
|
||||
});
|
||||
this.getMoreRooms().done();
|
||||
this.getMoreRooms();
|
||||
},
|
||||
|
||||
getMoreRooms: function() {
|
||||
if (!MatrixClientPeg.get()) return Promise.resolve();
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const my_filter_string = this.state.filterString;
|
||||
const my_server = this.state.roomServer;
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const my_next_batch = this.nextBatch;
|
||||
const opts = {limit: 20};
|
||||
if (my_server != MatrixClientPeg.getHomeServerName()) {
|
||||
if (my_server != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = my_server;
|
||||
}
|
||||
if (this.state.instanceId) {
|
||||
|
@ -240,7 +245,7 @@ module.exports = React.createClass({
|
|||
if (!alias) return;
|
||||
step = _t('delete the alias.');
|
||||
return MatrixClientPeg.get().deleteAlias(alias);
|
||||
}).done(() => {
|
||||
}).then(() => {
|
||||
modal.close();
|
||||
this.refreshRoomList();
|
||||
}, (err) => {
|
||||
|
@ -320,12 +325,7 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onCreateRoomClicked: function() {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({action: 'view_create_room'});
|
||||
},
|
||||
|
||||
onJoinClick: function(alias) {
|
||||
onJoinFromSearchClick: function(alias) {
|
||||
// If we don't have a particular instance id selected, just show that rooms alias
|
||||
if (!this.state.instanceId) {
|
||||
// If the user specified an alias without a domain, add on whichever server is selected
|
||||
|
@ -333,7 +333,7 @@ module.exports = React.createClass({
|
|||
if (alias.indexOf(':') == -1) {
|
||||
alias = alias + ':' + this.state.roomServer;
|
||||
}
|
||||
this.showRoomAlias(alias);
|
||||
this.showRoomAlias(alias, true);
|
||||
} else {
|
||||
// This is a 3rd party protocol. Let's see if we can join it
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
|
@ -347,9 +347,9 @@ module.exports = React.createClass({
|
|||
});
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => {
|
||||
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).then((resp) => {
|
||||
if (resp.length > 0 && resp[0].alias) {
|
||||
this.showRoomAlias(resp[0].alias);
|
||||
this.showRoomAlias(resp[0].alias, true);
|
||||
} else {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
|
||||
|
@ -367,13 +367,46 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
showRoomAlias: function(alias) {
|
||||
this.showRoom(null, alias);
|
||||
onPreviewClick: function(ev, room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.room_id,
|
||||
should_peek: true,
|
||||
});
|
||||
ev.stopPropagation();
|
||||
},
|
||||
|
||||
showRoom: function(room, room_alias) {
|
||||
onViewClick: function(ev, room) {
|
||||
this.props.onFinished();
|
||||
const payload = {action: 'view_room'};
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.room_id,
|
||||
should_peek: false,
|
||||
});
|
||||
ev.stopPropagation();
|
||||
},
|
||||
|
||||
onJoinClick: function(ev, room) {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
},
|
||||
|
||||
onCreateRoomClick: function(room) {
|
||||
this.props.onFinished();
|
||||
dis.dispatch({action: 'view_create_room'});
|
||||
},
|
||||
|
||||
showRoomAlias: function(alias, autoJoin=false) {
|
||||
this.showRoom(null, alias, autoJoin);
|
||||
},
|
||||
|
||||
showRoom: function(room, room_alias, autoJoin=false) {
|
||||
this.props.onFinished();
|
||||
const payload = {
|
||||
action: 'view_room',
|
||||
auto_join: autoJoin,
|
||||
};
|
||||
if (room) {
|
||||
// Don't let the user view a room they won't be able to either
|
||||
// peek or join: fail earlier so they don't have to click back
|
||||
|
@ -408,74 +441,70 @@ module.exports = React.createClass({
|
|||
dis.dispatch(payload);
|
||||
},
|
||||
|
||||
getRows: function() {
|
||||
getRow(room) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
const isGuest = client.isGuest();
|
||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
let previewButton;
|
||||
let joinOrViewButton;
|
||||
|
||||
if (!this.state.publicRooms) return [];
|
||||
|
||||
const rooms = this.state.publicRooms;
|
||||
const rows = [];
|
||||
const self = this;
|
||||
let guestRead; let guestJoin; let perms;
|
||||
for (let i = 0; i < rooms.length; i++) {
|
||||
guestRead = null;
|
||||
guestJoin = null;
|
||||
|
||||
if (rooms[i].world_readable) {
|
||||
guestRead = (
|
||||
<div className="mx_RoomDirectory_perm">{ _t('World readable') }</div>
|
||||
);
|
||||
}
|
||||
if (rooms[i].guest_can_join) {
|
||||
guestJoin = (
|
||||
<div className="mx_RoomDirectory_perm">{ _t('Guests can join') }</div>
|
||||
);
|
||||
}
|
||||
|
||||
perms = null;
|
||||
if (guestRead || guestJoin) {
|
||||
perms = <div className="mx_RoomDirectory_perms">{guestRead}{guestJoin}</div>;
|
||||
}
|
||||
|
||||
let name = rooms[i].name || get_display_alias_for_room(rooms[i]) || _t('Unnamed room');
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
|
||||
}
|
||||
|
||||
let topic = rooms[i].topic || '';
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
topic = linkifyAndSanitizeHtml(topic);
|
||||
|
||||
rows.push(
|
||||
<tr key={ rooms[i].room_id }
|
||||
onClick={self.onRoomClicked.bind(self, rooms[i])}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
>
|
||||
<td className="mx_RoomDirectory_roomAvatar">
|
||||
<BaseAvatar width={24} height={24} resizeMethod='crop'
|
||||
name={ name } idName={ name }
|
||||
url={ ContentRepo.getHttpUriForMxc(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
rooms[i].avatar_url, 24, 24, "crop") } />
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomDescription">
|
||||
<div className="mx_RoomDirectory_name">{ name }</div>
|
||||
{ perms }
|
||||
<div className="mx_RoomDirectory_topic"
|
||||
onClick={ function(e) { e.stopPropagation(); } }
|
||||
dangerouslySetInnerHTML={{ __html: topic }} />
|
||||
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(rooms[i]) }</div>
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomMemberCount">
|
||||
{ rooms[i].num_joined_members }
|
||||
</td>
|
||||
</tr>,
|
||||
if (room.world_readable && !hasJoinedRoom) {
|
||||
previewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
if (hasJoinedRoom) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
|
||||
);
|
||||
} else if (!isGuest || room.guest_can_join) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room');
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
|
||||
}
|
||||
|
||||
let topic = room.topic || '';
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
topic = linkifyAndSanitizeHtml(topic);
|
||||
const avatarUrl = ContentRepo.getHttpUriForMxc(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
room.avatar_url, 32, 32, "crop",
|
||||
);
|
||||
return (
|
||||
<tr key={ room.room_id }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
>
|
||||
<td className="mx_RoomDirectory_roomAvatar">
|
||||
<BaseAvatar width={32} height={32} resizeMethod='crop'
|
||||
name={ name } idName={ name }
|
||||
url={ avatarUrl } />
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomDescription">
|
||||
<div className="mx_RoomDirectory_name">{ name }</div>
|
||||
<div className="mx_RoomDirectory_topic"
|
||||
onClick={ (ev) => { ev.stopPropagation(); } }
|
||||
dangerouslySetInnerHTML={{ __html: topic }} />
|
||||
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomMemberCount">
|
||||
{ room.num_joined_members }
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_preview">{previewButton}</td>
|
||||
<td className="mx_RoomDirectory_join">{joinOrViewButton}</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
|
||||
collectScrollPanel: function(element) {
|
||||
|
@ -526,20 +555,26 @@ module.exports = React.createClass({
|
|||
let content;
|
||||
if (this.state.error) {
|
||||
content = this.state.error;
|
||||
} else if (this.state.protocolsLoading || this.state.loading) {
|
||||
} else if (this.state.protocolsLoading) {
|
||||
content = <Loader />;
|
||||
} else {
|
||||
const rows = this.getRows();
|
||||
const rows = (this.state.publicRooms || []).map(room => this.getRow(room));
|
||||
// we still show the scrollpanel, at least for now, because
|
||||
// otherwise we don't fetch more because we don't get a fill
|
||||
// request from the scrollpanel because there isn't one
|
||||
|
||||
let spinner;
|
||||
if (this.state.loading) {
|
||||
spinner = <Loader />;
|
||||
}
|
||||
|
||||
let scrollpanel_content;
|
||||
if (rows.length == 0) {
|
||||
if (rows.length === 0 && !this.state.loading) {
|
||||
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
|
||||
} else {
|
||||
scrollpanel_content = <table ref="directory_table" className="mx_RoomDirectory_table">
|
||||
<tbody>
|
||||
{ this.getRows() }
|
||||
{ rows }
|
||||
</tbody>
|
||||
</table>;
|
||||
}
|
||||
|
@ -551,6 +586,7 @@ module.exports = React.createClass({
|
|||
startAtBottom={false}
|
||||
>
|
||||
{ scrollpanel_content }
|
||||
{ spinner }
|
||||
</ScrollPanel>;
|
||||
}
|
||||
|
||||
|
@ -572,10 +608,9 @@ module.exports = React.createClass({
|
|||
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
|
||||
}
|
||||
|
||||
|
||||
let placeholder = _t('Search for a room');
|
||||
let placeholder = _t('Find a room…');
|
||||
if (!this.state.instanceId) {
|
||||
placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer;
|
||||
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;
|
||||
}
|
||||
|
@ -591,27 +626,31 @@ module.exports = React.createClass({
|
|||
listHeader = <div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
|
||||
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinFromSearchClick}
|
||||
placeholder={placeholder} showJoinButton={showJoinButton}
|
||||
/>
|
||||
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
const createRoomButton = (<AccessibleButton
|
||||
onClick={this.onCreateRoomClicked}
|
||||
className="mx_RoomDirectory_createRoom"
|
||||
>{_t("Create new room")}</AccessibleButton>);
|
||||
const explanation =
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
|
||||
{a: sub => {
|
||||
return (<AccessibleButton
|
||||
kind="secondary"
|
||||
onClick={this.onCreateRoomClick}
|
||||
>{sub}</AccessibleButton>);
|
||||
}},
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
headerButton={createRoomButton}
|
||||
title={_t("Room directory")}
|
||||
title={_t("Explore rooms")}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
<p>{explanation}</p>
|
||||
<div className="mx_RoomDirectory_list">
|
||||
{listHeader}
|
||||
{content}
|
||||
|
|
|
@ -16,13 +16,12 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
import sdk from '../../index';
|
||||
import WhoIsTyping from '../../WhoIsTyping';
|
||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import MemberAvatar from '../views/avatars/MemberAvatar';
|
||||
import Resend from '../../Resend';
|
||||
import * as cryptodevices from '../../cryptodevices';
|
||||
import dis from '../../dispatcher';
|
||||
|
@ -39,7 +38,7 @@ function getUnsentMessages(room) {
|
|||
});
|
||||
}
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'RoomStatusBar',
|
||||
|
||||
propTypes: {
|
||||
|
@ -290,7 +289,7 @@ module.exports = React.createClass({
|
|||
}
|
||||
|
||||
return <div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title={_t("Warning")} alt="" />
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ title }
|
||||
|
@ -304,12 +303,10 @@ module.exports = React.createClass({
|
|||
|
||||
// return suitable content for the main (text) part of the status bar.
|
||||
_getContent: function() {
|
||||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ _t('Connectivity to the server has been lost.') }
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,7 +17,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, {createRef} from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import classNames from 'classnames';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
|
@ -24,16 +26,17 @@ import Unread from '../../Unread';
|
|||
import * as RoomNotifs from '../../RoomNotifs';
|
||||
import * as FormattingUtils from '../../utils/FormattingUtils';
|
||||
import IndicatorScrollbar from './IndicatorScrollbar';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import {Key, KeyCode} from '../../Keyboard';
|
||||
import { Group } from 'matrix-js-sdk';
|
||||
import PropTypes from 'prop-types';
|
||||
import RoomTile from "../views/rooms/RoomTile";
|
||||
import LazyRenderList from "../views/elements/LazyRenderList";
|
||||
import {_t} from "../../languageHandler";
|
||||
|
||||
// turn this on for drop & drag console debugging galore
|
||||
const debug = false;
|
||||
|
||||
const RoomSubList = React.createClass({
|
||||
const RoomSubList = createReactClass({
|
||||
displayName: 'RoomSubList',
|
||||
|
||||
debug: debug,
|
||||
|
@ -42,6 +45,7 @@ const RoomSubList = React.createClass({
|
|||
list: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
tagName: PropTypes.string,
|
||||
addRoomLabel: PropTypes.string,
|
||||
|
||||
order: PropTypes.string.isRequired,
|
||||
|
||||
|
@ -53,9 +57,8 @@ const RoomSubList = React.createClass({
|
|||
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
|
||||
onHeaderClick: PropTypes.func,
|
||||
incomingCall: PropTypes.object,
|
||||
isFiltered: PropTypes.bool,
|
||||
headerItems: PropTypes.node, // content shown in the sublist header
|
||||
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
|
||||
forceExpand: PropTypes.bool,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -64,6 +67,9 @@ const RoomSubList = React.createClass({
|
|||
// some values to get LazyRenderList starting
|
||||
scrollerHeight: 800,
|
||||
scrollTop: 0,
|
||||
// React 16's getDerivedStateFromProps(props, state) doesn't give the previous props so
|
||||
// we have to store the length of the list here so we can see if it's changed or not...
|
||||
listLength: null,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -76,17 +82,27 @@ const RoomSubList = React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
componentDidMount: function() {
|
||||
this._headerButton = createRef();
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
},
|
||||
|
||||
statics: {
|
||||
getDerivedStateFromProps: function(props, state) {
|
||||
return {
|
||||
listLength: props.list.length,
|
||||
scrollTop: props.list.length === state.listLength ? state.scrollTop : 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
|
||||
// The header is collapsable if it is hidden or not stuck
|
||||
// The header is collapsible if it is hidden or not stuck
|
||||
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
|
||||
isCollapsableOnClick: function() {
|
||||
isCollapsibleOnClick: function() {
|
||||
const stuck = this.refs.header.dataset.stuck;
|
||||
if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) {
|
||||
return true;
|
||||
|
@ -111,8 +127,8 @@ const RoomSubList = React.createClass({
|
|||
},
|
||||
|
||||
onClick: function(ev) {
|
||||
if (this.isCollapsableOnClick()) {
|
||||
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
|
||||
if (this.isCollapsibleOnClick()) {
|
||||
// The header isCollapsible, so the click is to be interpreted as collapse and truncation logic
|
||||
const isHidden = !this.state.hidden;
|
||||
this.setState({hidden: isHidden}, () => {
|
||||
this.props.onHeaderClick(isHidden);
|
||||
|
@ -123,6 +139,49 @@ const RoomSubList = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onHeaderKeyDown: function(ev) {
|
||||
switch (ev.key) {
|
||||
case Key.TAB:
|
||||
// Prevent LeftPanel handling Tab if focus is on the sublist header itself
|
||||
ev.stopPropagation();
|
||||
break;
|
||||
case Key.ARROW_LEFT:
|
||||
// On ARROW_LEFT collapse the room sublist
|
||||
if (!this.state.hidden && !this.props.forceExpand) {
|
||||
this.onClick();
|
||||
}
|
||||
ev.stopPropagation();
|
||||
break;
|
||||
case Key.ARROW_RIGHT: {
|
||||
ev.stopPropagation();
|
||||
if (this.state.hidden && !this.props.forceExpand) {
|
||||
// sublist is collapsed, expand it
|
||||
this.onClick();
|
||||
} else if (!this.props.forceExpand) {
|
||||
// sublist is expanded, go to first room
|
||||
const element = this.refs.subList && this.refs.subList.querySelector(".mx_RoomTile");
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onKeyDown: function(ev) {
|
||||
switch (ev.key) {
|
||||
// On ARROW_LEFT go to the sublist header
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
this._headerButton.current.focus();
|
||||
break;
|
||||
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
|
||||
case Key.ARROW_RIGHT:
|
||||
ev.stopPropagation();
|
||||
}
|
||||
},
|
||||
|
||||
onRoomTileClick(roomId, ev) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
|
@ -190,8 +249,14 @@ const RoomSubList = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onAddRoom: function(e) {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
},
|
||||
|
||||
_getHeaderJsx: function(isCollapsed) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton');
|
||||
const subListNotifications = !this.props.isInvite ?
|
||||
RoomNotifs.aggregateNotificationCount(this.props.list) :
|
||||
{count: 0, highlight: true};
|
||||
|
@ -204,13 +269,24 @@ const RoomSubList = React.createClass({
|
|||
'mx_RoomSubList_badge': true,
|
||||
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
|
||||
});
|
||||
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||
if (subListNotifCount > 0) {
|
||||
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
|
||||
{ FormattingUtils.formatCount(subListNotifCount) }
|
||||
</div>;
|
||||
badge = (
|
||||
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
|
||||
<div>
|
||||
{ FormattingUtils.formatCount(subListNotifCount) }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (this.props.isInvite && this.props.list.length) {
|
||||
// no notifications but highlight anyway because this is an invite badge
|
||||
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>{this.props.list.length}</div>;
|
||||
badge = (
|
||||
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
|
||||
<div>
|
||||
{ this.props.list.length }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -232,7 +308,11 @@ const RoomSubList = React.createClass({
|
|||
let addRoomButton;
|
||||
if (this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleButton onClick={ this.props.onAddRoom } className="mx_RoomSubList_addRoom" />
|
||||
<AccessibleTooltipButton
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSubList_addRoom"
|
||||
title={this.props.addRoomLabel || _t("Add room")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -244,13 +324,20 @@ const RoomSubList = React.createClass({
|
|||
'mx_RoomSubList_chevronRight': isCollapsed,
|
||||
'mx_RoomSubList_chevronDown': !isCollapsed,
|
||||
});
|
||||
chevron = (<div className={chevronClasses}></div>);
|
||||
chevron = (<div className={chevronClasses} />);
|
||||
}
|
||||
|
||||
const tabindex = this.props.isFiltered ? "0" : "-1";
|
||||
return (
|
||||
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
|
||||
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
|
||||
<div className="mx_RoomSubList_labelContainer" title={title} ref="header" onKeyDown={this.onHeaderKeyDown}>
|
||||
<AccessibleButton
|
||||
onClick={this.onClick}
|
||||
className="mx_RoomSubList_label"
|
||||
tabIndex={0}
|
||||
aria-expanded={!isCollapsed}
|
||||
inputRef={this._headerButton}
|
||||
role="treeitem"
|
||||
aria-level="1"
|
||||
>
|
||||
{ chevron }
|
||||
<span>{this.props.label}</span>
|
||||
{ incomingCall }
|
||||
|
@ -291,21 +378,20 @@ const RoomSubList = React.createClass({
|
|||
render: function() {
|
||||
const len = this.props.list.length + this.props.extraTiles.length;
|
||||
const isCollapsed = this.state.hidden && !this.props.forceExpand;
|
||||
if (len) {
|
||||
const subListClasses = classNames({
|
||||
"mx_RoomSubList": true,
|
||||
"mx_RoomSubList_hidden": isCollapsed,
|
||||
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
|
||||
});
|
||||
|
||||
const subListClasses = classNames({
|
||||
"mx_RoomSubList": true,
|
||||
"mx_RoomSubList_hidden": len && isCollapsed,
|
||||
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
|
||||
});
|
||||
|
||||
let content;
|
||||
if (len) {
|
||||
if (isCollapsed) {
|
||||
return <div ref="subList" className={subListClasses}>
|
||||
{this._getHeaderJsx(isCollapsed)}
|
||||
</div>;
|
||||
// no body
|
||||
} else if (this._canUseLazyListRendering()) {
|
||||
return <div ref="subList" className={subListClasses}>
|
||||
{this._getHeaderJsx(isCollapsed)}
|
||||
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
|
||||
content = (
|
||||
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
|
||||
<LazyRenderList
|
||||
scrollTop={this.state.scrollTop }
|
||||
height={ this.state.scrollerHeight }
|
||||
|
@ -313,31 +399,35 @@ const RoomSubList = React.createClass({
|
|||
itemHeight={34}
|
||||
items={ this.props.list } />
|
||||
</IndicatorScrollbar>
|
||||
</div>;
|
||||
);
|
||||
} else {
|
||||
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
|
||||
const tiles = roomTiles.concat(this.props.extraTiles);
|
||||
return <div ref="subList" className={subListClasses}>
|
||||
{this._getHeaderJsx(isCollapsed)}
|
||||
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
|
||||
content = (
|
||||
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
|
||||
{ tiles }
|
||||
</IndicatorScrollbar>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
let content;
|
||||
if (this.props.showSpinner && !isCollapsed) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
content = <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref="subList" className="mx_RoomSubList">
|
||||
{ this._getHeaderJsx(isCollapsed) }
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref="subList"
|
||||
className={subListClasses}
|
||||
role="group"
|
||||
aria-label={this.props.label}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{ this._getHeaderJsx(isCollapsed) }
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -23,13 +24,13 @@ limitations under the License.
|
|||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Promise from 'bluebird';
|
||||
import filesize from 'filesize';
|
||||
import classNames from 'classnames';
|
||||
import {Room} from "matrix-js-sdk";
|
||||
import { _t } from '../../languageHandler';
|
||||
import {RoomPermalinkCreator} from '../../matrix-to';
|
||||
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
|
||||
|
||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
|
@ -41,6 +42,7 @@ import Tinter from '../../Tinter';
|
|||
import rate_limited_func from '../../ratelimitedfunc';
|
||||
import ObjectUtils from '../../ObjectUtils';
|
||||
import * as Rooms from '../../Rooms';
|
||||
import eventSearch from '../../Searching';
|
||||
|
||||
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
|
||||
|
||||
|
@ -63,7 +65,13 @@ if (DEBUG) {
|
|||
debuglog = console.log.bind(console);
|
||||
}
|
||||
|
||||
module.exports = React.createClass({
|
||||
const RoomContext = PropTypes.shape({
|
||||
canReact: PropTypes.bool.isRequired,
|
||||
canReply: PropTypes.bool.isRequired,
|
||||
room: PropTypes.instanceOf(Room),
|
||||
});
|
||||
|
||||
module.exports = createReactClass({
|
||||
displayName: 'RoomView',
|
||||
propTypes: {
|
||||
ConferenceHandler: PropTypes.any,
|
||||
|
@ -87,7 +95,7 @@ module.exports = React.createClass({
|
|||
// * name (string) The room's name
|
||||
// * avatarUrl (string) The mxc:// avatar URL for the room
|
||||
// * inviterName (string) The display name of the person who
|
||||
// * invited us tovthe room
|
||||
// * invited us to the room
|
||||
oobData: PropTypes.object,
|
||||
|
||||
// is the RightPanel collapsed?
|
||||
|
@ -155,6 +163,26 @@ module.exports = React.createClass({
|
|||
// We load this later by asking the js-sdk to suggest a version for us.
|
||||
// This object is the result of Room#getRecommendedVersion()
|
||||
upgradeRecommendation: null,
|
||||
|
||||
canReact: false,
|
||||
canReply: false,
|
||||
|
||||
useCider: false,
|
||||
};
|
||||
},
|
||||
|
||||
childContextTypes: {
|
||||
room: RoomContext,
|
||||
},
|
||||
|
||||
getChildContext: function() {
|
||||
const {canReact, canReply, room} = this.state;
|
||||
return {
|
||||
room: {
|
||||
canReact,
|
||||
canReply,
|
||||
room,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -164,6 +192,7 @@ module.exports = React.createClass({
|
|||
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);
|
||||
|
@ -174,6 +203,14 @@ module.exports = React.createClass({
|
|||
this._onRoomViewStoreUpdate(true);
|
||||
|
||||
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
|
||||
|
||||
this._onCiderUpdated();
|
||||
this._ciderWatcherRef = SettingsStore.watchSetting(
|
||||
"useCiderComposer", null, this._onCiderUpdated);
|
||||
},
|
||||
|
||||
_onCiderUpdated: function() {
|
||||
this.setState({useCider: SettingsStore.getValue("useCiderComposer")});
|
||||
},
|
||||
|
||||
_onRoomViewStoreUpdate: function(initial) {
|
||||
|
@ -320,7 +357,7 @@ module.exports = React.createClass({
|
|||
if (this.props.autoJoin) {
|
||||
this.onJoinButtonClicked();
|
||||
} else if (!room && shouldPeek) {
|
||||
console.log("Attempting to peek into room %s", roomId);
|
||||
console.info("Attempting to peek into room %s", roomId);
|
||||
this.setState({
|
||||
peekLoading: true,
|
||||
isPeeking: true, // this will change to false if peeking fails
|
||||
|
@ -452,6 +489,8 @@ module.exports = React.createClass({
|
|||
// (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());
|
||||
|
@ -556,7 +595,7 @@ module.exports = React.createClass({
|
|||
payload.data.description || payload.data.name);
|
||||
break;
|
||||
case 'picture_snapshot':
|
||||
return ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
[payload.file], this.state.room.roomId, MatrixClientPeg.get(),
|
||||
);
|
||||
break;
|
||||
|
@ -597,6 +636,11 @@ module.exports = React.createClass({
|
|||
showApps: payload.show,
|
||||
});
|
||||
break;
|
||||
case 'reply_to_event':
|
||||
if (this.state.searchResults && payload.event.getRoomId() === this.state.roomId && !this.unmounted) {
|
||||
this.onCancelSearchClick();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -671,6 +715,7 @@ module.exports = React.createClass({
|
|||
this._loadMembersIfJoined(room);
|
||||
this._calculateRecommendedVersion(room);
|
||||
this._updateE2EStatus(room);
|
||||
this._updatePermissions(room);
|
||||
},
|
||||
|
||||
_calculateRecommendedVersion: async function(room) {
|
||||
|
@ -794,6 +839,15 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onRoomStateEvents: function(ev, state) {
|
||||
// ignore if we don't have a room yet
|
||||
if (!this.state.room || this.state.room.roomId !== state.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updatePermissions(this.state.room);
|
||||
},
|
||||
|
||||
onRoomStateMember: function(ev, state, member) {
|
||||
// ignore if we don't have a room yet
|
||||
if (!this.state.room) {
|
||||
|
@ -812,6 +866,17 @@ module.exports = React.createClass({
|
|||
if (room.roomId === this.state.roomId) {
|
||||
this.forceUpdate();
|
||||
this._loadMembersIfJoined(room);
|
||||
this._updatePermissions(room);
|
||||
}
|
||||
},
|
||||
|
||||
_updatePermissions: function(room) {
|
||||
if (room) {
|
||||
const me = MatrixClientPeg.get().getUserId();
|
||||
const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me);
|
||||
const canReply = room.maySendMessage();
|
||||
|
||||
this.setState({canReact, canReply});
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -1036,7 +1101,7 @@ module.exports = React.createClass({
|
|||
}
|
||||
|
||||
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
|
||||
.done(undefined, (error) => {
|
||||
.then(undefined, (error) => {
|
||||
if (error.name === "UnknownDeviceError") {
|
||||
// Let the staus bar handle this
|
||||
return;
|
||||
|
@ -1064,23 +1129,12 @@ module.exports = React.createClass({
|
|||
// todo: should cancel any previous search requests.
|
||||
this.searchId = new Date().getTime();
|
||||
|
||||
let filter;
|
||||
if (scope === "Room") {
|
||||
filter = {
|
||||
// XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
|
||||
rooms: [
|
||||
this.state.room.roomId,
|
||||
],
|
||||
};
|
||||
}
|
||||
let roomId;
|
||||
if (scope === "Room") roomId = this.state.room.roomId;
|
||||
|
||||
debuglog("sending search request");
|
||||
|
||||
const searchPromise = MatrixClientPeg.get().searchRoomEvents({
|
||||
filter: filter,
|
||||
term: term,
|
||||
});
|
||||
this._handleSearchResult(searchPromise).done();
|
||||
const searchPromise = eventSearch(term, roomId);
|
||||
this._handleSearchResult(searchPromise);
|
||||
},
|
||||
|
||||
_handleSearchResult: function(searchPromise) {
|
||||
|
@ -1251,7 +1305,7 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
onForgetClick: function() {
|
||||
MatrixClientPeg.get().forget(this.state.room.roomId).done(function() {
|
||||
MatrixClientPeg.get().forget(this.state.room.roomId).then(function() {
|
||||
dis.dispatch({ action: 'view_next_room' });
|
||||
}, function(err) {
|
||||
const errCode = err.errcode || _t("unknown error code");
|
||||
|
@ -1268,7 +1322,7 @@ module.exports = React.createClass({
|
|||
this.setState({
|
||||
rejecting: true,
|
||||
});
|
||||
MatrixClientPeg.get().leave(this.state.roomId).done(function() {
|
||||
MatrixClientPeg.get().leave(this.state.roomId).then(function() {
|
||||
dis.dispatch({ action: 'view_next_room' });
|
||||
self.setState({
|
||||
rejecting: false,
|
||||
|
@ -1364,7 +1418,8 @@ module.exports = React.createClass({
|
|||
|
||||
const scrollState = messagePanel.getScrollState();
|
||||
|
||||
if (scrollState.stuckAtBottom) {
|
||||
// getScrollState on TimelinePanel *may* return null, so guard against that
|
||||
if (!scrollState || scrollState.stuckAtBottom) {
|
||||
// we don't really expect to be in this state, but it will
|
||||
// occasionally happen when no scroll state has been set on the
|
||||
// messagePanel (ie, we didn't have an initial event (so it's
|
||||
|
@ -1503,7 +1558,6 @@ module.exports = React.createClass({
|
|||
|
||||
render: function() {
|
||||
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
|
||||
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
|
||||
const ForwardMessage = sdk.getComponent("rooms.ForwardMessage");
|
||||
const AuxPanel = sdk.getComponent("rooms.AuxPanel");
|
||||
const SearchBar = sdk.getComponent("rooms.SearchBar");
|
||||
|
@ -1514,18 +1568,23 @@ module.exports = React.createClass({
|
|||
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
|
||||
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
|
||||
const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder");
|
||||
const ErrorBoundary = sdk.getComponent("elements.ErrorBoundary");
|
||||
|
||||
if (!this.state.room) {
|
||||
const loading = this.state.roomLoading || this.state.peekLoading;
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<RoomPreviewBar
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
loading={loading}
|
||||
joining={this.state.joining}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<RoomPreviewBar
|
||||
canPreview={false}
|
||||
previewLoading={this.state.peekLoading}
|
||||
error={this.state.roomLoadError}
|
||||
loading={loading}
|
||||
joining={this.state.joining}
|
||||
oobData={this.props.oobData}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
@ -1543,16 +1602,20 @@ module.exports = React.createClass({
|
|||
const roomAlias = this.state.roomAlias;
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false} error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
invitedEmail={invitedEmail}
|
||||
room={this.state.room}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false} error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
invitedEmail={invitedEmail}
|
||||
oobData={this.props.oobData}
|
||||
signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null}
|
||||
room={this.state.room}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1562,12 +1625,14 @@ module.exports = React.createClass({
|
|||
if (myMembership == 'invite') {
|
||||
if (this.state.joining || this.state.rejecting) {
|
||||
return (
|
||||
<RoomPreviewBar
|
||||
<ErrorBoundary>
|
||||
<RoomPreviewBar
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
joining={this.state.joining}
|
||||
rejecting={this.state.rejecting}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
} else {
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
|
@ -1582,14 +1647,16 @@ module.exports = React.createClass({
|
|||
// We have a regular invite for this room.
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectButtonClicked}
|
||||
inviterName={inviterName}
|
||||
canPreview={false}
|
||||
joining={this.state.joining}
|
||||
room={this.state.room}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectButtonClicked}
|
||||
inviterName={inviterName}
|
||||
canPreview={false}
|
||||
joining={this.state.joining}
|
||||
room={this.state.room}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1681,6 +1748,7 @@ module.exports = React.createClass({
|
|||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
invitedEmail={invitedEmail}
|
||||
oobData={this.props.oobData}
|
||||
canPreview={this.state.canPeek}
|
||||
room={this.state.room}
|
||||
/>
|
||||
|
@ -1726,15 +1794,29 @@ module.exports = React.createClass({
|
|||
myMembership === 'join' && !this.state.searchResults
|
||||
);
|
||||
if (canSpeak) {
|
||||
messageComposer =
|
||||
<MessageComposer
|
||||
room={this.state.room}
|
||||
callState={this.state.callState}
|
||||
disabled={this.props.disabled}
|
||||
showApps={this.state.showApps}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
||||
/>;
|
||||
if (this.state.useCider) {
|
||||
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
|
||||
messageComposer =
|
||||
<MessageComposer
|
||||
room={this.state.room}
|
||||
callState={this.state.callState}
|
||||
disabled={this.props.disabled}
|
||||
showApps={this.state.showApps}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
||||
/>;
|
||||
} else {
|
||||
const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer');
|
||||
messageComposer =
|
||||
<SlateMessageComposer
|
||||
room={this.state.room}
|
||||
callState={this.state.callState}
|
||||
disabled={this.props.disabled}
|
||||
showApps={this.state.showApps}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Why aren't we storing the term/scope/count in this format
|
||||
|
@ -1814,7 +1896,7 @@ module.exports = React.createClass({
|
|||
highlightedEventId = this.state.initialEventId;
|
||||
}
|
||||
|
||||
// console.log("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
|
||||
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
|
||||
const messagePanel = (
|
||||
<TimelinePanel ref={this._gatherTimelinePanelRef}
|
||||
timelineSet={this.state.room.getUnfilteredTimelineSet()}
|
||||
|
@ -1832,6 +1914,7 @@ module.exports = React.createClass({
|
|||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
showReactions={true}
|
||||
/>);
|
||||
|
||||
let topUnreadMessagesBar = null;
|
||||
|
@ -1870,42 +1953,46 @@ module.exports = React.createClass({
|
|||
|
||||
return (
|
||||
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
|
||||
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
|
||||
oobData={this.props.oobData}
|
||||
inRoom={myMembership === 'join'}
|
||||
collapsedRhs={collapsedRhs}
|
||||
onSearchClick={this.onSearchClick}
|
||||
onSettingsClick={this.onSettingsClick}
|
||||
onPinnedClick={this.onPinnedClick}
|
||||
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
|
||||
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
|
||||
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
/>
|
||||
<MainSplit
|
||||
panel={rightPanel}
|
||||
collapsedRhs={collapsedRhs}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
<div className={fadableSectionClasses}>
|
||||
{ auxPanel }
|
||||
<div className="mx_RoomView_timeline">
|
||||
{ topUnreadMessagesBar }
|
||||
{ jumpToBottom }
|
||||
{ messagePanel }
|
||||
{ searchResultsPanel }
|
||||
</div>
|
||||
<div className={statusBarAreaClass}>
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line"></div>
|
||||
{ statusBar }
|
||||
<ErrorBoundary>
|
||||
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
|
||||
oobData={this.props.oobData}
|
||||
inRoom={myMembership === 'join'}
|
||||
collapsedRhs={collapsedRhs}
|
||||
onSearchClick={this.onSearchClick}
|
||||
onSettingsClick={this.onSettingsClick}
|
||||
onPinnedClick={this.onPinnedClick}
|
||||
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
|
||||
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
|
||||
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
/>
|
||||
<MainSplit
|
||||
panel={rightPanel}
|
||||
collapsedRhs={collapsedRhs}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
<div className={fadableSectionClasses}>
|
||||
{auxPanel}
|
||||
<div className="mx_RoomView_timeline">
|
||||
{topUnreadMessagesBar}
|
||||
{jumpToBottom}
|
||||
{messagePanel}
|
||||
{searchResultsPanel}
|
||||
</div>
|
||||
<div className={statusBarAreaClass}>
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line"></div>
|
||||
{statusBar}
|
||||
</div>
|
||||
</div>
|
||||
{previewBar}
|
||||
{messageComposer}
|
||||
</div>
|
||||
{ previewBar }
|
||||
{ messageComposer }
|
||||
</div>
|
||||
</MainSplit>
|
||||
</MainSplit>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
module.exports.RoomContext = RoomContext;
|
||||
|
|
|
@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import React from "react";
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import Promise from 'bluebird';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import Timer from '../../utils/Timer';
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
@ -84,7 +84,7 @@ if (DEBUG_SCROLL) {
|
|||
* offset as normal.
|
||||
*/
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'ScrollPanel',
|
||||
|
||||
propTypes: {
|
||||
|
@ -214,6 +214,9 @@ module.exports = React.createClass({
|
|||
// after an update to the contents of the panel, check that the scroll is
|
||||
// where it ought to be, and set off pagination requests if necessary.
|
||||
checkScroll: function() {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this._restoreSavedScrollState();
|
||||
this.checkFillState();
|
||||
},
|
||||
|
@ -673,6 +676,11 @@ module.exports = React.createClass({
|
|||
debuglog("updateHeight getting straight to business, no scrolling going on.");
|
||||
}
|
||||
|
||||
// We might have unmounted since the timer finished, so abort if so.
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sn = this._getScrollNode();
|
||||
const itemlist = this.refs.itemlist;
|
||||
const contentHeight = this._getMessagesHeight();
|
||||
|
@ -750,8 +758,10 @@ module.exports = React.createClass({
|
|||
_getMessagesHeight() {
|
||||
const itemlist = this.refs.itemlist;
|
||||
const lastNode = itemlist.lastElementChild;
|
||||
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
|
||||
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
|
||||
// 18 is itemlist padding
|
||||
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
|
||||
return lastNodeBottom - firstNodeTop + (18 * 2);
|
||||
},
|
||||
|
||||
_topFromBottom(node) {
|
||||
|
|
|
@ -16,18 +16,21 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import dis from '../../dispatcher';
|
||||
import { throttle } from 'lodash';
|
||||
import AccessibleButton from '../../components/views/elements/AccessibleButton';
|
||||
import classNames from 'classnames';
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'SearchBox',
|
||||
|
||||
propTypes: {
|
||||
onSearch: PropTypes.func,
|
||||
onCleared: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
|
||||
|
@ -46,6 +49,7 @@ module.exports = React.createClass({
|
|||
getInitialState: function() {
|
||||
return {
|
||||
searchTerm: "",
|
||||
blurred: true,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -90,10 +94,22 @@ module.exports = React.createClass({
|
|||
this._clearSearch("keyboard");
|
||||
break;
|
||||
}
|
||||
if (this.props.onKeyDown) this.props.onKeyDown(ev);
|
||||
},
|
||||
|
||||
_onFocus: function(ev) {
|
||||
this.setState({blurred: false});
|
||||
ev.target.select();
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
},
|
||||
|
||||
_onBlur: function(ev) {
|
||||
this.setState({blurred: true});
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
},
|
||||
|
||||
_clearSearch: function(source) {
|
||||
|
@ -112,15 +128,21 @@ module.exports = React.createClass({
|
|||
if (this.props.collapsed) {
|
||||
return null;
|
||||
}
|
||||
const clearButton = this.state.searchTerm.length > 0 ?
|
||||
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
|
||||
(<AccessibleButton key="button"
|
||||
className="mx_SearchBox_closeButton"
|
||||
onClick={ () => {this._clearSearch("button"); } }>
|
||||
</AccessibleButton>) : undefined;
|
||||
|
||||
// show a shorter placeholder when blurred, if requested
|
||||
// this is used for the room filter field that has
|
||||
// the explore button next to it when blurred
|
||||
const placeholder = this.state.blurred ?
|
||||
(this.props.blurredPlaceholder || this.props.placeholder) :
|
||||
this.props.placeholder;
|
||||
const className = this.props.className || "";
|
||||
return (
|
||||
<div className="mx_SearchBox mx_textinput">
|
||||
<div className={classNames("mx_SearchBox", "mx_textinput", {"mx_SearchBox_blurred": this.state.blurred})}>
|
||||
<input
|
||||
key="searchfield"
|
||||
type="text"
|
||||
|
@ -130,7 +152,8 @@ module.exports = React.createClass({
|
|||
onFocus={ this._onFocus }
|
||||
onChange={ this.onChange }
|
||||
onKeyDown={ this._onKeyDown }
|
||||
placeholder={ this.props.placeholder }
|
||||
onBlur={this._onBlur}
|
||||
placeholder={ placeholder }
|
||||
/>
|
||||
{ clearButton }
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2017 Travis Ralston
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,6 +19,7 @@ limitations under the License.
|
|||
import * as React from "react";
|
||||
import {_t} from '../../languageHandler';
|
||||
import PropTypes from "prop-types";
|
||||
import sdk from "../../index";
|
||||
|
||||
/**
|
||||
* Represents a tab for the TabbedView.
|
||||
|
@ -70,6 +72,8 @@ export class TabbedView extends React.Component {
|
|||
}
|
||||
|
||||
_renderTabLabel(tab) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let classes = "mx_TabbedView_tabLabel ";
|
||||
|
||||
const idx = this.props.tabs.indexOf(tab);
|
||||
|
@ -82,14 +86,14 @@ export class TabbedView extends React.Component {
|
|||
|
||||
const onClickHandler = () => this._setActiveTab(tab);
|
||||
|
||||
const label = _t(tab.label);
|
||||
return (
|
||||
<span className={classes} key={"tab_label_" + tab.label}
|
||||
onClick={onClickHandler}>
|
||||
<AccessibleButton className={classes} key={"tab_label_" + tab.label} onClick={onClickHandler}>
|
||||
{tabIcon}
|
||||
<span className="mx_TabbedView_tabLabel_text">
|
||||
{_t(tab.label)}
|
||||
{ label }
|
||||
</span>
|
||||
</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import TagOrderStore from '../../stores/TagOrderStore';
|
||||
|
@ -28,7 +29,7 @@ import { _t } from '../../languageHandler';
|
|||
import { Droppable } from 'react-beautiful-dnd';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const TagPanel = React.createClass({
|
||||
const TagPanel = createReactClass({
|
||||
displayName: 'TagPanel',
|
||||
|
||||
contextTypes: {
|
||||
|
|
|
@ -15,16 +15,17 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import Modal from '../../Modal';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
const TagPanelButtons = React.createClass({
|
||||
const TagPanelButtons = createReactClass({
|
||||
displayName: 'TagPanelButtons',
|
||||
|
||||
|
||||
componentWillMount: function() {
|
||||
componentDidMount: function() {
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
},
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,10 +19,10 @@ limitations under the License.
|
|||
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
const React = require('react');
|
||||
const ReactDOM = require("react-dom");
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import ReactDOM from "react-dom";
|
||||
import PropTypes from 'prop-types';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
const Matrix = require("matrix-js-sdk");
|
||||
const EventTimeline = Matrix.EventTimeline;
|
||||
|
@ -35,11 +36,11 @@ const Modal = require("../../Modal");
|
|||
const UserActivity = require("../../UserActivity");
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import Timer from '../../utils/Timer';
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
||||
|
||||
const PAGINATE_SIZE = 20;
|
||||
const INITIAL_SIZE = 20;
|
||||
const READ_MARKER_INVIEW_THRESHOLD_MS = 1 * 1000;
|
||||
const READ_MARKER_OUTOFVIEW_THRESHOLD_MS = 30 * 1000;
|
||||
const READ_RECEIPT_INTERVAL_MS = 500;
|
||||
|
||||
const DEBUG = false;
|
||||
|
@ -55,7 +56,7 @@ if (DEBUG) {
|
|||
*
|
||||
* Also responsible for handling and sending read receipts.
|
||||
*/
|
||||
const TimelinePanel = React.createClass({
|
||||
const TimelinePanel = createReactClass({
|
||||
displayName: 'TimelinePanel',
|
||||
|
||||
propTypes: {
|
||||
|
@ -106,6 +107,9 @@ const TimelinePanel = React.createClass({
|
|||
|
||||
// placeholder text to use if the timeline is empty
|
||||
empty: PropTypes.string,
|
||||
|
||||
// whether to show reactions for an event
|
||||
showReactions: PropTypes.bool,
|
||||
},
|
||||
|
||||
statics: {
|
||||
|
@ -137,6 +141,7 @@ const TimelinePanel = React.createClass({
|
|||
|
||||
return {
|
||||
events: [],
|
||||
liveEvents: [],
|
||||
timelineLoading: true, // track whether our room timeline is loading
|
||||
|
||||
// canBackPaginate == false may mean:
|
||||
|
@ -183,6 +188,12 @@ const TimelinePanel = React.createClass({
|
|||
|
||||
// always show timestamps on event tiles?
|
||||
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
|
||||
|
||||
// how long to show the RM for when it's visible in the window
|
||||
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
|
||||
|
||||
// how long to show the RM for when it's scrolled off-screen
|
||||
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -204,10 +215,13 @@ const TimelinePanel = React.createClass({
|
|||
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
|
||||
MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
|
||||
MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
|
||||
// same event handler as Room.redaction as for both we just do forceUpdate
|
||||
MatrixClientPeg.get().on("Room.redactionCancelled", this.onRoomRedaction);
|
||||
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
|
||||
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||
MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
|
||||
MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced);
|
||||
MatrixClientPeg.get().on("sync", this.onSync);
|
||||
|
||||
this._initTimeline(this.props);
|
||||
|
@ -282,10 +296,12 @@ const TimelinePanel = React.createClass({
|
|||
client.removeListener("Room.timeline", this.onRoomTimeline);
|
||||
client.removeListener("Room.timelineReset", this.onRoomTimelineReset);
|
||||
client.removeListener("Room.redaction", this.onRoomRedaction);
|
||||
client.removeListener("Room.redactionCancelled", this.onRoomRedaction);
|
||||
client.removeListener("Room.receipt", this.onRoomReceipt);
|
||||
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||
client.removeListener("Room.accountData", this.onAccountData);
|
||||
client.removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
client.removeListener("Event.replaced", this.onEventReplaced);
|
||||
client.removeListener("sync", this.onSync);
|
||||
}
|
||||
},
|
||||
|
@ -313,9 +329,11 @@ const TimelinePanel = React.createClass({
|
|||
|
||||
// We can now paginate in the unpaginated direction
|
||||
const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate';
|
||||
const { events, liveEvents } = this._getEvents();
|
||||
this.setState({
|
||||
[canPaginateKey]: true,
|
||||
events: this._getEvents(),
|
||||
events,
|
||||
liveEvents,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -347,10 +365,12 @@ const TimelinePanel = React.createClass({
|
|||
|
||||
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
|
||||
|
||||
const { events, liveEvents } = this._getEvents();
|
||||
const newState = {
|
||||
[paginatingKey]: false,
|
||||
[canPaginateKey]: r,
|
||||
events: this._getEvents(),
|
||||
events,
|
||||
liveEvents,
|
||||
};
|
||||
|
||||
// moving the window in this direction may mean that we can now
|
||||
|
@ -402,6 +422,16 @@ const TimelinePanel = React.createClass({
|
|||
if (payload.action === 'ignore_state_changed') {
|
||||
this.forceUpdate();
|
||||
}
|
||||
if (payload.action === "edit_event") {
|
||||
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
|
||||
this.setState({editState}, () => {
|
||||
if (payload.event && this.refs.messagePanel) {
|
||||
this.refs.messagePanel.scrollToEventIfNeeded(
|
||||
payload.event.getId(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
|
||||
|
@ -431,18 +461,16 @@ const TimelinePanel = React.createClass({
|
|||
// timeline window.
|
||||
//
|
||||
// see https://github.com/vector-im/vector-web/issues/1035
|
||||
this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).done(() => {
|
||||
this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => {
|
||||
if (this.unmounted) { return; }
|
||||
|
||||
const events = this._timelineWindow.getEvents();
|
||||
const lastEv = events[events.length-1];
|
||||
const { events, liveEvents } = this._getEvents();
|
||||
const lastLiveEvent = liveEvents[liveEvents.length - 1];
|
||||
|
||||
// if we're at the end of the live timeline, append the pending events
|
||||
if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
|
||||
events.push(...this.props.timelineSet.room.getPendingEvents());
|
||||
}
|
||||
|
||||
const updatedState = {events: events};
|
||||
const updatedState = {
|
||||
events,
|
||||
liveEvents,
|
||||
};
|
||||
|
||||
let callRMUpdated;
|
||||
if (this.props.manageReadMarkers) {
|
||||
|
@ -459,13 +487,13 @@ const TimelinePanel = React.createClass({
|
|||
callRMUpdated = false;
|
||||
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
updatedState.readMarkerVisible = true;
|
||||
} else if (lastEv && this.getReadMarkerPosition() === 0) {
|
||||
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
|
||||
// we know we're stuckAtBottom, so we can advance the RM
|
||||
// immediately, to save a later render cycle
|
||||
|
||||
this._setReadMarker(lastEv.getId(), lastEv.getTs(), true);
|
||||
this._setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true);
|
||||
updatedState.readMarkerVisible = false;
|
||||
updatedState.readMarkerEventId = lastEv.getId();
|
||||
updatedState.readMarkerEventId = lastLiveEvent.getId();
|
||||
callRMUpdated = true;
|
||||
}
|
||||
}
|
||||
|
@ -502,6 +530,17 @@ const TimelinePanel = React.createClass({
|
|||
this.forceUpdate();
|
||||
},
|
||||
|
||||
onEventReplaced: function(replacedEvent, room) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
if (room !== this.props.timelineSet.room) return;
|
||||
|
||||
// we could skip an update if the event isn't in our timeline,
|
||||
// but that's probably an early optimisation.
|
||||
this.forceUpdate();
|
||||
},
|
||||
|
||||
onRoomReceipt: function(ev, room) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
|
@ -537,6 +576,9 @@ const TimelinePanel = React.createClass({
|
|||
},
|
||||
|
||||
onEventDecrypted: function(ev) {
|
||||
// Can be null for the notification timeline, etc.
|
||||
if (!this.props.timelineSet.room) return;
|
||||
|
||||
// Need to update as we don't display event tiles for events that
|
||||
// haven't yet been decrypted. The event will have just been updated
|
||||
// in place so we just need to re-render.
|
||||
|
@ -554,8 +596,8 @@ const TimelinePanel = React.createClass({
|
|||
|
||||
_readMarkerTimeout(readMarkerPosition) {
|
||||
return readMarkerPosition === 0 ?
|
||||
READ_MARKER_INVIEW_THRESHOLD_MS :
|
||||
READ_MARKER_OUTOFVIEW_THRESHOLD_MS;
|
||||
this.state.readMarkerInViewThresholdMs :
|
||||
this.state.readMarkerOutOfViewThresholdMs;
|
||||
},
|
||||
|
||||
updateReadMarkerOnUserActivity: async function() {
|
||||
|
@ -585,6 +627,8 @@ const TimelinePanel = React.createClass({
|
|||
},
|
||||
|
||||
sendReadReceipt: function() {
|
||||
if (SettingsStore.getValue("lowBandwidth")) return;
|
||||
|
||||
if (!this.refs.messagePanel) return;
|
||||
if (!this.props.manageReadReceipts) return;
|
||||
// This happens on user_activity_end which is delayed, and it's
|
||||
|
@ -644,23 +688,32 @@ const TimelinePanel = React.createClass({
|
|||
}
|
||||
this.lastRMSentEventId = this.state.readMarkerEventId;
|
||||
|
||||
const roomId = this.props.timelineSet.room.roomId;
|
||||
const hiddenRR = !SettingsStore.getValue("sendReadReceipts", roomId);
|
||||
|
||||
debuglog('TimelinePanel: Sending Read Markers for ',
|
||||
this.props.timelineSet.room.roomId,
|
||||
'rm', this.state.readMarkerEventId,
|
||||
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
|
||||
' hidden:' + hiddenRR,
|
||||
);
|
||||
MatrixClientPeg.get().setRoomReadMarkers(
|
||||
this.props.timelineSet.room.roomId,
|
||||
this.state.readMarkerEventId,
|
||||
lastReadEvent, // Could be null, in which case no RR is sent
|
||||
{hidden: hiddenRR},
|
||||
).catch((e) => {
|
||||
// /read_markers API is not implemented on this HS, fallback to just RR
|
||||
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
|
||||
return MatrixClientPeg.get().sendReadReceipt(
|
||||
lastReadEvent,
|
||||
).catch(() => {
|
||||
{hidden: hiddenRR},
|
||||
).catch((e) => {
|
||||
console.error(e);
|
||||
this.lastRRSentEventId = undefined;
|
||||
});
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
// it failed, so allow retries next time the user is active
|
||||
this.lastRRSentEventId = undefined;
|
||||
|
@ -695,14 +748,8 @@ const TimelinePanel = React.createClass({
|
|||
// move the RM to *after* the message at the bottom of the screen. This
|
||||
// avoids a problem whereby we never advance the RM if there is a huge
|
||||
// message which doesn't fit on the screen.
|
||||
//
|
||||
// But ignore local echoes for this - they have a temporary event ID
|
||||
// and we'll get confused when their ID changes and we can't figure out
|
||||
// where the RM is pointing to. The read marker will be invisible for
|
||||
// now anyway, so this doesn't really matter.
|
||||
const lastDisplayedIndex = this._getLastDisplayedEventIndex({
|
||||
allowPartial: true,
|
||||
ignoreEchoes: true,
|
||||
});
|
||||
|
||||
if (lastDisplayedIndex === null) {
|
||||
|
@ -726,9 +773,9 @@ const TimelinePanel = React.createClass({
|
|||
_advanceReadMarkerPastMyEvents: function() {
|
||||
if (!this.props.manageReadMarkers) return;
|
||||
|
||||
// we call _timelineWindow.getEvents() rather than using
|
||||
// this.state.events, because react batches the update to the latter, so it
|
||||
// may not have been updated yet.
|
||||
// we call `_timelineWindow.getEvents()` rather than using
|
||||
// `this.state.liveEvents`, because React batches the update to the
|
||||
// latter, so it may not have been updated yet.
|
||||
const events = this._timelineWindow.getEvents();
|
||||
|
||||
// first find where the current RM is
|
||||
|
@ -1016,8 +1063,6 @@ const TimelinePanel = React.createClass({
|
|||
});
|
||||
};
|
||||
|
||||
let prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
|
||||
|
||||
// if we already have the event in question, TimelineWindow.load
|
||||
// returns a resolved promise.
|
||||
//
|
||||
|
@ -1026,20 +1071,23 @@ const TimelinePanel = React.createClass({
|
|||
// quite slow. So we detect that situation and shortcut straight to
|
||||
// calling _reloadEvents and updating the state.
|
||||
|
||||
if (prom.isFulfilled()) {
|
||||
const timeline = this.props.timelineSet.getTimelineForEvent(eventId);
|
||||
if (timeline) {
|
||||
// This is a hot-path optimization by skipping a promise tick
|
||||
// by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
|
||||
this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time
|
||||
onLoaded();
|
||||
} else {
|
||||
const prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
|
||||
this.setState({
|
||||
events: [],
|
||||
liveEvents: [],
|
||||
canBackPaginate: false,
|
||||
canForwardPaginate: false,
|
||||
timelineLoading: true,
|
||||
});
|
||||
|
||||
prom = prom.then(onLoaded, onError);
|
||||
prom.then(onLoaded, onError);
|
||||
}
|
||||
|
||||
prom.done();
|
||||
},
|
||||
|
||||
// handle the completion of a timeline load or localEchoUpdate, by
|
||||
|
@ -1050,21 +1098,26 @@ const TimelinePanel = React.createClass({
|
|||
// the results if so.
|
||||
if (this.unmounted) return;
|
||||
|
||||
this.setState({
|
||||
events: this._getEvents(),
|
||||
});
|
||||
this.setState(this._getEvents());
|
||||
},
|
||||
|
||||
// get the list of events from the timeline window and the pending event list
|
||||
_getEvents: function() {
|
||||
const events = this._timelineWindow.getEvents();
|
||||
|
||||
// Hold onto the live events separately. The read receipt and read marker
|
||||
// should use this list, so that they don't advance into pending events.
|
||||
const liveEvents = [...events];
|
||||
|
||||
// if we're at the end of the live timeline, append the pending events
|
||||
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
|
||||
events.push(...this.props.timelineSet.getPendingEvents());
|
||||
}
|
||||
|
||||
return events;
|
||||
return {
|
||||
events,
|
||||
liveEvents,
|
||||
};
|
||||
},
|
||||
|
||||
_indexForEventId: function(evId) {
|
||||
|
@ -1079,36 +1132,76 @@ const TimelinePanel = React.createClass({
|
|||
_getLastDisplayedEventIndex: function(opts) {
|
||||
opts = opts || {};
|
||||
const ignoreOwn = opts.ignoreOwn || false;
|
||||
const ignoreEchoes = opts.ignoreEchoes || false;
|
||||
const allowPartial = opts.allowPartial || false;
|
||||
|
||||
const messagePanel = this.refs.messagePanel;
|
||||
if (messagePanel === undefined) return null;
|
||||
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
|
||||
const wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
|
||||
for (let i = this.state.events.length-1; i >= 0; --i) {
|
||||
const ev = this.state.events[i];
|
||||
|
||||
if (ignoreOwn && ev.sender && ev.sender.userId == myUserId) {
|
||||
continue;
|
||||
const isNodeInView = (node) => {
|
||||
if (node) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
if ((allowPartial && boundingRect.top < wrapperRect.bottom) ||
|
||||
(!allowPartial && boundingRect.bottom < wrapperRect.bottom)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// local echoes have a fake event ID
|
||||
if (ignoreEchoes && ev.status) {
|
||||
continue;
|
||||
}
|
||||
// We keep track of how many of the adjacent events didn't have a tile
|
||||
// but should have the read receipt moved past them, so
|
||||
// we can include those once we find the last displayed (visible) event.
|
||||
// The counter is not started for events we don't want
|
||||
// to send a read receipt for (our own events, local echos).
|
||||
let adjacentInvisibleEventCount = 0;
|
||||
// Use `liveEvents` here because we don't want the read marker or read
|
||||
// receipt to advance into pending events.
|
||||
for (let i = this.state.liveEvents.length - 1; i >= 0; --i) {
|
||||
const ev = this.state.liveEvents[i];
|
||||
|
||||
const node = messagePanel.getNodeForEventId(ev.getId());
|
||||
if (!node) continue;
|
||||
const isInView = isNodeInView(node);
|
||||
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
if ((allowPartial && boundingRect.top < wrapperRect.bottom) ||
|
||||
(!allowPartial && boundingRect.bottom < wrapperRect.bottom)) {
|
||||
// when we've reached the first visible event, and the previous
|
||||
// events were all invisible (with the first one not being ignored),
|
||||
// return the index of the first invisible event.
|
||||
if (isInView && adjacentInvisibleEventCount !== 0) {
|
||||
return i + adjacentInvisibleEventCount;
|
||||
}
|
||||
if (node && !isInView) {
|
||||
// has node but not in view, so reset adjacent invisible events
|
||||
adjacentInvisibleEventCount = 0;
|
||||
}
|
||||
|
||||
const shouldIgnore = !!ev.status || // local echo
|
||||
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
|
||||
const isWithoutTile = !EventTile.haveTileForEvent(ev) || shouldHideEvent(ev);
|
||||
|
||||
if (isWithoutTile || !node) {
|
||||
// don't start counting if the event should be ignored,
|
||||
// but continue counting if we were already so the offset
|
||||
// to the previous invisble event that didn't need to be ignored
|
||||
// doesn't get messed up
|
||||
if (!shouldIgnore || (shouldIgnore && adjacentInvisibleEventCount !== 0)) {
|
||||
++adjacentInvisibleEventCount;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldIgnore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInView) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
|
@ -1168,6 +1261,10 @@ const TimelinePanel = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
getRelationsForEvent(...args) {
|
||||
return this.props.timelineSet.getRelationsForEvent(...args);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const MessagePanel = sdk.getComponent("structures.MessagePanel");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
@ -1193,9 +1290,9 @@ const TimelinePanel = React.createClass({
|
|||
|
||||
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
|
||||
return (
|
||||
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
|
||||
<div className="mx_RoomView_empty">{ this.props.empty }</div>
|
||||
</div>
|
||||
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
|
||||
<div className="mx_RoomView_empty">{this.props.empty}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1217,28 +1314,31 @@ const TimelinePanel = React.createClass({
|
|||
);
|
||||
return (
|
||||
<MessagePanel ref="messagePanel"
|
||||
room={this.props.timelineSet.room}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
hidden={this.props.hidden}
|
||||
backPaginating={this.state.backPaginating}
|
||||
forwardPaginating={forwardPaginating}
|
||||
events={this.state.events}
|
||||
highlightedEventId={this.props.highlightedEventId}
|
||||
readMarkerEventId={this.state.readMarkerEventId}
|
||||
readMarkerVisible={this.state.readMarkerVisible}
|
||||
suppressFirstDateSeparator={this.state.canBackPaginate}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
showReadReceipts={this.props.showReadReceipts}
|
||||
ourUserId={MatrixClientPeg.get().credentials.userId}
|
||||
stickyBottom={stickyBottom}
|
||||
onScroll={this.onMessageListScroll}
|
||||
onFillRequest={this.onMessageListFillRequest}
|
||||
onUnfillRequest={this.onMessageListUnfillRequest}
|
||||
isTwelveHour={this.state.isTwelveHour}
|
||||
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
|
||||
className={this.props.className}
|
||||
tileShape={this.props.tileShape}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
room={this.props.timelineSet.room}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
hidden={this.props.hidden}
|
||||
backPaginating={this.state.backPaginating}
|
||||
forwardPaginating={forwardPaginating}
|
||||
events={this.state.events}
|
||||
highlightedEventId={this.props.highlightedEventId}
|
||||
readMarkerEventId={this.state.readMarkerEventId}
|
||||
readMarkerVisible={this.state.readMarkerVisible}
|
||||
suppressFirstDateSeparator={this.state.canBackPaginate}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
showReadReceipts={this.props.showReadReceipts}
|
||||
ourUserId={MatrixClientPeg.get().credentials.userId}
|
||||
stickyBottom={stickyBottom}
|
||||
onScroll={this.onMessageListScroll}
|
||||
onFillRequest={this.onMessageListFillRequest}
|
||||
onUnfillRequest={this.onMessageListUnfillRequest}
|
||||
isTwelveHour={this.state.isTwelveHour}
|
||||
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
|
||||
className={this.props.className}
|
||||
tileShape={this.props.tileShape}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
getRelationsForEvent={this.getRelationsForEvent}
|
||||
editState={this.state.editState}
|
||||
showReactions={this.props.showReactions}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
84
src/components/structures/ToastContainer.js
Normal file
84
src/components/structures/ToastContainer.js
Normal file
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import dis from "../../dispatcher";
|
||||
import { _t } from '../../languageHandler';
|
||||
import classNames from "classnames";
|
||||
|
||||
export default class ToastContainer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {toasts: []};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
if (payload.action === "show_toast") {
|
||||
this._addToast(payload.toast);
|
||||
}
|
||||
};
|
||||
|
||||
_addToast(toast) {
|
||||
this.setState({toasts: this.state.toasts.concat(toast)});
|
||||
}
|
||||
|
||||
dismissTopToast = () => {
|
||||
const [, ...remaining] = this.state.toasts;
|
||||
this.setState({toasts: remaining});
|
||||
};
|
||||
|
||||
render() {
|
||||
const totalCount = this.state.toasts.length;
|
||||
const isStacked = totalCount > 1;
|
||||
let toast;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
const {title, icon, key, component, props} = topToast;
|
||||
const toastClasses = classNames("mx_Toast_toast", {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
});
|
||||
const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null;
|
||||
|
||||
const toastProps = Object.assign({}, props, {
|
||||
dismiss: this.dismissTopToast,
|
||||
key,
|
||||
});
|
||||
toast = (<div className={toastClasses}>
|
||||
<h2>{title}{countIndicator}</h2>
|
||||
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
const containerClasses = classNames("mx_ToastContainer", {
|
||||
"mx_ToastContainer_stacked": isStacked,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClasses} role="alert">
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -23,6 +24,8 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
|
|||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import Avatar from '../../Avatar';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from "../../dispatcher";
|
||||
import {focusCapturedRef} from "../../utils/Accessibility";
|
||||
|
||||
const AVATAR_SIZE = 28;
|
||||
|
||||
|
@ -37,6 +40,7 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
super();
|
||||
this.state = {
|
||||
menuDisplayed: false,
|
||||
menuFunctions: null, // should be { close: fn }
|
||||
profileInfo: null,
|
||||
};
|
||||
|
||||
|
@ -59,6 +63,8 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
}
|
||||
|
||||
async componentDidMount() {
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
try {
|
||||
const profileInfo = await this._getProfileInfo();
|
||||
this.setState({profileInfo});
|
||||
|
@ -68,6 +74,17 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
// For accessibility
|
||||
if (payload.action === "toggle_top_left_menu") {
|
||||
if (this._buttonRef) this._buttonRef.click();
|
||||
}
|
||||
};
|
||||
|
||||
_getDisplayName() {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return _t("Guest");
|
||||
|
@ -81,14 +98,23 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
render() {
|
||||
const name = this._getDisplayName();
|
||||
let nameElement;
|
||||
let chevronElement;
|
||||
if (!this.props.collapsed) {
|
||||
nameElement = <div className="mx_TopLeftMenuButton_name">
|
||||
{ name }
|
||||
</div>;
|
||||
chevronElement = <span className="mx_TopLeftMenuButton_chevron" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
|
||||
<AccessibleButton
|
||||
className="mx_TopLeftMenuButton"
|
||||
onClick={this.onToggleMenu}
|
||||
inputRef={(r) => this._buttonRef = r}
|
||||
aria-label={_t("Your profile")}
|
||||
aria-haspopup={true}
|
||||
aria-expanded={this.state.menuDisplayed}
|
||||
>
|
||||
<BaseAvatar
|
||||
idName={MatrixClientPeg.get().getUserId()}
|
||||
name={name}
|
||||
|
@ -98,7 +124,7 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
resizeMethod="crop"
|
||||
/>
|
||||
{ nameElement }
|
||||
<span className="mx_TopLeftMenuButton_chevron"></span>
|
||||
{ chevronElement }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
@ -107,20 +133,26 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.state.menuDisplayed && this.state.menuFunctions) {
|
||||
this.state.menuFunctions.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const elementRect = e.currentTarget.getBoundingClientRect();
|
||||
const x = elementRect.left;
|
||||
const y = elementRect.top + elementRect.height;
|
||||
|
||||
ContextualMenu.createMenu(TopLeftMenu, {
|
||||
const menuFunctions = ContextualMenu.createMenu(TopLeftMenu, {
|
||||
chevronFace: "none",
|
||||
left: x,
|
||||
top: y,
|
||||
userId: MatrixClientPeg.get().getUserId(),
|
||||
displayName: this._getDisplayName(),
|
||||
containerRef: focusCapturedRef, // Focus the TopLeftMenu on first render
|
||||
onFinished: () => {
|
||||
this.setState({ menuDisplayed: false });
|
||||
this.setState({ menuDisplayed: false, menuFunctions: null });
|
||||
},
|
||||
});
|
||||
this.setState({ menuDisplayed: true });
|
||||
this.setState({ menuDisplayed: true, menuFunctions });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
const React = require('react');
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
const dis = require('../../dispatcher');
|
||||
const filesize = require('filesize');
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
module.exports = React.createClass({displayName: 'UploadBar',
|
||||
module.exports = createReactClass({
|
||||
displayName: 'UploadBar',
|
||||
propTypes: {
|
||||
room: PropTypes.object,
|
||||
},
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,6 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Matrix from "matrix-js-sdk";
|
||||
import MatrixClientPeg from "../../MatrixClientPeg";
|
||||
import sdk from "../../index";
|
||||
|
@ -24,7 +26,7 @@ import { _t } from '../../languageHandler';
|
|||
export default class UserView extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
userId: React.PropTypes.string,
|
||||
userId: PropTypes.string,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -16,13 +16,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import SyntaxHighlight from '../views/elements/SyntaxHighlight';
|
||||
import {_t} from "../../languageHandler";
|
||||
import sdk from "../../index";
|
||||
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'ViewSource',
|
||||
|
||||
propTypes: {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,13 +17,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import Modal from "../../../Modal";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
import PasswordReset from "../../../PasswordReset";
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from 'classnames';
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
|
@ -36,46 +39,73 @@ const PHASE_EMAIL_SENT = 3;
|
|||
// User has clicked the link in email and completed reset
|
||||
const PHASE_DONE = 4;
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'ForgotPassword',
|
||||
|
||||
propTypes: {
|
||||
// The default server name to use when the user hasn't specified
|
||||
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
|
||||
// via `.well-known` discovery. The server name is used instead of the
|
||||
// HS URL when talking about "your account".
|
||||
defaultServerName: PropTypes.string,
|
||||
// An error passed along from higher up explaining that something
|
||||
// went wrong when finding the defaultHsUrl.
|
||||
defaultServerDiscoveryError: PropTypes.string,
|
||||
|
||||
defaultHsUrl: PropTypes.string,
|
||||
defaultIsUrl: PropTypes.string,
|
||||
customHsUrl: PropTypes.string,
|
||||
customIsUrl: PropTypes.string,
|
||||
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
onLoginClick: PropTypes.func,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl,
|
||||
enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl,
|
||||
phase: PHASE_FORGOT,
|
||||
email: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
errorText: null,
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
serverRequiresIdServer: null,
|
||||
};
|
||||
},
|
||||
|
||||
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
|
||||
componentWillMount: function() {
|
||||
this.reset = null;
|
||||
this._checkServerLiveliness(this.props.serverConfig);
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Do a liveliness check on the new URLs
|
||||
this._checkServerLiveliness(newProps.serverConfig);
|
||||
},
|
||||
|
||||
_checkServerLiveliness: async function(serverConfig) {
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
serverConfig.hsUrl,
|
||||
serverConfig.isUrl,
|
||||
);
|
||||
|
||||
const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl);
|
||||
const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam();
|
||||
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
serverRequiresIdServer,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
|
||||
}
|
||||
},
|
||||
|
||||
submitPasswordReset: function(email, password) {
|
||||
this.setState({
|
||||
phase: PHASE_SENDING_EMAIL,
|
||||
});
|
||||
this.reset = new PasswordReset(hsUrl, identityUrl);
|
||||
this.reset.resetPassword(email, password).done(() => {
|
||||
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
|
||||
this.reset.resetPassword(email, password).then(() => {
|
||||
this.setState({
|
||||
phase: PHASE_EMAIL_SENT,
|
||||
});
|
||||
|
@ -87,28 +117,25 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
onVerify: function(ev) {
|
||||
onVerify: async function(ev) {
|
||||
ev.preventDefault();
|
||||
if (!this.reset) {
|
||||
console.error("onVerify called before submitPasswordReset!");
|
||||
return;
|
||||
}
|
||||
this.reset.checkEmailLinkClicked().done((res) => {
|
||||
try {
|
||||
await this.reset.checkEmailLinkClicked();
|
||||
this.setState({ phase: PHASE_DONE });
|
||||
}, (err) => {
|
||||
} catch (err) {
|
||||
this.showErrorDialog(err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onSubmitForm: function(ev) {
|
||||
onSubmitForm: async function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
// Don't allow the user to register if there's a discovery error
|
||||
// Without this, the user could end up registering on the wrong homeserver.
|
||||
if (this.props.defaultServerDiscoveryError) {
|
||||
this.setState({errorText: this.props.defaultServerDiscoveryError});
|
||||
return;
|
||||
}
|
||||
// refresh the server errors, just in case the server came back online
|
||||
await this._checkServerLiveliness(this.props.serverConfig);
|
||||
|
||||
if (!this.state.email) {
|
||||
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
|
||||
|
@ -132,10 +159,7 @@ module.exports = React.createClass({
|
|||
button: _t('Continue'),
|
||||
onFinished: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.submitPasswordReset(
|
||||
this.state.enteredHsUrl, this.state.enteredIsUrl,
|
||||
this.state.email, this.state.password,
|
||||
);
|
||||
this.submitPasswordReset(this.state.email, this.state.password);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -148,19 +172,7 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
onServerConfigChange: function(config) {
|
||||
const newState = {};
|
||||
if (config.hsUrl !== undefined) {
|
||||
newState.enteredHsUrl = config.hsUrl;
|
||||
}
|
||||
if (config.isUrl !== undefined) {
|
||||
newState.enteredIsUrl = config.isUrl;
|
||||
}
|
||||
this.setState(newState);
|
||||
},
|
||||
|
||||
onServerDetailsNextPhaseClick(ev) {
|
||||
ev.stopPropagation();
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
this.setState({
|
||||
phase: PHASE_FORGOT,
|
||||
});
|
||||
|
@ -190,56 +202,61 @@ module.exports = React.createClass({
|
|||
|
||||
renderServerDetails() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<ServerConfig
|
||||
defaultHsUrl={this.props.defaultHsUrl}
|
||||
defaultIsUrl={this.props.defaultIsUrl}
|
||||
customHsUrl={this.state.enteredHsUrl}
|
||||
customIsUrl={this.state.enteredIsUrl}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
delayTimeMs={0} />
|
||||
<AccessibleButton className="mx_Login_submit"
|
||||
onClick={this.onServerDetailsNextPhaseClick}
|
||||
>
|
||||
{_t("Next")}
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
return <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={0}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
onAfterSubmit={this.onServerDetailsNextPhaseClick}
|
||||
submitText={_t("Next")}
|
||||
submitClass="mx_Login_submit"
|
||||
/>;
|
||||
},
|
||||
|
||||
renderForgot() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
let errorText = null;
|
||||
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
|
||||
const err = this.state.errorText;
|
||||
if (err) {
|
||||
errorText = <div className="mx_Login_error">{ err }</div>;
|
||||
}
|
||||
|
||||
let yourMatrixAccountText = _t('Your Matrix account');
|
||||
if (this.state.enteredHsUrl === this.props.defaultHsUrl && this.props.defaultServerName) {
|
||||
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.defaultServerName,
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
"mx_Login_error": true,
|
||||
"mx_Login_serverError": true,
|
||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const parsedHsUrl = new URL(this.state.enteredHsUrl);
|
||||
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
|
||||
serverName: parsedHsUrl.hostname,
|
||||
});
|
||||
} catch (e) {
|
||||
errorText = <div className="mx_Login_error">{_t(
|
||||
"The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please " +
|
||||
"enter a valid URL including the protocol prefix.",
|
||||
{
|
||||
hsUrl: this.state.enteredHsUrl,
|
||||
})}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
|
@ -252,12 +269,29 @@ module.exports = React.createClass({
|
|||
</a>;
|
||||
}
|
||||
|
||||
if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) {
|
||||
return <div>
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
{_t(
|
||||
"No identity server is configured: " +
|
||||
"add one in server settings to reset your password.",
|
||||
)}
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{_t('Sign in instead')}
|
||||
</a>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
{errorText}
|
||||
{serverDeadSection}
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
{errorText}
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
|
@ -292,7 +326,11 @@ module.exports = React.createClass({
|
|||
'A verification email will be sent to your inbox to confirm ' +
|
||||
'setting your new password.',
|
||||
)}</span>
|
||||
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
|
||||
<input
|
||||
className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Send Reset Email')}
|
||||
/>
|
||||
</form>
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{_t('Sign in instead')}
|
||||
|
|
|
@ -16,16 +16,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import {_t, _td} from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import Login from '../../../Login';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import { AutoDiscovery } from "matrix-js-sdk";
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
@ -53,25 +53,15 @@ _td("General failure");
|
|||
/**
|
||||
* A wire component which glues together login UI components and Login logic
|
||||
*/
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'Login',
|
||||
|
||||
propTypes: {
|
||||
onLoggedIn: PropTypes.func.isRequired,
|
||||
|
||||
// The default server name to use when the user hasn't specified
|
||||
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
|
||||
// via `.well-known` discovery. The server name is used instead of the
|
||||
// HS URL when talking about where to "sign in to".
|
||||
defaultServerName: PropTypes.string,
|
||||
// An error passed along from higher up explaining that something
|
||||
// went wrong when finding the defaultHsUrl.
|
||||
defaultServerDiscoveryError: PropTypes.string,
|
||||
// If true, the component will consider itself busy.
|
||||
busy: PropTypes.bool,
|
||||
|
||||
customHsUrl: PropTypes.string,
|
||||
customIsUrl: PropTypes.string,
|
||||
defaultHsUrl: PropTypes.string,
|
||||
defaultIsUrl: PropTypes.string,
|
||||
// Secondary HS which we try to log into if the user is using
|
||||
// the default HS but login fails. Useful for migrating to a
|
||||
// different homeserver without confusing users.
|
||||
|
@ -79,12 +69,13 @@ module.exports = React.createClass({
|
|||
|
||||
defaultDeviceDisplayName: PropTypes.string,
|
||||
|
||||
// login shouldn't know or care how registration is done.
|
||||
// login shouldn't know or care how registration, password recovery,
|
||||
// etc is done.
|
||||
onRegisterClick: PropTypes.func.isRequired,
|
||||
|
||||
// login shouldn't care how password recovery is done.
|
||||
onForgotPasswordClick: PropTypes.func,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -92,9 +83,7 @@ module.exports = React.createClass({
|
|||
busy: false,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
|
||||
enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl,
|
||||
enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl,
|
||||
canTryLogin: true, // can we attempt to log in or are there validation errors?
|
||||
|
||||
// used for preserving form values when changing homeserver
|
||||
username: "",
|
||||
|
@ -104,11 +93,15 @@ module.exports = React.createClass({
|
|||
// Phase of the overall login dialog.
|
||||
phase: PHASE_LOGIN,
|
||||
// The current login flow, such as password, SSO, etc.
|
||||
currentFlow: "m.login.password",
|
||||
currentFlow: null, // we need to load the flows from the server
|
||||
|
||||
// .well-known discovery
|
||||
discoveryError: "",
|
||||
findingHomeserver: false,
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -132,6 +125,14 @@ module.exports = React.createClass({
|
|||
this._unmounted = true;
|
||||
},
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Ensure that we end up actually logging in to the right place
|
||||
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
|
||||
},
|
||||
|
||||
onPasswordLoginError: function(errorText) {
|
||||
this.setState({
|
||||
errorText,
|
||||
|
@ -139,10 +140,35 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
|
||||
// Prevent people from submitting their password when homeserver
|
||||
// discovery went wrong
|
||||
if (this.state.discoveryError || this.props.defaultServerDiscoveryError) return;
|
||||
isBusy: function() {
|
||||
return this.state.busy || this.props.busy;
|
||||
},
|
||||
|
||||
onPasswordLogin: async function(username, phoneCountry, phoneNumber, password) {
|
||||
if (!this.state.serverIsAlive) {
|
||||
this.setState({busy: true});
|
||||
// Do a quick liveliness check on the URLs
|
||||
let aliveAgain = true;
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
this.props.serverConfig.hsUrl,
|
||||
this.props.serverConfig.isUrl,
|
||||
);
|
||||
this.setState({serverIsAlive: true, errorText: ""});
|
||||
} catch (e) {
|
||||
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
this.setState({
|
||||
busy: false,
|
||||
...componentState,
|
||||
});
|
||||
aliveAgain = !componentState.serverErrorIsFatal;
|
||||
}
|
||||
|
||||
// Prevent people from submitting their password when something isn't right.
|
||||
if (!aliveAgain) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
|
@ -153,6 +179,7 @@ module.exports = React.createClass({
|
|||
this._loginLogic.loginViaPassword(
|
||||
username, phoneCountry, phoneNumber, password,
|
||||
).then((data) => {
|
||||
this.setState({serverIsAlive: true}); // it must be, we logged in.
|
||||
this.props.onLoggedIn(data);
|
||||
}, (error) => {
|
||||
if (this._unmounted) {
|
||||
|
@ -164,7 +191,7 @@ module.exports = React.createClass({
|
|||
const usingEmail = username.indexOf("@") > 0;
|
||||
if (error.httpStatus === 400 && usingEmail) {
|
||||
errorText = _t('This homeserver does not support login using email address.');
|
||||
} else if (error.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
error.data.limit_type,
|
||||
error.data.admin_contact, {
|
||||
|
@ -189,16 +216,17 @@ module.exports = React.createClass({
|
|||
</div>
|
||||
);
|
||||
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
if (error.errcode === 'M_USER_DEACTIVATED') {
|
||||
errorText = _t('This account has been deactivated.');
|
||||
} else if (SdkConfig.get()['disable_custom_urls']) {
|
||||
errorText = (
|
||||
<div>
|
||||
<div>{ _t('Incorrect username and/or password.') }</div>
|
||||
<div className="mx_Login_smallError">
|
||||
{ _t('Please note you are logging into the %(hs)s server, not matrix.org.',
|
||||
{
|
||||
hs: this.props.defaultHsUrl.replace(/^https?:\/\//, ''),
|
||||
})
|
||||
}
|
||||
{_t(
|
||||
'Please note you are logging into the %(hs)s server, not matrix.org.',
|
||||
{hs: this.props.serverConfig.hsName},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -225,28 +253,56 @@ module.exports = React.createClass({
|
|||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}).done();
|
||||
});
|
||||
},
|
||||
|
||||
onUsernameChanged: function(username) {
|
||||
this.setState({ username: username });
|
||||
},
|
||||
|
||||
onUsernameBlur: function(username) {
|
||||
onUsernameBlur: async function(username) {
|
||||
const doWellknownLookup = username[0] === "@";
|
||||
this.setState({
|
||||
username: username,
|
||||
discoveryError: null,
|
||||
busy: doWellknownLookup,
|
||||
errorText: null,
|
||||
canTryLogin: true,
|
||||
});
|
||||
if (username[0] === "@") {
|
||||
if (doWellknownLookup) {
|
||||
const serverName = username.split(':').slice(1).join(':');
|
||||
try {
|
||||
// we have to append 'https://' to make the URL constructor happy
|
||||
// otherwise we get things like 'protocol: matrix.org, pathname: 8448'
|
||||
const url = new URL("https://" + serverName);
|
||||
this._tryWellKnownDiscovery(url.hostname);
|
||||
const result = await AutoDiscoveryUtils.validateServerName(serverName);
|
||||
this.props.onServerConfigChange(result);
|
||||
// We'd like to rely on new props coming in via `onServerConfigChange`
|
||||
// so that we know the servers have definitely updated before clearing
|
||||
// the busy state. In the case of a full MXID that resolves to the same
|
||||
// HS as Riot's default HS though, there may not be any server change.
|
||||
// To avoid this trap, we clear busy here. For cases where the server
|
||||
// actually has changed, `_initLoginLogic` will be called and manages
|
||||
// busy state for its own liveness check.
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
|
||||
this.setState({discoveryError: _t("Failed to perform homeserver discovery")});
|
||||
|
||||
let message = _t("Failed to perform homeserver discovery");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
|
||||
let errorText = message;
|
||||
let discoveryState = {};
|
||||
if (AutoDiscoveryUtils.isLivelinessError(e)) {
|
||||
errorText = this.state.errorText;
|
||||
discoveryState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText,
|
||||
...discoveryState,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -262,44 +318,27 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
onPhoneNumberBlur: function(phoneNumber) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
|
||||
// Validate the phone number entered
|
||||
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
|
||||
this.setState({
|
||||
errorText: _t('The phone number entered looks invalid'),
|
||||
canTryLogin: false,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
canTryLogin: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onServerConfigChange: function(config) {
|
||||
const self = this;
|
||||
const newState = {
|
||||
errorText: null, // reset err messages
|
||||
};
|
||||
if (config.hsUrl !== undefined) {
|
||||
newState.enteredHsUrl = config.hsUrl;
|
||||
}
|
||||
if (config.isUrl !== undefined) {
|
||||
newState.enteredIsUrl = config.isUrl;
|
||||
}
|
||||
|
||||
this.props.onServerConfigChange(config);
|
||||
this.setState(newState, function() {
|
||||
self._initLoginLogic(config.hsUrl || null, config.isUrl);
|
||||
});
|
||||
},
|
||||
|
||||
onRegisterClick: function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onRegisterClick();
|
||||
},
|
||||
|
||||
onServerDetailsNextPhaseClick(ev) {
|
||||
ev.stopPropagation();
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
this.setState({
|
||||
phase: PHASE_LOGIN,
|
||||
});
|
||||
|
@ -313,64 +352,18 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_tryWellKnownDiscovery: async function(serverName) {
|
||||
if (!serverName.trim()) {
|
||||
// Nothing to discover
|
||||
this.setState({
|
||||
discoveryError: "",
|
||||
findingHomeserver: false,
|
||||
});
|
||||
return;
|
||||
_initLoginLogic: async function(hsUrl, isUrl) {
|
||||
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
|
||||
isUrl = isUrl || this.props.serverConfig.isUrl;
|
||||
|
||||
let isDefaultServer = false;
|
||||
if (this.props.serverConfig.isDefault
|
||||
&& hsUrl === this.props.serverConfig.hsUrl
|
||||
&& isUrl === this.props.serverConfig.isUrl) {
|
||||
isDefaultServer = true;
|
||||
}
|
||||
|
||||
this.setState({findingHomeserver: true});
|
||||
try {
|
||||
const discovery = await AutoDiscovery.findClientConfig(serverName);
|
||||
|
||||
const state = discovery["m.homeserver"].state;
|
||||
if (state !== AutoDiscovery.SUCCESS && state !== AutoDiscovery.PROMPT) {
|
||||
this.setState({
|
||||
discoveryError: discovery["m.homeserver"].error,
|
||||
findingHomeserver: false,
|
||||
});
|
||||
} else if (state === AutoDiscovery.PROMPT) {
|
||||
this.setState({
|
||||
discoveryError: "",
|
||||
findingHomeserver: false,
|
||||
});
|
||||
} else if (state === AutoDiscovery.SUCCESS) {
|
||||
this.setState({
|
||||
discoveryError: "",
|
||||
findingHomeserver: false,
|
||||
});
|
||||
this.onServerConfigChange({
|
||||
hsUrl: discovery["m.homeserver"].base_url,
|
||||
isUrl: discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
|
||||
? discovery["m.identity_server"].base_url
|
||||
: "",
|
||||
});
|
||||
} else {
|
||||
console.warn("Unknown state for m.homeserver in discovery response: ", discovery);
|
||||
this.setState({
|
||||
discoveryError: _t("Unknown failure discovering homeserver"),
|
||||
findingHomeserver: false,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.setState({
|
||||
findingHomeserver: false,
|
||||
discoveryError: _t("Unknown error discovering homeserver"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_initLoginLogic: function(hsUrl, isUrl) {
|
||||
const self = this;
|
||||
hsUrl = hsUrl || this.state.enteredHsUrl;
|
||||
isUrl = isUrl || this.state.enteredIsUrl;
|
||||
|
||||
const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
|
||||
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl : null;
|
||||
|
||||
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
|
||||
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
||||
|
@ -378,12 +371,40 @@ module.exports = React.createClass({
|
|||
this._loginLogic = loginLogic;
|
||||
|
||||
this.setState({
|
||||
enteredHsUrl: hsUrl,
|
||||
enteredIsUrl: isUrl,
|
||||
busy: true,
|
||||
currentFlow: null, // reset flow
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
// Do a quick liveliness check on the URLs
|
||||
try {
|
||||
const { warning } =
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
if (warning) {
|
||||
this.setState({
|
||||
...AutoDiscoveryUtils.authComponentStateForError(warning),
|
||||
errorText: "",
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
errorText: "",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e),
|
||||
});
|
||||
if (this.state.serverErrorIsFatal) {
|
||||
// Server is dead: show server details prompt instead
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loginLogic.getFlows().then((flows) => {
|
||||
// look for a flow where we understand all of the steps.
|
||||
for (let i = 0; i < flows.length; i++ ) {
|
||||
|
@ -408,16 +429,17 @@ module.exports = React.createClass({
|
|||
"supported by this client.",
|
||||
),
|
||||
});
|
||||
}, function(err) {
|
||||
self.setState({
|
||||
errorText: self._errorTextFromError(err),
|
||||
}, (err) => {
|
||||
this.setState({
|
||||
errorText: this._errorTextFromError(err),
|
||||
loginIncorrect: false,
|
||||
canTryLogin: false,
|
||||
});
|
||||
}).finally(function() {
|
||||
self.setState({
|
||||
}).finally(() => {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}).done();
|
||||
});
|
||||
},
|
||||
|
||||
_isSupportedFlow: function(flow) {
|
||||
|
@ -445,8 +467,8 @@ module.exports = React.createClass({
|
|||
|
||||
if (err.cors === 'rejected') {
|
||||
if (window.location.protocol === 'https:' &&
|
||||
(this.state.enteredHsUrl.startsWith("http:") ||
|
||||
!this.state.enteredHsUrl.startsWith("http"))
|
||||
(this.props.serverConfig.hsUrl.startsWith("http:") ||
|
||||
!this.props.serverConfig.hsUrl.startsWith("http"))
|
||||
) {
|
||||
errorText = <span>
|
||||
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
|
||||
|
@ -469,9 +491,9 @@ module.exports = React.createClass({
|
|||
"is not blocking requests.", {},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a target="_blank" rel="noopener"
|
||||
href={this.state.enteredHsUrl}
|
||||
>{ sub }</a>;
|
||||
return <a target="_blank" rel="noopener" href={this.props.serverConfig.hsUrl}>
|
||||
{ sub }
|
||||
</a>;
|
||||
},
|
||||
},
|
||||
) }
|
||||
|
@ -484,7 +506,6 @@ module.exports = React.createClass({
|
|||
|
||||
renderServerComponent() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
|
@ -494,28 +515,19 @@ module.exports = React.createClass({
|
|||
return null;
|
||||
}
|
||||
|
||||
const serverDetails = <ServerConfig
|
||||
customHsUrl={this.state.enteredHsUrl}
|
||||
customIsUrl={this.state.enteredIsUrl}
|
||||
defaultHsUrl={this.props.defaultHsUrl}
|
||||
defaultIsUrl={this.props.defaultIsUrl}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
/>;
|
||||
|
||||
let nextButton = null;
|
||||
const serverDetailsProps = {};
|
||||
if (PHASES_ENABLED) {
|
||||
nextButton = <AccessibleButton className="mx_Login_submit"
|
||||
onClick={this.onServerDetailsNextPhaseClick}
|
||||
>
|
||||
{_t("Next")}
|
||||
</AccessibleButton>;
|
||||
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
|
||||
serverDetailsProps.submitText = _t("Next");
|
||||
serverDetailsProps.submitClass = "mx_Login_submit";
|
||||
}
|
||||
|
||||
return <div>
|
||||
{serverDetails}
|
||||
{nextButton}
|
||||
</div>;
|
||||
return <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderLoginComponentForStep() {
|
||||
|
@ -547,13 +559,6 @@ module.exports = React.createClass({
|
|||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
|
||||
// If the current HS URL is the default HS URL, then we can label it
|
||||
// with the default HS name (if it exists).
|
||||
let hsName;
|
||||
if (this.state.enteredHsUrl === this.props.defaultHsUrl) {
|
||||
hsName = this.props.defaultServerName;
|
||||
}
|
||||
|
||||
return (
|
||||
<PasswordLogin
|
||||
onSubmit={this.onPasswordLogin}
|
||||
|
@ -569,14 +574,20 @@ module.exports = React.createClass({
|
|||
onPhoneNumberBlur={this.onPhoneNumberBlur}
|
||||
onForgotPasswordClick={this.props.onForgotPasswordClick}
|
||||
loginIncorrect={this.state.loginIncorrect}
|
||||
hsName={hsName}
|
||||
hsUrl={this.state.enteredHsUrl}
|
||||
disableSubmit={this.state.findingHomeserver}
|
||||
/>
|
||||
serverConfig={this.props.serverConfig}
|
||||
disableSubmit={this.isBusy()}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
_renderSsoStep: function(url) {
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
|
||||
// redirecting the user back to a URI once they're logged in. On the web, this means
|
||||
// we use the same window and redirect back to riot. On electron, this actually
|
||||
|
@ -586,7 +597,12 @@ module.exports = React.createClass({
|
|||
// user's browser, let them log into their SSO provider, then redirect their browser
|
||||
// to vector://vector which, of course, will not work.
|
||||
return (
|
||||
<a href={url} className="mx_Login_sso_link mx_Login_submit">{ _t('Sign in with single sign-on') }</a>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
|
@ -595,9 +611,9 @@ module.exports = React.createClass({
|
|||
const AuthPage = sdk.getComponent("auth.AuthPage");
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
|
||||
const loader = this.isBusy() ? <div className="mx_Login_loader"><Loader /></div> : null;
|
||||
|
||||
const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText;
|
||||
const errorText = this.state.errorText;
|
||||
|
||||
let errorTextSection;
|
||||
if (errorText) {
|
||||
|
@ -608,6 +624,20 @@ module.exports = React.createClass({
|
|||
);
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
"mx_Login_error": true,
|
||||
"mx_Login_serverError": true,
|
||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
|
@ -617,6 +647,7 @@ module.exports = React.createClass({
|
|||
{loader}
|
||||
</h2>
|
||||
{ errorTextSection }
|
||||
{ serverDeadSection }
|
||||
{ this.renderServerComponent() }
|
||||
{ this.renderLoginComponentForStep() }
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">
|
||||
|
|
|
@ -14,15 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'PostRegistration',
|
||||
|
||||
propTypes: {
|
||||
|
@ -44,7 +43,7 @@ module.exports = React.createClass({
|
|||
const cli = MatrixClientPeg.get();
|
||||
this.setState({busy: true});
|
||||
const self = this;
|
||||
cli.getProfileInfo(cli.credentials.userId).done(function(result) {
|
||||
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
|
||||
self.setState({
|
||||
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
|
||||
busy: false,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,16 +18,18 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
|
||||
import Promise from 'bluebird';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import sdk from '../../../index';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
|
@ -37,7 +40,7 @@ const PHASE_REGISTRATION = 1;
|
|||
// Enable phases for registration
|
||||
const PHASES_ENABLED = true;
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'Registration',
|
||||
|
||||
propTypes: {
|
||||
|
@ -46,18 +49,7 @@ module.exports = React.createClass({
|
|||
sessionId: PropTypes.string,
|
||||
makeRegistrationUrl: PropTypes.func.isRequired,
|
||||
idSid: PropTypes.string,
|
||||
// The default server name to use when the user hasn't specified
|
||||
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
|
||||
// via `.well-known` discovery. The server name is used instead of the
|
||||
// HS URL when talking about "your account".
|
||||
defaultServerName: PropTypes.string,
|
||||
// An error passed along from higher up explaining that something
|
||||
// went wrong when finding the defaultHsUrl.
|
||||
defaultServerDiscoveryError: PropTypes.string,
|
||||
customHsUrl: PropTypes.string,
|
||||
customIsUrl: PropTypes.string,
|
||||
defaultHsUrl: PropTypes.string,
|
||||
defaultIsUrl: PropTypes.string,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
brand: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
// registration shouldn't know or care how login is done.
|
||||
|
@ -66,7 +58,7 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
getInitialState: function() {
|
||||
const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl);
|
||||
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
|
@ -87,11 +79,34 @@ module.exports = React.createClass({
|
|||
// straight back into UI auth
|
||||
doingUIAuth: Boolean(this.props.sessionId),
|
||||
serverType,
|
||||
hsUrl: this.props.customHsUrl,
|
||||
isUrl: this.props.customIsUrl,
|
||||
// Phase of the overall registration dialog.
|
||||
phase: PHASE_REGISTRATION,
|
||||
flows: null,
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: false,
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient: null,
|
||||
|
||||
// whether the HS requires an ID server to register with a threepid
|
||||
serverRequiresIdServer: null,
|
||||
|
||||
// The user ID we've just registered
|
||||
registeredUsername: null,
|
||||
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId: null,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -100,18 +115,22 @@ module.exports = React.createClass({
|
|||
this._replaceClient();
|
||||
},
|
||||
|
||||
onServerConfigChange: function(config) {
|
||||
const newState = {};
|
||||
if (config.hsUrl !== undefined) {
|
||||
newState.hsUrl = config.hsUrl;
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
this._replaceClient(newProps.serverConfig);
|
||||
|
||||
// Handle cases where the user enters "https://matrix.org" for their server
|
||||
// from the advanced option - we should default to FREE at that point.
|
||||
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
|
||||
if (serverType !== this.state.serverType) {
|
||||
// Reset the phase to default phase for the server type.
|
||||
this.setState({
|
||||
serverType,
|
||||
phase: this.getDefaultPhaseForServerType(serverType),
|
||||
});
|
||||
}
|
||||
if (config.isUrl !== undefined) {
|
||||
newState.isUrl = config.isUrl;
|
||||
}
|
||||
this.props.onServerConfigChange(config);
|
||||
this.setState(newState, () => {
|
||||
this._replaceClient();
|
||||
});
|
||||
},
|
||||
|
||||
getDefaultPhaseForServerType(type) {
|
||||
|
@ -136,19 +155,17 @@ module.exports = React.createClass({
|
|||
// the new type.
|
||||
switch (type) {
|
||||
case ServerType.FREE: {
|
||||
const { hsUrl, isUrl } = ServerType.TYPES.FREE;
|
||||
this.onServerConfigChange({
|
||||
hsUrl,
|
||||
isUrl,
|
||||
});
|
||||
const { serverConfig } = ServerType.TYPES.FREE;
|
||||
this.props.onServerConfigChange(serverConfig);
|
||||
break;
|
||||
}
|
||||
case ServerType.PREMIUM:
|
||||
// We can accept whatever server config was the default here as this essentially
|
||||
// acts as a slightly different "custom server"/ADVANCED option.
|
||||
break;
|
||||
case ServerType.ADVANCED:
|
||||
this.onServerConfigChange({
|
||||
hsUrl: this.props.defaultHsUrl,
|
||||
isUrl: this.props.defaultIsUrl,
|
||||
});
|
||||
// Use the default config from the config
|
||||
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -158,13 +175,54 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_replaceClient: async function() {
|
||||
_replaceClient: async function(serverConfig) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
serverDeadError: null,
|
||||
serverErrorIsFatal: false,
|
||||
// busy while we do liveness check (we need to avoid trying to render
|
||||
// the UI auth component while we don't have a matrix client)
|
||||
busy: true,
|
||||
});
|
||||
this._matrixClient = Matrix.createClient({
|
||||
baseUrl: this.state.hsUrl,
|
||||
idBaseUrl: this.state.isUrl,
|
||||
if (!serverConfig) serverConfig = this.props.serverConfig;
|
||||
|
||||
// Do a liveliness check on the URLs
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
serverConfig.hsUrl,
|
||||
serverConfig.isUrl,
|
||||
);
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e, "register"),
|
||||
});
|
||||
if (this.state.serverErrorIsFatal) {
|
||||
return; // Server is dead - do not continue.
|
||||
}
|
||||
}
|
||||
|
||||
const {hsUrl, isUrl} = serverConfig;
|
||||
const cli = Matrix.createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
});
|
||||
|
||||
let serverRequiresIdServer = true;
|
||||
try {
|
||||
serverRequiresIdServer = await cli.doesServerRequireIdServerParam();
|
||||
} catch (e) {
|
||||
console.log("Unable to determine is server needs id_server param", e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
matrixClient: cli,
|
||||
serverRequiresIdServer,
|
||||
busy: false,
|
||||
});
|
||||
try {
|
||||
await this._makeRegisterRequest({});
|
||||
|
@ -179,22 +237,21 @@ module.exports = React.createClass({
|
|||
} 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: [],
|
||||
});
|
||||
} 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: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onFormSubmit: function(formVals) {
|
||||
// Don't allow the user to register if there's a discovery error
|
||||
// Without this, the user could end up registering on the wrong homeserver.
|
||||
if (this.props.defaultServerDiscoveryError) {
|
||||
this.setState({errorText: this.props.defaultServerDiscoveryError});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
|
@ -203,11 +260,25 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) {
|
||||
return this.state.matrixClient.requestRegisterEmailToken(
|
||||
emailAddress,
|
||||
clientSecret,
|
||||
sendAttempt,
|
||||
this.props.makeRegistrationUrl({
|
||||
client_secret: clientSecret,
|
||||
hs_url: this.state.matrixClient.getHomeserverUrl(),
|
||||
is_url: this.state.matrixClient.getIdentityServerUrl(),
|
||||
session_id: sessionId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
_onUIAuthFinished: async function(success, response, extra) {
|
||||
if (!success) {
|
||||
let msg = response.message || response.toString();
|
||||
// can we give a better error message?
|
||||
if (response.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact, {
|
||||
|
@ -246,21 +317,47 @@ module.exports = React.createClass({
|
|||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
busy: true,
|
||||
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
|
||||
|
||||
const newState = {
|
||||
doingUIAuth: false,
|
||||
});
|
||||
registeredUsername: response.user_id,
|
||||
};
|
||||
|
||||
const cli = await this.props.onLoggedIn({
|
||||
userId: response.user_id,
|
||||
deviceId: response.device_id,
|
||||
homeserverUrl: this._matrixClient.getHomeserverUrl(),
|
||||
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
|
||||
accessToken: response.access_token,
|
||||
});
|
||||
// The user came in through an email validation link. To avoid overwriting
|
||||
// their session, check to make sure the session isn't someone else, and
|
||||
// isn't a guest user since we'll usually have set a guest user session before
|
||||
// starting the registration process. This isn't perfect since it's possible
|
||||
// the user had a separate guest session they didn't actually mean to replace.
|
||||
const sessionOwner = Lifecycle.getStoredSessionOwner();
|
||||
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
|
||||
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
|
||||
console.log(
|
||||
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
|
||||
);
|
||||
newState.differentLoggedInUserId = sessionOwner;
|
||||
} else {
|
||||
newState.differentLoggedInUserId = null;
|
||||
}
|
||||
|
||||
this._setupPushers(cli);
|
||||
if (response.access_token) {
|
||||
const cli = await this.props.onLoggedIn({
|
||||
userId: response.user_id,
|
||||
deviceId: response.device_id,
|
||||
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
|
||||
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
|
||||
accessToken: response.access_token,
|
||||
});
|
||||
|
||||
this._setupPushers(cli);
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
newState.busy = true;
|
||||
} else {
|
||||
newState.busy = false;
|
||||
newState.completedNoSignin = true;
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
},
|
||||
|
||||
_setupPushers: function(matrixClient) {
|
||||
|
@ -273,7 +370,7 @@ module.exports = React.createClass({
|
|||
if (pushers[i].kind === 'email') {
|
||||
const emailPusher = pushers[i];
|
||||
emailPusher.data = { brand: this.props.brand };
|
||||
matrixClient.setPusher(emailPusher).done(() => {
|
||||
matrixClient.setPusher(emailPusher).then(() => {
|
||||
console.log("Set email branding to " + this.props.brand);
|
||||
}, (error) => {
|
||||
console.error("Couldn't set email branding: " + error);
|
||||
|
@ -302,8 +399,7 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
onServerDetailsNextPhaseClick(ev) {
|
||||
ev.stopPropagation();
|
||||
async onServerDetailsNextPhaseClick() {
|
||||
this.setState({
|
||||
phase: PHASE_REGISTRATION,
|
||||
});
|
||||
|
@ -318,21 +414,25 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
_makeRegisterRequest: function(auth) {
|
||||
// Only send the bind params if we're sending username / pw params
|
||||
// We inhibit login if we're trying to register with an email address: this
|
||||
// avoids a lot of complex race conditions that can occur if we try to log
|
||||
// the user in one one or both of the tabs they might end up with after
|
||||
// clicking the email link.
|
||||
let inhibitLogin = Boolean(this.state.formVals.email);
|
||||
|
||||
// Only send inhibitLogin if we're sending username / pw params
|
||||
// (Since we need to send no params at all to use the ones saved in the
|
||||
// session).
|
||||
const bindThreepids = this.state.formVals.password ? {
|
||||
email: true,
|
||||
msisdn: true,
|
||||
} : {};
|
||||
if (!this.state.formVals.password) inhibitLogin = null;
|
||||
|
||||
return this._matrixClient.register(
|
||||
return this.state.matrixClient.register(
|
||||
this.state.formVals.username,
|
||||
this.state.formVals.password,
|
||||
undefined, // session id: included in the auth dict already
|
||||
auth,
|
||||
bindThreepids,
|
||||
null,
|
||||
null,
|
||||
inhibitLogin,
|
||||
);
|
||||
},
|
||||
|
||||
|
@ -344,11 +444,23 @@ module.exports = React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
// Links to the login page shown after registration is completed are routed through this
|
||||
// which checks the user hasn't already logged in somewhere else (perhaps we should do
|
||||
// this more generally?)
|
||||
_onLoginClickWithCheck: async function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
|
||||
if (!sessionLoaded) {
|
||||
// ok fine, there's still no session: really go to the login page
|
||||
this.props.onLoginClick();
|
||||
}
|
||||
},
|
||||
|
||||
renderServerComponent() {
|
||||
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
|
@ -356,7 +468,9 @@ module.exports = React.createClass({
|
|||
|
||||
// If we're on a different phase, we only show the server type selector,
|
||||
// which is always shown if we allow custom URLs at all.
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
|
||||
// (if there's a fatal server error, we need to show the full server
|
||||
// config as the user may need to change servers to resolve the error).
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
|
||||
return <div>
|
||||
<ServerTypeSelector
|
||||
selected={this.state.serverType}
|
||||
|
@ -365,47 +479,42 @@ module.exports = React.createClass({
|
|||
</div>;
|
||||
}
|
||||
|
||||
const serverDetailsProps = {};
|
||||
if (PHASES_ENABLED) {
|
||||
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
|
||||
serverDetailsProps.submitText = _t("Next");
|
||||
serverDetailsProps.submitClass = "mx_Login_submit";
|
||||
}
|
||||
|
||||
let serverDetails = null;
|
||||
switch (this.state.serverType) {
|
||||
case ServerType.FREE:
|
||||
break;
|
||||
case ServerType.PREMIUM:
|
||||
serverDetails = <ModularServerConfig
|
||||
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
|
||||
defaultHsUrl={this.props.defaultHsUrl}
|
||||
defaultIsUrl={this.props.defaultIsUrl}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
break;
|
||||
case ServerType.ADVANCED:
|
||||
serverDetails = <ServerConfig
|
||||
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
|
||||
customIsUrl={this.state.discoveredIsUrl || this.props.customIsUrl}
|
||||
defaultHsUrl={this.props.defaultHsUrl}
|
||||
defaultIsUrl={this.props.defaultIsUrl}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
break;
|
||||
}
|
||||
|
||||
let nextButton = null;
|
||||
if (PHASES_ENABLED) {
|
||||
nextButton = <AccessibleButton className="mx_Login_submit"
|
||||
onClick={this.onServerDetailsNextPhaseClick}
|
||||
>
|
||||
{_t("Next")}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<ServerTypeSelector
|
||||
selected={this.state.serverType}
|
||||
onChange={this.onServerTypeChange}
|
||||
/>
|
||||
{serverDetails}
|
||||
{nextButton}
|
||||
</div>;
|
||||
},
|
||||
|
||||
|
@ -418,23 +527,25 @@ module.exports = React.createClass({
|
|||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
|
||||
|
||||
if (this.state.doingUIAuth) {
|
||||
if (this.state.matrixClient && this.state.doingUIAuth) {
|
||||
return <InteractiveAuth
|
||||
matrixClient={this._matrixClient}
|
||||
matrixClient={this.state.matrixClient}
|
||||
makeRequest={this._makeRegisterRequest}
|
||||
onAuthFinished={this._onUIAuthFinished}
|
||||
inputs={this._getUIAuthInputs()}
|
||||
makeRegistrationUrl={this.props.makeRegistrationUrl}
|
||||
requestEmailToken={this._requestEmailToken}
|
||||
sessionId={this.props.sessionId}
|
||||
clientSecret={this.props.clientSecret}
|
||||
emailSid={this.props.idSid}
|
||||
poll={true}
|
||||
/>;
|
||||
} else if (!this.state.matrixClient && !this.state.busy) {
|
||||
return null;
|
||||
} else if (this.state.busy || !this.state.flows) {
|
||||
return <div className="mx_AuthBody_spinner">
|
||||
<Spinner />
|
||||
</div>;
|
||||
} else {
|
||||
} else if (this.state.flows.length) {
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed and we haven't selected the Free server type, wire
|
||||
// up the server details edit link.
|
||||
|
@ -446,13 +557,6 @@ module.exports = React.createClass({
|
|||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
|
||||
// If the current HS URL is the default HS URL, then we can label it
|
||||
// with the default HS name (if it exists).
|
||||
let hsName;
|
||||
if (this.state.hsUrl === this.props.defaultHsUrl) {
|
||||
hsName = this.props.defaultServerName;
|
||||
}
|
||||
|
||||
return <RegistrationForm
|
||||
defaultUsername={this.state.formVals.username}
|
||||
defaultEmail={this.state.formVals.email}
|
||||
|
@ -462,8 +566,9 @@ module.exports = React.createClass({
|
|||
onRegisterClick={this.onFormSubmit}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick}
|
||||
flows={this.state.flows}
|
||||
hsName={hsName}
|
||||
hsUrl={this.state.hsUrl}
|
||||
serverConfig={this.props.serverConfig}
|
||||
canSubmit={!this.state.serverErrorIsFatal}
|
||||
serverRequiresIdServer={this.state.serverRequiresIdServer}
|
||||
/>;
|
||||
}
|
||||
},
|
||||
|
@ -472,13 +577,28 @@ module.exports = React.createClass({
|
|||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const AuthPage = sdk.getComponent('auth.AuthPage');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let errorText;
|
||||
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
|
||||
const err = this.state.errorText;
|
||||
if (err) {
|
||||
errorText = <div className="mx_Login_error">{ err }</div>;
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
"mx_Login_error": true,
|
||||
"mx_Login_serverError": true,
|
||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{ _t('Sign in instead') }
|
||||
</a>;
|
||||
|
@ -491,16 +611,62 @@ module.exports = React.createClass({
|
|||
</a>;
|
||||
}
|
||||
|
||||
let body;
|
||||
if (this.state.completedNoSignin) {
|
||||
let regDoneText;
|
||||
if (this.state.differentLoggedInUserId) {
|
||||
regDoneText = <div>
|
||||
<p>{_t(
|
||||
"Your new account (%(newAccountId)s) is registered, but you're already " +
|
||||
"logged into a different account (%(loggedInUserId)s).", {
|
||||
newAccountId: this.state.registeredUsername,
|
||||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
},
|
||||
)}</p>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
|
||||
{_t("Continue with previous account")}
|
||||
</AccessibleButton></p>
|
||||
</div>;
|
||||
} else if (this.state.formVals.password) {
|
||||
// We're the client that started the registration
|
||||
regDoneText = <h3>{_t(
|
||||
"<a>Log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
} else {
|
||||
// We're not the original client: the user probably got to us by clicking the
|
||||
// email validation link. We can't offer a 'go straight to your account' link
|
||||
// as we don't have the original creds.
|
||||
regDoneText = <h3>{_t(
|
||||
"You can now close this window or <a>log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
}
|
||||
body = <div>
|
||||
<h2>{_t("Registration Successful")}</h2>
|
||||
{ regDoneText }
|
||||
</div>;
|
||||
} else {
|
||||
body = <div>
|
||||
<h2>{ _t('Create your account') }</h2>
|
||||
{ errorText }
|
||||
{ serverDeadSection }
|
||||
{ this.renderServerComponent() }
|
||||
{ this.renderRegisterComponent() }
|
||||
{ goBack }
|
||||
{ signIn }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<h2>{ _t('Create your account') }</h2>
|
||||
{ errorText }
|
||||
{ this.renderServerComponent() }
|
||||
{ this.renderRegisterComponent() }
|
||||
{ goBack }
|
||||
{ signIn }
|
||||
{ body }
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
|
|
322
src/components/structures/auth/SoftLogout.js
Normal file
322
src/components/structures/auth/SoftLogout.js
Normal file
|
@ -0,0 +1,322 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import dis from '../../../dispatcher';
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import Modal from '../../../Modal';
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import {sendLoginRequest} from "../../../Login";
|
||||
import url from 'url';
|
||||
|
||||
const LOGIN_VIEW = {
|
||||
LOADING: 1,
|
||||
PASSWORD: 2,
|
||||
CAS: 3, // SSO, but old
|
||||
SSO: 4,
|
||||
UNSUPPORTED: 5,
|
||||
};
|
||||
|
||||
const FLOWS_TO_VIEWS = {
|
||||
"m.login.password": LOGIN_VIEW.PASSWORD,
|
||||
"m.login.cas": LOGIN_VIEW.CAS,
|
||||
"m.login.sso": LOGIN_VIEW.SSO,
|
||||
};
|
||||
|
||||
export default class SoftLogout extends React.Component {
|
||||
static propTypes = {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: PropTypes.object, // {homeserver, identityServer, loginToken}
|
||||
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
loginView: LOGIN_VIEW.LOADING,
|
||||
keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount)
|
||||
ssoUrl: null,
|
||||
|
||||
busy: false,
|
||||
password: "",
|
||||
errorText: "",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
// We've ended up here when we don't need to - navigate to login
|
||||
if (!Lifecycle.isSoftLogout()) {
|
||||
dis.dispatch({action: "on_logged_in"});
|
||||
return;
|
||||
}
|
||||
|
||||
this._initLogin();
|
||||
|
||||
MatrixClientPeg.get().flagAllGroupSessionsForBackup().then(remaining => {
|
||||
this.setState({keyBackupNeeded: remaining > 0});
|
||||
});
|
||||
}
|
||||
|
||||
onClearAll = () => {
|
||||
const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog');
|
||||
Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, {
|
||||
onFinished: (wipeData) => {
|
||||
if (!wipeData) return;
|
||||
|
||||
console.log("Clearing data from soft-logged-out device");
|
||||
Lifecycle.logout();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async _initLogin() {
|
||||
const queryParams = this.props.realQueryParams;
|
||||
const hasAllParams = queryParams && queryParams['homeserver'] && queryParams['loginToken'];
|
||||
if (hasAllParams) {
|
||||
this.setState({loginView: LOGIN_VIEW.LOADING});
|
||||
this.trySsoLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
|
||||
// care about login flows here, unless it is the single flow we support.
|
||||
const client = MatrixClientPeg.get();
|
||||
const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]);
|
||||
|
||||
const 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) => {
|
||||
this.setState({password: ev.target.value});
|
||||
};
|
||||
|
||||
onForgotPassword = () => {
|
||||
dis.dispatch({action: 'start_password_recovery'});
|
||||
};
|
||||
|
||||
onPasswordLogin = async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.setState({busy: true});
|
||||
|
||||
const hsUrl = MatrixClientPeg.get().getHomeserverUrl();
|
||||
const isUrl = MatrixClientPeg.get().getIdentityServerUrl();
|
||||
const loginType = "m.login.password";
|
||||
const loginParams = {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: MatrixClientPeg.get().getUserId(),
|
||||
},
|
||||
password: this.state.password,
|
||||
device_id: MatrixClientPeg.get().getDeviceId(),
|
||||
};
|
||||
|
||||
let credentials = null;
|
||||
try {
|
||||
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
|
||||
} catch (e) {
|
||||
let errorText = _t("Failed to re-authenticate due to a homeserver problem");
|
||||
if (e.errcode === "M_FORBIDDEN" && (e.httpStatus === 401 || e.httpStatus === 403)) {
|
||||
errorText = _t("Incorrect password");
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: errorText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Lifecycle.hydrateSession(credentials).catch((e) => {
|
||||
console.error(e);
|
||||
this.setState({busy: false, errorText: _t("Failed to re-authenticate")});
|
||||
});
|
||||
};
|
||||
|
||||
async trySsoLogin() {
|
||||
this.setState({busy: true});
|
||||
|
||||
const hsUrl = this.props.realQueryParams['homeserver'];
|
||||
const isUrl = this.props.realQueryParams['identityServer'] || MatrixClientPeg.get().getIdentityServerUrl();
|
||||
const loginType = "m.login.token";
|
||||
const loginParams = {
|
||||
token: this.props.realQueryParams['loginToken'],
|
||||
device_id: MatrixClientPeg.get().getDeviceId(),
|
||||
};
|
||||
|
||||
let credentials = null;
|
||||
try {
|
||||
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
|
||||
return;
|
||||
}
|
||||
|
||||
Lifecycle.hydrateSession(credentials).then(() => {
|
||||
if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted();
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
let introText = null; // null is translated to something area specific in this function
|
||||
if (this.state.keyBackupNeeded) {
|
||||
introText = _t(
|
||||
"Regain access to your account and recover encryption keys stored on this device. " +
|
||||
"Without them, you won’t be able to read all of your secure messages on any device.");
|
||||
}
|
||||
|
||||
if (this.state.loginView === LOGIN_VIEW.PASSWORD) {
|
||||
const Field = sdk.getComponent("elements.Field");
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = <span className='mx_Login_error'>{this.state.errorText}</span>;
|
||||
}
|
||||
|
||||
if (!introText) {
|
||||
introText = _t("Enter your password to sign in and regain access to your account.");
|
||||
} // else we already have a message and should use it (key backup warning)
|
||||
|
||||
return (
|
||||
<form onSubmit={this.onPasswordLogin}>
|
||||
<p>{introText}</p>
|
||||
{error}
|
||||
<Field
|
||||
id="softlogout_password"
|
||||
type="password"
|
||||
label={_t("Password")}
|
||||
onChange={this.onPasswordChange}
|
||||
value={this.state.password}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
<AccessibleButton
|
||||
onClick={this.onPasswordLogin}
|
||||
kind="primary"
|
||||
type="submit"
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("Sign In")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this.onForgotPassword} kind="link">
|
||||
{_t("Forgotten your password?")}
|
||||
</AccessibleButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{introText}</p>
|
||||
<AccessibleButton kind='primary' onClick={this.onSsoLogin}>
|
||||
{_t('Sign in with single sign-on')}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: assume unsupported/error
|
||||
return (
|
||||
<p>
|
||||
{_t(
|
||||
"You cannot sign in to your account. Please contact your " +
|
||||
"homeserver admin for more information.",
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const AuthPage = sdk.getComponent("auth.AuthPage");
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<h2>
|
||||
{_t("You're signed out")}
|
||||
</h2>
|
||||
|
||||
<h3>{_t("Sign in")}</h3>
|
||||
<div>
|
||||
{this._renderSignInSection()}
|
||||
</div>
|
||||
|
||||
<h3>{_t("Clear personal data")}</h3>
|
||||
<p>
|
||||
{_t(
|
||||
"Warning: Your personal data (including encryption keys) is still stored " +
|
||||
"on this device. Clear it if you're finished using this device, or want to sign " +
|
||||
"in to another account.",
|
||||
)}
|
||||
</p>
|
||||
<div>
|
||||
<AccessibleButton onClick={this.onClearAll} kind="danger">
|
||||
{_t("Clear all data")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,12 +15,11 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'AuthFooter',
|
||||
|
||||
render: function() {
|
||||
|
|
|
@ -15,12 +15,11 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const React = require('react');
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import sdk from '../../../index';
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'AuthHeader',
|
||||
|
||||
render: function() {
|
||||
|
|
|
@ -15,12 +15,11 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const React = require('react');
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import sdk from '../../../index';
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'AuthPage',
|
||||
|
||||
render: function() {
|
||||
|
|
|
@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
|
@ -25,7 +24,7 @@ const DIV_ID = 'mx_recaptcha';
|
|||
/**
|
||||
* A pure UI component which displays a captcha form.
|
||||
*/
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'CaptchaForm',
|
||||
|
||||
propTypes: {
|
||||
|
@ -90,7 +89,7 @@ module.exports = React.createClass({
|
|||
+ "authentication");
|
||||
}
|
||||
|
||||
console.log("Rendering to %s", divId);
|
||||
console.info("Rendering to %s", divId);
|
||||
this._captchaWidgetId = global.grecaptcha.render(divId, {
|
||||
sitekey: publicKey,
|
||||
callback: this.props.onCaptchaResponse,
|
||||
|
|
|
@ -20,6 +20,7 @@ import PropTypes from 'prop-types';
|
|||
import sdk from '../../../index';
|
||||
|
||||
import { COUNTRIES } from '../../../phonenumber';
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
const COUNTRIES_BY_ISO2 = {};
|
||||
for (const c of COUNTRIES) {
|
||||
|
@ -45,17 +46,25 @@ export default class CountryDropdown extends React.Component {
|
|||
this._onOptionChange = this._onOptionChange.bind(this);
|
||||
this._getShortOption = this._getShortOption.bind(this);
|
||||
|
||||
let defaultCountry = COUNTRIES[0];
|
||||
const defaultCountryCode = SdkConfig.get()["defaultCountryCode"];
|
||||
if (defaultCountryCode) {
|
||||
const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase());
|
||||
if (country) defaultCountry = country;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
searchQuery: '',
|
||||
defaultCountry,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
if (!this.props.value) {
|
||||
// If no value is given, we start with the first
|
||||
// If no value is given, we start with the default
|
||||
// country selected, but our parent component
|
||||
// doesn't know this, therefore we do this.
|
||||
this.props.onOptionChange(COUNTRIES[0]);
|
||||
this.props.onOptionChange(this.state.defaultCountry);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -119,7 +128,7 @@ export default class CountryDropdown extends React.Component {
|
|||
|
||||
// default value here too, otherwise we need to handle null / undefined
|
||||
// values between mounting and the initial value propgating
|
||||
const value = this.props.value || COUNTRIES[0].iso2;
|
||||
const value = this.props.value || this.state.defaultCountry.iso2;
|
||||
|
||||
return <Dropdown className={this.props.className + " mx_CountryDropdown"}
|
||||
onOptionChange={this._onOptionChange} onSearchChange={this._onSearchChange}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,9 +16,10 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'CustomServerDialog',
|
||||
|
||||
render: function() {
|
||||
|
@ -33,11 +35,6 @@ module.exports = React.createClass({
|
|||
"allows you to use this app with an existing Matrix account on a " +
|
||||
"different homeserver.",
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"You can also set a custom identity server, but you won't be " +
|
||||
"able to invite users by email address, or be invited by email " +
|
||||
"address yourself.",
|
||||
)}</p>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.props.onFinished} autoFocus={true}>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,6 +17,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import url from 'url';
|
||||
import classnames from 'classnames';
|
||||
|
@ -57,13 +59,12 @@ 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.
|
||||
* makeRegistrationUrl A function that makes a registration URL
|
||||
*
|
||||
* Each component may also provide the following functions (beyond the standard React ones):
|
||||
* focus: set the input focus appropriately in the form.
|
||||
*/
|
||||
|
||||
export const PasswordAuthEntry = React.createClass({
|
||||
export const PasswordAuthEntry = createReactClass({
|
||||
displayName: 'PasswordAuthEntry',
|
||||
|
||||
statics: {
|
||||
|
@ -81,40 +82,38 @@ export const PasswordAuthEntry = React.createClass({
|
|||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
passwordValid: false,
|
||||
password: "",
|
||||
};
|
||||
},
|
||||
|
||||
focus: function() {
|
||||
if (this.refs.passwordField) {
|
||||
this.refs.passwordField.focus();
|
||||
}
|
||||
},
|
||||
|
||||
_onSubmit: function(e) {
|
||||
e.preventDefault();
|
||||
if (this.props.busy) return;
|
||||
|
||||
this.props.submitAuthDict({
|
||||
type: PasswordAuthEntry.LOGIN_TYPE,
|
||||
// TODO: Remove `user` once servers support proper UIA
|
||||
// See https://github.com/vector-im/riot-web/issues/10312
|
||||
user: this.props.matrixClient.credentials.userId,
|
||||
password: this.refs.passwordField.value,
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: this.props.matrixClient.credentials.userId,
|
||||
},
|
||||
password: this.state.password,
|
||||
});
|
||||
},
|
||||
|
||||
_onPasswordFieldChange: function(ev) {
|
||||
// enable the submit button iff the password is non-empty
|
||||
this.setState({
|
||||
passwordValid: Boolean(this.refs.passwordField.value),
|
||||
password: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
let passwordBoxClass = null;
|
||||
|
||||
if (this.props.errorText) {
|
||||
passwordBoxClass = 'error';
|
||||
}
|
||||
const passwordBoxClass = classnames({
|
||||
"error": this.props.errorText,
|
||||
});
|
||||
|
||||
let submitButtonOrSpinner;
|
||||
if (this.props.busy) {
|
||||
|
@ -124,7 +123,8 @@ export const PasswordAuthEntry = React.createClass({
|
|||
submitButtonOrSpinner = (
|
||||
<input type="submit"
|
||||
className="mx_Dialog_primary"
|
||||
disabled={!this.state.passwordValid}
|
||||
disabled={!this.state.password}
|
||||
value={_t("Continue")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -138,17 +138,21 @@ export const PasswordAuthEntry = React.createClass({
|
|||
);
|
||||
}
|
||||
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("To continue, please enter your password.") }</p>
|
||||
<form onSubmit={this._onSubmit}>
|
||||
<label htmlFor="passwordField">{ _t("Password:") }</label>
|
||||
<input
|
||||
name="passwordField"
|
||||
ref="passwordField"
|
||||
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
|
||||
<Field
|
||||
id="mx_InteractiveAuthEntryComponents_password"
|
||||
className={passwordBoxClass}
|
||||
onChange={this._onPasswordFieldChange}
|
||||
type="password"
|
||||
name="passwordField"
|
||||
label={_t('Password')}
|
||||
autoFocus={true}
|
||||
value={this.state.password}
|
||||
onChange={this._onPasswordFieldChange}
|
||||
/>
|
||||
<div className="mx_button_row">
|
||||
{ submitButtonOrSpinner }
|
||||
|
@ -160,7 +164,7 @@ export const PasswordAuthEntry = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
export const RecaptchaAuthEntry = React.createClass({
|
||||
export const RecaptchaAuthEntry = createReactClass({
|
||||
displayName: 'RecaptchaAuthEntry',
|
||||
|
||||
statics: {
|
||||
|
@ -187,14 +191,24 @@ export const RecaptchaAuthEntry = React.createClass({
|
|||
return <Loader />;
|
||||
}
|
||||
|
||||
let errorText = this.props.errorText;
|
||||
|
||||
const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm");
|
||||
const sitePublicKey = this.props.stageParams.public_key;
|
||||
let sitePublicKey;
|
||||
if (!this.props.stageParams || !this.props.stageParams.public_key) {
|
||||
errorText = _t(
|
||||
"Missing captcha public key in homeserver configuration. Please report " +
|
||||
"this to your homeserver administrator.",
|
||||
);
|
||||
} else {
|
||||
sitePublicKey = this.props.stageParams.public_key;
|
||||
}
|
||||
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
if (errorText) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ this.props.errorText }
|
||||
{ errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -210,7 +224,7 @@ export const RecaptchaAuthEntry = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
export const TermsAuthEntry = React.createClass({
|
||||
export const TermsAuthEntry = createReactClass({
|
||||
displayName: 'TermsAuthEntry',
|
||||
|
||||
statics: {
|
||||
|
@ -316,7 +330,7 @@ export const TermsAuthEntry = React.createClass({
|
|||
|
||||
checkboxes.push(
|
||||
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
|
||||
<input type="checkbox" onClick={() => this._togglePolicy(policy.id)} checked={checked} />
|
||||
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} />
|
||||
<a href={policy.url} target="_blank" rel="noopener">{ policy.name }</a>
|
||||
</label>,
|
||||
);
|
||||
|
@ -349,7 +363,7 @@ export const TermsAuthEntry = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
export const EmailIdentityAuthEntry = React.createClass({
|
||||
export const EmailIdentityAuthEntry = createReactClass({
|
||||
displayName: 'EmailIdentityAuthEntry',
|
||||
|
||||
statics: {
|
||||
|
@ -365,7 +379,6 @@ export const EmailIdentityAuthEntry = React.createClass({
|
|||
stageState: PropTypes.object.isRequired,
|
||||
fail: PropTypes.func.isRequired,
|
||||
setEmailSid: PropTypes.func.isRequired,
|
||||
makeRegistrationUrl: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -374,38 +387,6 @@ export const EmailIdentityAuthEntry = React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
if (this.props.stageState.emailSid === null) {
|
||||
this.setState({requestingToken: true});
|
||||
this._requestEmailToken().catch((e) => {
|
||||
this.props.fail(e);
|
||||
}).finally(() => {
|
||||
this.setState({requestingToken: false});
|
||||
}).done();
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Requests a verification token by email.
|
||||
*/
|
||||
_requestEmailToken: function() {
|
||||
const nextLink = this.props.makeRegistrationUrl({
|
||||
client_secret: this.props.clientSecret,
|
||||
hs_url: this.props.matrixClient.getHomeserverUrl(),
|
||||
is_url: this.props.matrixClient.getIdentityServerUrl(),
|
||||
session_id: this.props.authSessionId,
|
||||
});
|
||||
|
||||
return this.props.matrixClient.requestRegisterEmailToken(
|
||||
this.props.inputs.emailAddress,
|
||||
this.props.clientSecret,
|
||||
1, // TODO: Multiple send attempts?
|
||||
nextLink,
|
||||
).then((result) => {
|
||||
this.props.setEmailSid(result.sid);
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
if (this.state.requestingToken) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
@ -424,7 +405,7 @@ export const EmailIdentityAuthEntry = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
export const MsisdnAuthEntry = React.createClass({
|
||||
export const MsisdnAuthEntry = createReactClass({
|
||||
displayName: 'MsisdnAuthEntry',
|
||||
|
||||
statics: {
|
||||
|
@ -450,6 +431,7 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._submitUrl = null;
|
||||
this._sid = null;
|
||||
this._msisdn = null;
|
||||
this._tokenBox = null;
|
||||
|
@ -459,7 +441,7 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
this.props.fail(e);
|
||||
}).finally(() => {
|
||||
this.setState({requestingToken: false});
|
||||
}).done();
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
|
@ -472,6 +454,7 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
this.props.clientSecret,
|
||||
1, // TODO: Multiple send attempts?
|
||||
).then((result) => {
|
||||
this._submitUrl = result.submit_url;
|
||||
this._sid = result.sid;
|
||||
this._msisdn = result.msisdn;
|
||||
});
|
||||
|
@ -483,38 +466,56 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_onFormSubmit: function(e) {
|
||||
_onFormSubmit: async function(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.token == '') return;
|
||||
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
|
||||
this.props.matrixClient.submitMsisdnToken(
|
||||
this._sid, this.props.clientSecret, this.state.token,
|
||||
).then((result) => {
|
||||
if (result.success) {
|
||||
const idServerParsedUrl = url.parse(
|
||||
this.props.matrixClient.getIdentityServerUrl(),
|
||||
try {
|
||||
const requiresIdServerParam =
|
||||
await this.props.matrixClient.doesServerRequireIdServerParam();
|
||||
let result;
|
||||
if (this._submitUrl) {
|
||||
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
|
||||
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
|
||||
);
|
||||
} else if (requiresIdServerParam) {
|
||||
result = await this.props.matrixClient.submitMsisdnToken(
|
||||
this._sid, this.props.clientSecret, this.state.token,
|
||||
);
|
||||
} else {
|
||||
throw new Error("The registration with MSISDN flow is misconfigured");
|
||||
}
|
||||
if (result.success) {
|
||||
const creds = {
|
||||
sid: this._sid,
|
||||
client_secret: this.props.clientSecret,
|
||||
};
|
||||
if (requiresIdServerParam) {
|
||||
const idServerParsedUrl = url.parse(
|
||||
this.props.matrixClient.getIdentityServerUrl(),
|
||||
);
|
||||
creds.id_server = idServerParsedUrl.host;
|
||||
}
|
||||
this.props.submitAuthDict({
|
||||
type: MsisdnAuthEntry.LOGIN_TYPE,
|
||||
threepid_creds: {
|
||||
sid: this._sid,
|
||||
client_secret: this.props.clientSecret,
|
||||
id_server: idServerParsedUrl.host,
|
||||
},
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/riot-web/issues/10312
|
||||
threepid_creds: creds,
|
||||
threepidCreds: creds,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
errorText: _t("Token incorrect"),
|
||||
});
|
||||
}
|
||||
}).catch((e) => {
|
||||
} catch (e) {
|
||||
this.props.fail(e);
|
||||
console.log("Failed to submit msisdn token");
|
||||
}).done();
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
@ -564,7 +565,7 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
export const FallbackAuthEntry = React.createClass({
|
||||
export const FallbackAuthEntry = createReactClass({
|
||||
displayName: 'FallbackAuthEntry',
|
||||
|
||||
propTypes: {
|
||||
|
|
|
@ -15,91 +15,82 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import ServerConfig from "./ServerConfig";
|
||||
|
||||
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
|
||||
|
||||
// TODO: TravisR - Can this extend ServerConfig for most things?
|
||||
|
||||
/*
|
||||
* Configure the Modular server name.
|
||||
*
|
||||
* This is a variant of ServerConfig with only the HS field and different body
|
||||
* text that is specific to the Modular case.
|
||||
*/
|
||||
export default class ModularServerConfig extends React.PureComponent {
|
||||
static propTypes = {
|
||||
onServerConfigChange: PropTypes.func,
|
||||
export default class ModularServerConfig extends ServerConfig {
|
||||
static propTypes = ServerConfig.propTypes;
|
||||
|
||||
// default URLs are defined in config.json (or the hardcoded defaults)
|
||||
// they are used if the user has not overridden them with a custom URL.
|
||||
// In other words, if the custom URL is blank, the default is used.
|
||||
defaultHsUrl: PropTypes.string, // e.g. https://matrix.org
|
||||
|
||||
// This component always uses the default IS URL and doesn't allow it
|
||||
// to be changed. We still receive it as a prop here to simplify
|
||||
// consumers by still passing the IS URL via onServerConfigChange.
|
||||
defaultIsUrl: PropTypes.string, // e.g. https://vector.im
|
||||
|
||||
// custom URLs are explicitly provided by the user and override the
|
||||
// default URLs. The user enters them via the component's input fields,
|
||||
// which is reflected on these properties whenever on..UrlChanged fires.
|
||||
// They are persisted in localStorage by MatrixClientPeg, and so can
|
||||
// override the default URLs when the component initially loads.
|
||||
customHsUrl: PropTypes.string,
|
||||
|
||||
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onServerConfigChange: function() {},
|
||||
customHsUrl: "",
|
||||
delayTimeMs: 0,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hsUrl: props.customHsUrl,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.customHsUrl === this.state.hsUrl) return;
|
||||
async validateAndApplyServer(hsUrl, isUrl) {
|
||||
// Always try and use the defaults first
|
||||
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
|
||||
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
hsUrl: newProps.customHsUrl,
|
||||
hsUrl,
|
||||
isUrl,
|
||||
busy: true,
|
||||
errorText: "",
|
||||
});
|
||||
this.props.onServerConfigChange({
|
||||
hsUrl: newProps.customHsUrl,
|
||||
isUrl: this.props.defaultIsUrl,
|
||||
});
|
||||
}
|
||||
|
||||
onHomeserverBlur = (ev) => {
|
||||
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
|
||||
this.props.onServerConfigChange({
|
||||
hsUrl: this.state.hsUrl,
|
||||
isUrl: this.props.defaultIsUrl,
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
let message = _t("Unable to validate homeserver/identity server");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onHomeserverChange = (ev) => {
|
||||
const hsUrl = ev.target.value;
|
||||
this.setState({ hsUrl });
|
||||
}
|
||||
|
||||
_waitThenInvoke(existingTimeoutId, fn) {
|
||||
if (existingTimeoutId) {
|
||||
clearTimeout(existingTimeoutId);
|
||||
return null;
|
||||
}
|
||||
return setTimeout(fn.bind(this), this.props.delayTimeMs);
|
||||
}
|
||||
|
||||
async validateServer() {
|
||||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
|
||||
}
|
||||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const submitButton = this.props.submitText
|
||||
? <AccessibleButton
|
||||
element="button"
|
||||
type="submit"
|
||||
className={this.props.submitClass}
|
||||
onClick={this.onSubmit}
|
||||
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx_ServerConfig">
|
||||
|
@ -113,15 +104,18 @@ export default class ModularServerConfig extends React.PureComponent {
|
|||
</a>,
|
||||
},
|
||||
)}
|
||||
<div className="mx_ServerConfig_fields">
|
||||
<Field id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Server Name")}
|
||||
placeholder={this.props.defaultHsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
|
||||
<div className="mx_ServerConfig_fields">
|
||||
<Field id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Server Name")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
/>
|
||||
</div>
|
||||
{submitButton}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -21,11 +22,30 @@ import classNames from 'classnames';
|
|||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a username/password form.
|
||||
*/
|
||||
class PasswordLogin extends React.Component {
|
||||
export default class PasswordLogin extends React.Component {
|
||||
static propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired, // fn(username, password)
|
||||
onError: PropTypes.func,
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
onForgotPasswordClick: PropTypes.func, // fn()
|
||||
initialUsername: PropTypes.string,
|
||||
initialPhoneCountry: PropTypes.string,
|
||||
initialPhoneNumber: PropTypes.string,
|
||||
initialPassword: PropTypes.string,
|
||||
onUsernameChanged: PropTypes.func,
|
||||
onPhoneCountryChanged: PropTypes.func,
|
||||
onPhoneNumberChanged: PropTypes.func,
|
||||
onPasswordChanged: PropTypes.func,
|
||||
loginIncorrect: PropTypes.bool,
|
||||
disableSubmit: PropTypes.bool,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onError: function() {},
|
||||
onEditServerDetailsClick: null,
|
||||
|
@ -40,13 +60,12 @@ class PasswordLogin extends React.Component {
|
|||
initialPhoneNumber: "",
|
||||
initialPassword: "",
|
||||
loginIncorrect: false,
|
||||
// This is optional and only set if we used a server name to determine
|
||||
// the HS URL via `.well-known` discovery. The server name is used
|
||||
// instead of the HS URL when talking about where to "sign in to".
|
||||
hsName: null,
|
||||
hsUrl: "",
|
||||
disableSubmit: false,
|
||||
}
|
||||
};
|
||||
|
||||
static LOGIN_FIELD_EMAIL = "login_field_email";
|
||||
static LOGIN_FIELD_MXID = "login_field_mxid";
|
||||
static LOGIN_FIELD_PHONE = "login_field_phone";
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -193,10 +212,7 @@ class PasswordLogin extends React.Component {
|
|||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={SdkConfig.get().disable_custom_urls ?
|
||||
_t("Username on %(hs)s", {
|
||||
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
|
||||
}) : _t("Username")}
|
||||
label={_t("Username")}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onBlur={this.onUsernameBlur}
|
||||
|
@ -242,6 +258,7 @@ class PasswordLogin extends React.Component {
|
|||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
|
||||
let forgotPasswordJsx;
|
||||
|
||||
|
@ -258,31 +275,6 @@ class PasswordLogin extends React.Component {
|
|||
</span>;
|
||||
}
|
||||
|
||||
let signInToText = _t('Sign in to your Matrix account');
|
||||
if (this.props.hsName) {
|
||||
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.hsName,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const parsedHsUrl = new URL(this.props.hsUrl);
|
||||
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
|
||||
serverName: parsedHsUrl.hostname,
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
if (this.props.onEditServerDetailsClick) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.props.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
@ -295,7 +287,6 @@ class PasswordLogin extends React.Component {
|
|||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
className="mx_Login_type_dropdown"
|
||||
id="mx_PasswordLogin_type"
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
|
@ -326,10 +317,8 @@ class PasswordLogin extends React.Component {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h3>
|
||||
{signInToText}
|
||||
{editLink}
|
||||
</h3>
|
||||
<SignInToText serverConfig={this.props.serverConfig}
|
||||
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
|
@ -353,27 +342,3 @@ class PasswordLogin extends React.Component {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
PasswordLogin.LOGIN_FIELD_EMAIL = "login_field_email";
|
||||
PasswordLogin.LOGIN_FIELD_MXID = "login_field_mxid";
|
||||
PasswordLogin.LOGIN_FIELD_PHONE = "login_field_phone";
|
||||
|
||||
PasswordLogin.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired, // fn(username, password)
|
||||
onError: PropTypes.func,
|
||||
onForgotPasswordClick: PropTypes.func, // fn()
|
||||
initialUsername: PropTypes.string,
|
||||
initialPhoneCountry: PropTypes.string,
|
||||
initialPhoneNumber: PropTypes.string,
|
||||
initialPassword: PropTypes.string,
|
||||
onUsernameChanged: PropTypes.func,
|
||||
onPhoneCountryChanged: PropTypes.func,
|
||||
onPhoneNumberChanged: PropTypes.func,
|
||||
onPasswordChanged: PropTypes.func,
|
||||
loginIncorrect: PropTypes.bool,
|
||||
hsName: PropTypes.string,
|
||||
hsUrl: PropTypes.string,
|
||||
disableSubmit: PropTypes.bool,
|
||||
};
|
||||
|
||||
module.exports = PasswordLogin;
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,6 +18,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import Email from '../../../email';
|
||||
|
@ -26,6 +28,7 @@ import { _t } from '../../../languageHandler';
|
|||
import SdkConfig from '../../../SdkConfig';
|
||||
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
|
||||
import withValidation from '../elements/Validation';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
const FIELD_EMAIL = 'field_email';
|
||||
const FIELD_PHONE_NUMBER = 'field_phone_number';
|
||||
|
@ -38,7 +41,7 @@ const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from of
|
|||
/**
|
||||
* A pure UI component which displays a registration form.
|
||||
*/
|
||||
module.exports = React.createClass({
|
||||
module.exports = createReactClass({
|
||||
displayName: 'RegistrationForm',
|
||||
|
||||
propTypes: {
|
||||
|
@ -51,16 +54,15 @@ module.exports = React.createClass({
|
|||
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
// This is optional and only set if we used a server name to determine
|
||||
// the HS URL via `.well-known` discovery. The server name is used
|
||||
// instead of the HS URL when talking about "your account".
|
||||
hsName: PropTypes.string,
|
||||
hsUrl: PropTypes.string,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
canSubmit: PropTypes.bool,
|
||||
serverRequiresIdServer: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onValidationChange: console.error,
|
||||
canSubmit: true,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -70,33 +72,47 @@ module.exports = React.createClass({
|
|||
fieldValid: {},
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: this.props.defaultPhoneCountry,
|
||||
username: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
password: "",
|
||||
username: this.props.defaultUsername || "",
|
||||
email: this.props.defaultEmail || "",
|
||||
phoneNumber: this.props.defaultPhoneNumber || "",
|
||||
password: this.props.defaultPassword || "",
|
||||
passwordConfirm: "",
|
||||
passwordComplexity: null,
|
||||
passwordSafe: false,
|
||||
};
|
||||
},
|
||||
|
||||
onSubmit: async function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!this.props.canSubmit) return;
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
if (this.state.email == '') {
|
||||
if (this.state.email === '') {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
|
||||
let desc;
|
||||
if (this.props.serverRequiresIdServer && !haveIs) {
|
||||
desc = _t(
|
||||
"No identity server is configured so you cannot add an email address in order to " +
|
||||
"reset your password in the future.",
|
||||
);
|
||||
} else {
|
||||
desc = _t(
|
||||
"If you don't specify an email address, you won't be able to reset your password. " +
|
||||
"Are you sure?",
|
||||
);
|
||||
}
|
||||
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description:
|
||||
<div>
|
||||
{ _t("If you don't specify an email address, you won't be able to reset your password. " +
|
||||
"Are you sure?") }
|
||||
</div>,
|
||||
description: desc,
|
||||
button: _t("Continue"),
|
||||
onFinished: function(confirmed) {
|
||||
if (confirmed) {
|
||||
|
@ -150,7 +166,11 @@ module.exports = React.createClass({
|
|||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
field.validate({ allowEmpty: false });
|
||||
// We must wait for these validations to finish before queueing
|
||||
// up the setState below so our setState goes in the queue after
|
||||
// all the setStates from these validate calls (that's how we
|
||||
// know they've finished).
|
||||
await field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
|
@ -270,12 +290,23 @@ module.exports = React.createClass({
|
|||
}
|
||||
const { scorePassword } = await import('../../../utils/PasswordScorer');
|
||||
const complexity = scorePassword(value);
|
||||
const safe = complexity.score >= PASSWORD_MIN_SCORE;
|
||||
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
|
||||
this.setState({
|
||||
passwordComplexity: complexity,
|
||||
passwordSafe: safe,
|
||||
});
|
||||
return complexity.score >= PASSWORD_MIN_SCORE;
|
||||
return allowUnsafe || safe;
|
||||
},
|
||||
valid: function() {
|
||||
// Unsafe passwords that are valid are only possible through a
|
||||
// configuration flag. We'll print some helper text to signal
|
||||
// to the user that their password is allowed, but unsafe.
|
||||
if (!this.state.passwordSafe) {
|
||||
return _t("Password is allowed, but unsafe");
|
||||
}
|
||||
return _t("Nice, strong password!");
|
||||
},
|
||||
valid: () => _t("Nice, strong password!"),
|
||||
invalid: function() {
|
||||
const complexity = this.state.passwordComplexity;
|
||||
if (!complexity) {
|
||||
|
@ -367,7 +398,7 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
validateUsernameRules: withValidation({
|
||||
description: () => _t("Use letters, numbers, dashes and underscores only"),
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -406,8 +437,32 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_showEmail() {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.email.identity')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
_showPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
!threePidLogin ||
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.msisdn')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
renderEmail() {
|
||||
if (!this._authStepIsUsed('m.login.email.identity')) {
|
||||
if (!this._showEmail()) {
|
||||
return null;
|
||||
}
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
@ -419,7 +474,6 @@ module.exports = React.createClass({
|
|||
ref={field => this[FIELD_EMAIL] = field}
|
||||
type="text"
|
||||
label={emailPlaceholder}
|
||||
defaultValue={this.props.defaultEmail}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}
|
||||
onValidate={this.onEmailValidate}
|
||||
|
@ -433,7 +487,6 @@ module.exports = React.createClass({
|
|||
ref={field => this[FIELD_PASSWORD] = field}
|
||||
type="password"
|
||||
label={_t("Password")}
|
||||
defaultValue={this.props.defaultPassword}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChange}
|
||||
onValidate={this.onPasswordValidate}
|
||||
|
@ -447,7 +500,6 @@ module.exports = React.createClass({
|
|||
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
|
||||
type="password"
|
||||
label={_t("Confirm")}
|
||||
defaultValue={this.props.defaultPassword}
|
||||
value={this.state.passwordConfirm}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
|
@ -455,8 +507,7 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
renderPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) {
|
||||
if (!this._showPhoneNumber()) {
|
||||
return null;
|
||||
}
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
|
@ -475,7 +526,6 @@ module.exports = React.createClass({
|
|||
ref={field => this[FIELD_PHONE_NUMBER] = field}
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
defaultValue={this.props.defaultPhoneNumber}
|
||||
value={this.state.phoneNumber}
|
||||
prefix={phoneCountry}
|
||||
onChange={this.onPhoneNumberChange}
|
||||
|
@ -491,7 +541,6 @@ module.exports = React.createClass({
|
|||
type="text"
|
||||
autoFocus={true}
|
||||
label={_t("Username")}
|
||||
defaultValue={this.props.defaultUsername}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChange}
|
||||
onValidate={this.onUsernameValidate}
|
||||
|
@ -499,20 +548,22 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
let yourMatrixAccountText = _t('Create your Matrix account');
|
||||
if (this.props.hsName) {
|
||||
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.hsName,
|
||||
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const parsedHsUrl = new URL(this.props.hsUrl);
|
||||
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: parsedHsUrl.hostname,
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
|
@ -525,9 +576,38 @@ module.exports = React.createClass({
|
|||
}
|
||||
|
||||
const registerButton = (
|
||||
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
|
||||
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
|
||||
);
|
||||
|
||||
let emailHelperText = null;
|
||||
if (this._showEmail()) {
|
||||
if (this._showPhoneNumber()) {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email or phone to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
</div>;
|
||||
} else {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
let noIsText = null;
|
||||
if (this.props.serverRequiresIdServer && !haveIs) {
|
||||
noIsText = <div>
|
||||
{_t(
|
||||
"No identity server is configured so you cannot add an email address in order to " +
|
||||
"reset your password in the future.",
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>
|
||||
|
@ -546,8 +626,8 @@ module.exports = React.createClass({
|
|||
{this.renderEmail()}
|
||||
{this.renderPhoneNumber()}
|
||||
</div>
|
||||
{_t("Use an email address to recover your account.") + " "}
|
||||
{_t("Other users can invite you to rooms using your contact details.")}
|
||||
{ emailHelperText }
|
||||
{ noIsText }
|
||||
{ registerButton }
|
||||
</form>
|
||||
</div>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue