Merge branch 'develop' into luke/fix-render-1-1-avatars-when-others-leave

This commit is contained in:
Luke Barnard 2018-02-06 16:46:25 +00:00 committed by GitHub
commit 121b776e8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
378 changed files with 67241 additions and 11932 deletions

77
src/ActiveRoomObserver.js Normal file
View file

@ -0,0 +1,77 @@
/*
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 RoomViewStore from './stores/RoomViewStore';
/**
* Consumes changes from the RoomViewStore and notifies specific things
* about when the active room changes. Unlike listening for RoomViewStore
* changes, you can subscribe to only changes relevant to a particular
* room.
*
* TODO: If we introduce an observer for something else, factor out
* the adding / removing of listeners & emitting into a common class.
*/
class ActiveRoomObserver {
constructor() {
this._listeners = {};
this._activeRoomId = RoomViewStore.getRoomId();
// TODO: We could self-destruct when the last listener goes away, or at least
// stop listening.
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
}
addListener(roomId, listener) {
if (!this._listeners[roomId]) this._listeners[roomId] = [];
this._listeners[roomId].push(listener);
}
removeListener(roomId, listener) {
if (this._listeners[roomId]) {
const i = this._listeners[roomId].indexOf(listener);
if (i > -1) {
this._listeners[roomId].splice(i, 1);
}
} else {
console.warn("Unregistering unrecognised listener (roomId=" + roomId + ")");
}
}
_emit(roomId) {
if (!this._listeners[roomId]) return;
for (const l of this._listeners[roomId]) {
l.call();
}
}
_onRoomViewStoreUpdate() {
// emit for the old room ID
if (this._activeRoomId) this._emit(this._activeRoomId);
// update our cache
this._activeRoomId = RoomViewStore.getRoomId();
// and emit for the new one
if (this._activeRoomId) this._emit(this._activeRoomId);
}
}
if (global.mx_ActiveRoomObserver === undefined) {
global.mx_ActiveRoomObserver = new ActiveRoomObserver();
}
export default global.mx_ActiveRoomObserver;

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,14 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var MatrixClientPeg = require("./MatrixClientPeg");
import MatrixClientPeg from './MatrixClientPeg';
import { _t } from './languageHandler';
/**
* Allows a user to add a third party identifier to their Home Server and,
* optionally, the identity servers.
*
* 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
* the client owns the given email address, which is then passed to the
* add threepid API on the homeserver.
*/
class AddThreepid {
@ -42,8 +44,33 @@ class AddThreepid {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode == 'M_THREEPID_IN_USE') {
err.message = "This email address is already in use";
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;
});
}
/**
* Attempt to add a msisdn threepid. This will trigger a side-effect of
* sending a test message 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;
return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
phoneCountry, phoneNumber, this.clientSecret, 1,
).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})`;
}
@ -53,26 +80,49 @@ class AddThreepid {
/**
* Checks if the email link has been clicked by attempting to add the threepid
* @return {Promise} Resolves if the password was reset. Rejects with an object
* @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 reset failed, e.g. "There is no mapped matrix user ID for the given email address".
* the request failed.
*/
checkEmailLinkClicked() {
var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
return MatrixClientPeg.get().addThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: identityServerDomain
id_server: identityServerDomain,
}, this.bind).catch(function(err) {
if (err.httpStatus === 401) {
err.message = "Failed to verify email address: make sure you clicked the link in the email";
}
else if (err.httpStatus) {
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;
});
}
/**
* 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
* @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;
}
const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
return MatrixClientPeg.get().addThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: identityServerDomain,
}, this.bind);
});
}
}
module.exports = AddThreepid;

231
src/Analytics.js Normal file
View file

@ -0,0 +1,231 @@
/*
Copyright 2017 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.
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 { getCurrentLanguage, _t, _td } from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig, { DEFAULTS } from './SdkConfig';
import Modal from './Modal';
import sdk from './index';
function getRedactedHash() {
return window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/<redacted>");
}
function getRedactedUrl() {
// hardcoded url to make piwik happy
return 'https://riot.im/app/' + getRedactedHash();
}
const customVariables = {
'App Platform': {
id: 1,
expl: _td('The platform you\'re on'),
},
'App Version': {
id: 2,
expl: _td('The version of Riot.im'),
},
'User Type': {
id: 3,
expl: _td('Whether or not you\'re logged in (we don\'t record your user name)'),
},
'Chosen Language': {
id: 4,
expl: _td('Your language of choice'),
},
'Instance': {
id: 5,
expl: _td('Which officially provided instance you are using, if any'),
},
'RTE: Uses Richtext Mode': {
id: 6,
expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'),
},
'Homeserver URL': {
id: 7,
expl: _td('Your homeserver\'s URL'),
},
'Identity Server URL': {
id: 8,
expl: _td('Your identity server\'s URL'),
},
};
function whitelistRedact(whitelist, str) {
if (whitelist.includes(str)) return str;
return '<redacted>';
}
class Analytics {
constructor() {
this._paq = null;
this.disabled = true;
this.firstPage = true;
}
/**
* Enable Analytics if initialized but disabled
* otherwise try and initalize, no-op if piwik config missing
*/
enable() {
if (this._paq || this._init()) {
this.disabled = false;
}
}
/**
* Disable Analytics calls, will not fully unload Piwik until a refresh,
* but this is second best, Piwik should not pull anything implicitly.
*/
disable() {
this.trackEvent('Analytics', 'opt-out');
this.disabled = true;
}
_init() {
const config = SdkConfig.get();
if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return;
const url = config.piwik.url;
const siteId = config.piwik.siteId;
const self = this;
window._paq = this._paq = window._paq || [];
this._paq.push(['setTrackerUrl', url+'piwik.php']);
this._paq.push(['setSiteId', siteId]);
this._paq.push(['trackAllContentImpressions']);
this._paq.push(['discardHashTag', false]);
this._paq.push(['enableHeartBeatTimer']);
// this._paq.push(['enableLinkTracking', true]);
const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName());
platform.getAppVersion().then((version) => {
this._setVisitVariable('App Version', version);
}).catch(() => {
this._setVisitVariable('App Version', 'unknown');
});
this._setVisitVariable('Chosen Language', getCurrentLanguage());
if (window.location.hostname === 'riot.im') {
this._setVisitVariable('Instance', window.location.pathname);
}
(function() {
const g = document.createElement('script');
const s = document.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=url+'piwik.js';
g.onload = function() {
console.log('Initialised anonymous analytics');
self._paq = window._paq;
};
s.parentNode.insertBefore(g, s);
})();
return true;
}
trackPageChange() {
if (this.disabled) return;
if (this.firstPage) {
// De-duplicate first page
// router seems to hit the fn twice
this.firstPage = false;
return;
}
this._paq.push(['setCustomUrl', getRedactedUrl()]);
this._paq.push(['trackPageView']);
}
trackEvent(category, action, name) {
if (this.disabled) return;
this._paq.push(['trackEvent', category, action, name]);
}
logout() {
if (this.disabled) return;
this._paq.push(['deleteCookies']);
}
_setVisitVariable(key, value) {
this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']);
}
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
if (this.disabled) return;
const config = SdkConfig.get();
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || DEFAULTS.piwik.whitelistedHSUrls;
const whitelistedISUrls = config.piwik.whitelistedISUrls || DEFAULTS.piwik.whitelistedISUrls;
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl));
}
setRichtextMode(state) {
if (this.disabled) return;
this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off');
}
showDetailsModal() {
const Tracker = window.Piwik.getAsyncTracker();
const rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean);
const resolution = `${window.screen.width}x${window.screen.height}`;
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
title: _t('Analytics'),
description: <div>
<div>
{ _t('The information being sent to us to help make Riot.im better includes:') }
</div>
<table>
{ rows.map((row) => <tr key={row[0]}>
<td>{ _t(customVariables[row[0]].expl) }</td>
<td><code>{ row[1] }</code></td>
</tr>) }
</table>
<br />
<div>
{ _t('We also record each page you use in the app (currently <CurrentPageHash>), your User Agent'
+ ' (<CurrentUserAgent>) and your device resolution (<CurrentDeviceResolution>).',
{},
{
CurrentPageHash: <code>{ getRedactedHash() }</code>,
CurrentUserAgent: <code>{ navigator.userAgent }</code>,
CurrentDeviceResolution: <code>{ resolution }</code>,
},
) }
{ _t('Where this page includes identifiable information, such as a room, '
+ 'user or group ID, that data is removed before being sent to the server.') }
</div>
</div>,
});
}
}
if (!global.mxAnalytics) {
global.mxAnalytics = new Analytics();
}
module.exports = global.mxAnalytics;

View file

@ -15,18 +15,18 @@ limitations under the License.
*/
'use strict';
var ContentRepo = require("matrix-js-sdk").ContentRepo;
var MatrixClientPeg = require('./MatrixClientPeg');
import {ContentRepo} from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg';
module.exports = {
avatarUrlForMember: function(member, width, height, resizeMethod) {
var url = member.getAvatarUrl(
let url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
false,
false
false,
);
if (!url) {
// member can be null here currently since on invites, the JS SDK
@ -38,9 +38,11 @@ module.exports = {
},
avatarUrlForUser: function(user, width, height, resizeMethod) {
var url = ContentRepo.getHttpUriForMxc(
const url = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
width, height, resizeMethod
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
if (!url || url.length === 0) {
return null;
@ -49,12 +51,11 @@ module.exports = {
},
defaultAvatarUrlForString: function(s) {
var images = [ '76cfa6', '50e2c2', 'f4c371' ];
var total = 0;
for (var i = 0; i < s.length; ++i) {
const images = ['76cfa6', '50e2c2', 'f4c371'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
return 'img/' + images[total % images.length] + '.png';
}
}
},
};

View file

@ -17,6 +17,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from './dispatcher';
/**
* Base class for classes that provide platform-specific functionality
* eg. Setting an application badge or displaying notifications
@ -27,6 +29,21 @@ export default class BasePlatform {
constructor() {
this.notificationCount = 0;
this.errorDidOccur = false;
dis.register(this._onAction.bind(this));
}
_onAction(payload: Object) {
switch (payload.action) {
case 'on_logged_out':
this.setNotificationCount(0);
break;
}
}
// Used primarily for Analytics
getHumanReadableName(): string {
return 'Base Platform';
}
setNotificationCount(count: number) {
@ -40,16 +57,18 @@ export default class BasePlatform {
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/
supportsNotifications() : boolean {
supportsNotifications(): boolean {
return false;
}
/**
* Returns true if the application currently has permission
* to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/
maySendNotifications() : boolean {
maySendNotifications(): boolean {
return false;
}
@ -60,17 +79,42 @@ export default class BasePlatform {
* that is 'granted' if the user allowed the request or
* 'denied' otherwise.
*/
requestNotificationPermission() : Promise<string> {
requestNotificationPermission(): Promise<string> {
}
displayNotification(title: string, msg: string, avatarUrl: string, room: Object) {
}
loudNotification(ev: Event, room: Object) {
}
/**
* Returns a promise that resolves to a string representing
* the current version of the application.
*/
getAppVersion() {
getAppVersion(): Promise<string> {
throw new Error("getAppVersion not implemented!");
}
/*
* If it's not expected that capturing the screen will work
* with getUserMedia, return a string explaining why not.
* Otherwise, return null.
*/
screenCaptureErrorString(): string {
return "Not implemented";
}
isElectron(): boolean { return false; }
setupScreenSharingForIframe() {
}
/**
* Restarts the application, without neccessarily reloading
* any application code
*/
reload() {
throw new Error("reload not implemented!");
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -51,32 +52,34 @@ limitations under the License.
* }
*/
var MatrixClientPeg = require('./MatrixClientPeg');
var Modal = require('./Modal');
var sdk = require('./index');
var Matrix = require("matrix-js-sdk");
var dis = require("./dispatcher");
import MatrixClientPeg from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import Modal from './Modal';
import sdk from './index';
import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk';
import dis from './dispatcher';
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
global.mxCalls = {
//room_id: MatrixCall
};
var calls = global.mxCalls;
var ConferenceHandler = null;
const calls = global.mxCalls;
let ConferenceHandler = null;
var audioPromises = {};
const audioPromises = {};
function play(audioId) {
// TODO: Attach an invisible element for this instead
// which listens?
var audio = document.getElementById(audioId);
const audio = document.getElementById(audioId);
if (audio) {
if (audioPromises[audioId]) {
audioPromises[audioId] = audioPromises[audioId].then(()=>{
audio.load();
return audio.play();
});
}
else {
} else {
audioPromises[audioId] = audio.play();
}
}
@ -85,24 +88,67 @@ function play(audioId) {
function pause(audioId) {
// TODO: Attach an invisible element for this instead
// which listens?
var audio = document.getElementById(audioId);
const audio = document.getElementById(audioId);
if (audio) {
if (audioPromises[audioId]) {
audioPromises[audioId] = audioPromises[audioId].then(()=>audio.pause());
}
else {
} else {
// pause doesn't actually return a promise, but might as well do this for symmetry with play();
audioPromises[audioId] = audio.pause();
}
}
}
function _reAttemptCall(call) {
if (call.direction === 'outbound') {
dis.dispatch({
action: 'place_call',
room_id: call.roomId,
type: call.type,
});
} else {
call.answer();
}
}
function _setCallListeners(call) {
call.on("error", function(err) {
console.error("Call error: %s", err);
console.error(err.stack);
call.hangup();
_setCallState(undefined, call.roomId, "ended");
if (err.code === 'unknown_devices') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
title: _t('Call Failed'),
description: _t(
"There are unknown devices in this room: "+
"if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call."
),
button: _t('Review Devices'),
onFinished: function(confirmed) {
if (confirmed) {
const room = MatrixClientPeg.get().getRoom(call.roomId);
showUnknownDeviceDialogForCalls(
MatrixClientPeg.get(),
room,
() => {
_reAttemptCall(call);
},
call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"),
call.direction === 'outbound' ? _t("Call") : _t("Answer"),
);
}
},
});
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Call Failed'),
description: err.message,
});
}
});
call.on("hangup", function() {
_setCallState(undefined, call.roomId, "ended");
@ -113,38 +159,32 @@ function _setCallListeners(call) {
if (newState === "ringing") {
_setCallState(call, call.roomId, "ringing");
pause("ringbackAudio");
}
else if (newState === "invite_sent") {
} else if (newState === "invite_sent") {
_setCallState(call, call.roomId, "ringback");
play("ringbackAudio");
}
else if (newState === "ended" && oldState === "connected") {
} else if (newState === "ended" && oldState === "connected") {
_setCallState(undefined, call.roomId, "ended");
pause("ringbackAudio");
play("callendAudio");
}
else if (newState === "ended" && oldState === "invite_sent" &&
} else if (newState === "ended" && oldState === "invite_sent" &&
(call.hangupParty === "remote" ||
(call.hangupParty === "local" && call.hangupReason === "invite_timeout")
)) {
_setCallState(call, call.roomId, "busy");
pause("ringbackAudio");
play("busyAudio");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Call Timeout",
description: "The remote side failed to pick up."
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
title: _t('Call Timeout'),
description: _t('The remote side failed to pick up') + '.',
});
}
else if (oldState === "invite_sent") {
} else if (oldState === "invite_sent") {
_setCallState(call, call.roomId, "stop_ringback");
pause("ringbackAudio");
}
else if (oldState === "ringing") {
} else if (oldState === "ringing") {
_setCallState(call, call.roomId, "stop_ringing");
pause("ringbackAudio");
}
else if (newState === "connected") {
} else if (newState === "connected") {
_setCallState(call, call.roomId, "connected");
pause("ringbackAudio");
}
@ -153,15 +193,14 @@ 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 %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-"),
);
calls[roomId] = call;
if (status === "ringing") {
play("ringAudio")
}
else if (call && call.call_state === "ringing") {
pause("ringAudio")
play("ringAudio");
} else if (call && call.call_state === "ringing") {
pause("ringAudio");
}
if (call) {
@ -169,30 +208,38 @@ function _setCallState(call, roomId, status) {
}
dis.dispatch({
action: 'call_state',
room_id: roomId
room_id: roomId,
state: status,
});
}
function _onAction(payload) {
function placeCall(newCall) {
_setCallListeners(newCall);
_setCallState(newCall, newCall.roomId, "ringback");
if (payload.type === 'voice') {
newCall.placeVoiceCall();
}
else if (payload.type === 'video') {
} else if (payload.type === 'video') {
newCall.placeVideoCall(
payload.remote_element,
payload.local_element
payload.local_element,
);
}
else if (payload.type === 'screensharing') {
} else if (payload.type === 'screensharing') {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
_setCallState(undefined, newCall.roomId, "ended");
console.log("Can't capture screen: " + screenCapErrorString);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
newCall.placeScreenSharingCall(
payload.remote_element,
payload.local_element
payload.local_element,
);
}
else {
} else {
console.error("Unknown conf call type: %s", payload.type);
}
}
@ -201,9 +248,9 @@ function _onAction(payload) {
case 'place_call':
if (module.exports.getAnyActiveCall()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Existing Call",
description: "You are already in a call."
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Existing Call'),
description: _t('You are already in a call.'),
});
return; // don't allow >1 call to be placed.
}
@ -211,9 +258,9 @@ function _onAction(payload) {
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "VoIP is unsupported",
description: "You cannot place VoIP calls in this browser."
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
return;
}
@ -227,25 +274,21 @@ function _onAction(payload) {
var members = room.getJoinedMembers();
if (members.length <= 1) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
description: "You cannot place a call with yourself."
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'),
});
return;
}
else if (members.length === 2) {
} else if (members.length === 2) {
console.log("Place %s call in %s", payload.type, payload.room_id);
var call = Matrix.createNewMatrixCall(
MatrixClientPeg.get(), payload.room_id
);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call);
}
else { // > 2
} else { // > 2
dis.dispatch({
action: "place_conference_call",
room_id: payload.room_id,
type: payload.type,
remote_element: payload.remote_element,
local_element: payload.local_element
local_element: payload.local_element,
});
}
break;
@ -253,18 +296,16 @@ function _onAction(payload) {
console.log("Place conference call in %s", payload.room_id);
if (!ConferenceHandler) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
description: "Conference calls are not supported in this client"
Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, {
description: _t('Conference calls are not supported in this client'),
});
}
else if (!MatrixClientPeg.get().supportsVoip()) {
} else if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "VoIP is unsupported",
description: "You cannot place VoIP calls in this browser."
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
}
else if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) {
} else if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) {
// Conference calls are implemented by sending the media to central
// server which combines the audio from all the participants together
// into a single stream. This is incompatible with end-to-end encryption
@ -272,26 +313,26 @@ function _onAction(payload) {
// participant.
// Therefore we disable conference calling in E2E rooms.
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
description: "Conference calls are not supported in encrypted rooms",
Modal.createTrackedDialog('Call Handler', 'Conference calls unsupported e2e', ErrorDialog, {
description: _t('Conference calls are not supported in encrypted rooms'),
});
}
else {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Warning!",
description: "Conference calling in Riot is in development and may not be reliable.",
onFinished: confirm=>{
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, {
title: _t('Warning!'),
description: _t('Conference calling is in development and may not be reliable.'),
onFinished: (confirm)=>{
if (confirm) {
ConferenceHandler.createNewMatrixCall(
MatrixClientPeg.get(), payload.room_id
MatrixClientPeg.get(), payload.room_id,
).done(function(call) {
placeCall(call);
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failed to set up conference call",
description: "Conference call failed: " + err,
console.error("Conference call failed: " + err);
Modal.createTrackedDialog('Call Handler', 'Failed to set up conference call', ErrorDialog, {
title: _t('Failed to set up conference call'),
description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''),
});
});
}
@ -332,7 +373,7 @@ function _onAction(payload) {
_setCallState(calls[payload.room_id], payload.room_id, "connected");
dis.dispatch({
action: "view_room",
room_id: payload.room_id
room_id: payload.room_id,
});
break;
}
@ -343,9 +384,9 @@ if (!global.mxCallHandler) {
dis.register(_onAction);
}
var callHandler = {
const callHandler = {
getCallForRoom: function(roomId) {
var call = module.exports.getCall(roomId);
let call = module.exports.getCall(roomId);
if (call) return call;
if (ConferenceHandler) {
@ -361,8 +402,8 @@ var callHandler = {
},
getAnyActiveCall: function() {
var roomsWithCalls = Object.keys(calls);
for (var i = 0; i < roomsWithCalls.length; i++) {
const roomsWithCalls = Object.keys(calls);
for (let i = 0; i < roomsWithCalls.length; i++) {
if (calls[roomsWithCalls[i]] &&
calls[roomsWithCalls[i]].call_state !== "ended") {
return calls[roomsWithCalls[i]];
@ -377,7 +418,7 @@ var callHandler = {
getConferenceHandler: function() {
return ConferenceHandler;
}
},
};
// Only things in here which actually need to be global are the
// calls list (done separately) and making sure we only register

62
src/CallMediaHandler.js Normal file
View file

@ -0,0 +1,62 @@
/*
Copyright 2017 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.
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 Matrix from 'matrix-js-sdk';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
export default {
getDevices: function() {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
const audioIn = [];
const videoIn = [];
if (devices.some((device) => !device.label)) return false;
devices.forEach((device) => {
switch (device.kind) {
case 'audioinput': audioIn.push(device); break;
case 'videoinput': videoIn.push(device); break;
}
});
// console.log("Loaded WebRTC Devices", mediaDevices);
return {
audioinput: audioIn,
videoinput: videoIn,
};
}, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); });
},
loadDevices: function() {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
Matrix.setMatrixCallAudioInput(audioDeviceId);
Matrix.setMatrixCallVideoInput(videoDeviceId);
},
setAudioInput: function(deviceId) {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId);
},
};

View file

@ -0,0 +1,84 @@
//@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 {ContentState, convertToRaw, convertFromRaw} from 'draft-js';
import * as RichText from './RichText';
import Markdown from './Markdown';
import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown';
class HistoryItem {
// Keeping message for backwards-compatibility
message: string;
rawContentState: RawDraftContentState;
format: MessageFormat = 'html';
constructor(contentState: ?ContentState, format: ?MessageFormat) {
this.rawContentState = contentState ? convertToRaw(contentState) : null;
this.format = format;
}
toContentState(outputFormat: MessageFormat): ContentState {
const contentState = convertFromRaw(this.rawContentState);
if (outputFormat === 'markdown') {
if (this.format === 'html') {
return ContentState.createFromText(RichText.stateToMarkdown(contentState));
}
} else {
if (this.format === 'markdown') {
return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML());
}
}
// history item has format === outputFormat
return contentState;
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0;
currentIndex: number = 0;
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)),
);
}
this.lastIndex = this.currentIndex;
}
save(contentState: ContentState, format: MessageFormat) {
const item = new HistoryItem(contentState, format);
this.history.push(item);
this.currentIndex = this.lastIndex + 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item));
}
getItem(offset: number, format: MessageFormat): ?ContentState {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1);
const item = this.history[this.currentIndex];
return item ? item.toContentState(format) : null;
}
}

View file

@ -16,14 +16,15 @@ limitations under the License.
'use strict';
var q = require('q');
var extend = require('./extend');
var dis = require('./dispatcher');
var MatrixClientPeg = require('./MatrixClientPeg');
var sdk = require('./index');
var Modal = require('./Modal');
import Promise from 'bluebird';
const extend = require('./extend');
const dis = require('./dispatcher');
const MatrixClientPeg = require('./MatrixClientPeg');
const sdk = require('./index');
import { _t } from './languageHandler';
const Modal = require('./Modal');
var encrypt = require("browser-encrypt-attachment");
const encrypt = require("browser-encrypt-attachment");
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
require("blueimp-canvas-to-blob");
@ -51,10 +52,10 @@ const MAX_HEIGHT = 600;
* and a thumbnail key.
*/
function createThumbnail(element, inputWidth, inputHeight, mimeType) {
const deferred = q.defer();
const deferred = Promise.defer();
var targetWidth = inputWidth;
var targetHeight = inputHeight;
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
@ -80,7 +81,7 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) {
w: inputWidth,
h: inputHeight,
},
thumbnail: thumbnail
thumbnail: thumbnail,
});
}, mimeType);
@ -94,27 +95,21 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) {
* @return {Promise} A promise that resolves with the html image element.
*/
function loadImageElement(imageFile) {
const deferred = q.defer();
const deferred = Promise.defer();
// Load the file into an html element
const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile);
img.src = objectUrl;
const reader = new FileReader();
reader.onload = function(e) {
img.src = e.target.result;
// Once ready, create a thumbnail
img.onload = function() {
deferred.resolve(img);
};
img.onerror = function(e) {
deferred.reject(e);
};
// Once ready, create a thumbnail
img.onload = function() {
URL.revokeObjectURL(objectUrl);
deferred.resolve(img);
};
reader.onerror = function(e) {
img.onerror = function(e) {
deferred.reject(e);
};
reader.readAsDataURL(imageFile);
return deferred.promise;
}
@ -128,12 +123,12 @@ function loadImageElement(imageFile) {
* @return {Promise} A promise that resolves with the attachment info.
*/
function infoForImageFile(matrixClient, roomId, imageFile) {
var thumbnailType = "image/png";
let thumbnailType = "image/png";
if (imageFile.type == "image/jpeg") {
thumbnailType = "image/jpeg";
}
var imageInfo;
let imageInfo;
return loadImageElement(imageFile).then(function(img) {
return createThumbnail(img, img.width, img.height, thumbnailType);
}).then(function(result) {
@ -153,7 +148,7 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
* @return {Promise} A promise that resolves with the video image element.
*/
function loadVideoElement(videoFile) {
const deferred = q.defer();
const deferred = Promise.defer();
// Load the file into an html element
const video = document.createElement("video");
@ -190,7 +185,7 @@ function loadVideoElement(videoFile) {
function infoForVideoFile(matrixClient, roomId, videoFile) {
const thumbnailType = "image/jpeg";
var videoInfo;
let videoInfo;
return loadVideoElement(videoFile).then(function(video) {
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
}).then(function(result) {
@ -209,7 +204,7 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
* is read.
*/
function readFileAsArrayBuffer(file) {
const deferred = q.defer();
const deferred = Promise.defer();
const reader = new FileReader();
reader.onload = function(e) {
deferred.resolve(e.target.result);
@ -228,11 +223,13 @@ function readFileAsArrayBuffer(file) {
* @param {MatrixClient} matrixClient The matrix client to upload the file with.
* @param {String} roomId The ID of the room being uploaded to.
* @param {File} file The file to upload.
* @param {Function?} progressHandler optional callback to be called when a chunk of
* data is uploaded.
* @return {Promise} A promise that resolves with an object.
* If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key.
*/
function uploadFile(matrixClient, roomId, file) {
function uploadFile(matrixClient, roomId, file, progressHandler) {
if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it.
// First read the file into memory.
@ -244,7 +241,9 @@ function uploadFile(matrixClient, roomId, file) {
const encryptInfo = encryptResult.info;
// Pass the encrypted data as a Blob to the uploader.
const blob = new Blob([encryptResult.data]);
return matrixClient.uploadContent(blob).then(function(url) {
return matrixClient.uploadContent(blob, {
progressHandler: progressHandler,
}).then(function(url) {
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and
// add it under a file key.
@ -256,7 +255,9 @@ function uploadFile(matrixClient, roomId, file) {
});
});
} else {
const basePromise = matrixClient.uploadContent(file);
const basePromise = matrixClient.uploadContent(file, {
progressHandler: progressHandler,
});
const promise1 = basePromise.then(function(url) {
// If the attachment isn't encrypted then include the URL directly.
return {"url": url};
@ -276,10 +277,10 @@ class ContentMessages {
sendContentToRoom(file, roomId, matrixClient) {
const content = {
body: file.name,
body: file.name || 'Attachment',
info: {
size: file.size,
}
},
};
// if we have a mime type for the file, add it to the message metadata
@ -287,13 +288,13 @@ class ContentMessages {
content.info.mimetype = file.type;
}
const def = q.defer();
const def = Promise.defer();
if (file.type.indexOf('image/') == 0) {
content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then(imageInfo=>{
infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{
extend(content.info, imageInfo);
def.resolve();
}, error=>{
}, (error)=>{
console.error(error);
content.msgtype = 'm.file';
def.resolve();
@ -303,10 +304,10 @@ class ContentMessages {
def.resolve();
} else if (file.type.indexOf('video/') == 0) {
content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then(videoInfo=>{
infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{
extend(content.info, videoInfo);
def.resolve();
}, error=>{
}, (error)=>{
content.msgtype = 'm.file';
def.resolve();
});
@ -316,7 +317,7 @@ class ContentMessages {
}
const upload = {
fileName: file.name,
fileName: file.name || 'Attachment',
roomId: roomId,
total: 0,
loaded: 0,
@ -324,43 +325,44 @@ class ContentMessages {
this.inprogress.push(upload);
dis.dispatch({action: 'upload_started'});
var error;
let error;
function onProgress(ev) {
upload.total = ev.total;
upload.loaded = ev.loaded;
dis.dispatch({action: 'upload_progress', upload: upload});
}
return def.promise.then(function() {
// XXX: upload.promise must be the promise that
// is returned by uploadFile as it has an abort()
// method hacked onto it.
upload.promise = uploadFile(
matrixClient, roomId, file
matrixClient, roomId, file, onProgress,
);
return upload.promise.then(function(result) {
content.file = result.file;
content.url = result.url;
});
}).progress(function(ev) {
if (ev) {
upload.total = ev.total;
upload.loaded = ev.loaded;
dis.dispatch({action: 'upload_progress', upload: upload});
}
}).then(function(url) {
return matrixClient.sendMessage(roomId, content);
}, function(err) {
error = err;
if (!upload.canceled) {
var desc = "The file '"+upload.fileName+"' failed to upload.";
let desc = _t('The file \'%(fileName)s\' failed to upload', {fileName: upload.fileName}) + '.';
if (err.http_status == 413) {
desc = "The file '"+upload.fileName+"' exceeds this home server's size limit for uploads";
desc = _t('The file \'%(fileName)s\' exceeds this home server\'s size limit for uploads', {fileName: upload.fileName});
}
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Upload Failed",
description: desc
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
title: _t('Upload Failed'),
description: desc,
});
}
}).finally(() => {
const inprogressKeys = Object.keys(this.inprogress);
for (var i = 0; i < this.inprogress.length; ++i) {
var k = inprogressKeys[i];
for (let i = 0; i < this.inprogress.length; ++i) {
const k = inprogressKeys[i];
if (this.inprogress[k].promise === upload.promise) {
this.inprogress.splice(k, 1);
break;
@ -368,8 +370,7 @@ class ContentMessages {
}
if (error) {
dis.dispatch({action: 'upload_failed', upload: upload});
}
else {
} else {
dis.dispatch({action: 'upload_finished', upload: upload});
}
});
@ -381,9 +382,9 @@ class ContentMessages {
cancelUpload(promise) {
const inprogressKeys = Object.keys(this.inprogress);
var upload;
for (var i = 0; i < this.inprogress.length; ++i) {
var k = inprogressKeys[i];
let upload;
for (let i = 0; i < this.inprogress.length; ++i) {
const k = inprogressKeys[i];
if (this.inprogress[k].promise === promise) {
upload = this.inprogress[k];
break;

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,39 +15,113 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import { _t } from './languageHandler';
var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
module.exports = {
formatDate: function(date) {
// date.toLocaleTimeString is completely system dependent.
// just go 24h for now
function pad(n) {
return (n < 10 ? '0' : '') + n;
}
var now = new Date();
if (date.toDateString() === now.toDateString()) {
return pad(date.getHours()) + ':' + pad(date.getMinutes());
}
else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
return days[date.getDay()] + " " + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
else /* if (now.getFullYear() === date.getFullYear()) */ {
return days[date.getDay()] + ", " + months[date.getMonth()] + " " + date.getDate() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
/*
else {
return days[date.getDay()] + ", " + months[date.getMonth()] + " " + date.getDate() + " " + date.getFullYear() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
*/
},
formatTime: function(date) {
//return pad(date.getHours()) + ':' + pad(date.getMinutes());
return ('00' + date.getHours()).slice(-2) + ':' + ('00' + date.getMinutes()).slice(-2);
}
function getDaysArray() {
return [
_t('Sun'),
_t('Mon'),
_t('Tue'),
_t('Wed'),
_t('Thu'),
_t('Fri'),
_t('Sat'),
];
}
function getMonthsArray() {
return [
_t('Jan'),
_t('Feb'),
_t('Mar'),
_t('Apr'),
_t('May'),
_t('Jun'),
_t('Jul'),
_t('Aug'),
_t('Sep'),
_t('Oct'),
_t('Nov'),
_t('Dec'),
];
}
function pad(n) {
return (n < 10 ? '0' : '') + n;
}
function twelveHourTime(date) {
let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
hours = hours ? hours : 12; // convert 0 -> 12
return `${hours}:${minutes}${ampm}`;
}
export function formatDate(date, showTwelveHour=false) {
const now = new Date();
const days = getDaysArray();
const months = getMonthsArray();
if (date.toDateString() === now.toDateString()) {
return formatTime(date, showTwelveHour);
} else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
// TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s %(time)s', {
weekDayName: days[date.getDay()],
time: formatTime(date, showTwelveHour),
});
} else if (now.getFullYear() === date.getFullYear()) {
// TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', {
weekDayName: days[date.getDay()],
monthName: months[date.getMonth()],
day: date.getDate(),
time: formatTime(date, showTwelveHour),
});
}
return formatFullDate(date, showTwelveHour);
}
export function formatFullDateNoTime(date) {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
weekDayName: days[date.getDay()],
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
});
}
export function formatFullDate(date, showTwelveHour=false) {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
weekDayName: days[date.getDay()],
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
time: formatTime(date, showTwelveHour),
});
}
export function formatTime(date, showTwelveHour=false) {
if (showTwelveHour) {
return twelveHourTime(date);
}
return pad(date.getHours()) + ':' + pad(date.getMinutes());
}
const MILLIS_IN_DAY = 86400000;
export function wantsDateSeparator(prevEventDate, nextEventDate) {
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart
if (Math.abs(prevEventDate.getTime() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
return true;
}
// Compare weekdays
return prevEventDate.getDay() !== nextEventDate.getDay();
}

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var sdk = require('./index');
import sdk from './index';
function isMatch(query, name, uid) {
query = query.toLowerCase();
@ -33,8 +32,8 @@ function isMatch(query, name, uid) {
}
// split spaces in name and try matching constituent parts
var parts = name.split(" ");
for (var i = 0; i < parts.length; i++) {
const parts = name.split(" ");
for (let i = 0; i < parts.length; i++) {
if (parts[i].indexOf(query) === 0) {
return true;
}
@ -67,7 +66,7 @@ class Entity {
class MemberEntity extends Entity {
getJsx() {
var MemberTile = sdk.getComponent("rooms.MemberTile");
const MemberTile = sdk.getComponent("rooms.MemberTile");
return (
<MemberTile key={this.model.userId} member={this.model} />
);
@ -84,6 +83,7 @@ class UserEntity extends Entity {
super(model);
this.showInviteButton = Boolean(showInviteButton);
this.inviteFn = inviteFn;
this.onClick = this.onClick.bind(this);
}
onClick() {
@ -93,15 +93,15 @@ class UserEntity extends Entity {
}
getJsx() {
var UserTile = sdk.getComponent("rooms.UserTile");
const UserTile = sdk.getComponent("rooms.UserTile");
return (
<UserTile key={this.model.userId} user={this.model}
showInviteButton={this.showInviteButton} onClick={this.onClick.bind(this)} />
showInviteButton={this.showInviteButton} onClick={this.onClick} />
);
}
matches(queryString) {
var name = this.model.displayName || this.model.userId;
const name = this.model.displayName || this.model.userId;
return isMatch(queryString, name, this.model.userId);
}
}
@ -109,7 +109,7 @@ class UserEntity extends Entity {
module.exports = {
newEntity: function(jsx, matchFn) {
var entity = new Entity();
const entity = new Entity();
entity.getJsx = function() {
return jsx;
};
@ -136,6 +136,6 @@ module.exports = {
fromUsers: function(users, showInviteButton, inviteFn) {
return users.map(function(u) {
return new UserEntity(u, showInviteButton, inviteFn);
})
}
});
},
};

156
src/GroupAddressPicker.js Normal file
View file

@ -0,0 +1,156 @@
/*
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 './';
import MultiInviter from './utils/MultiInviter';
import { _t } from './languageHandler';
import MatrixClientPeg from './MatrixClientPeg';
import GroupStoreCache from './stores/GroupStoreCache';
export function showGroupInviteDialog(groupId) {
const description = <div>
<div>{ _t("Who would you like to add to this community?") }</div>
<div className="warning">
{ _t(
"Warning: any person you add to a community will be publicly "+
"visible to anyone who knows the community ID",
) }
</div>
</div>;
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, {
title: _t("Invite new community members"),
description: description,
placeholder: _t("Name or matrix ID"),
button: _t("Invite to Community"),
validAddressTypes: ['mx-user-id'],
onFinished: (success, addrs) => {
if (!success) return;
_onGroupInviteFinished(groupId, addrs);
},
});
}
export function showGroupAddRoomDialog(groupId) {
return new Promise((resolve, reject) => {
let addRoomsPublicly = false;
const onCheckboxClicked = (e) => {
addRoomsPublicly = e.target.checked;
};
const description = <div>
<div>{ _t("Which rooms would you like to add to this community?") }</div>
</div>;
const checkboxContainer = <label className="mx_GroupAddressPicker_checkboxContainer">
<input type="checkbox" onClick={onCheckboxClicked} />
<div>
{ _t("Show these rooms to non-members on the community page and room list?") }
</div>
</label>;
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, {
title: _t("Add rooms to the community"),
description: description,
extraNode: checkboxContainer,
placeholder: _t("Room name or alias"),
button: _t("Add to community"),
pickerType: 'room',
validAddressTypes: ['mx-room-id'],
onFinished: (success, addrs) => {
if (!success) return;
_onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly).then(resolve, reject);
},
});
});
}
function _onGroupInviteFinished(groupId, addrs) {
const multiInviter = new MultiInviter(groupId);
const addrTexts = addrs.map((addr) => addr.address);
multiInviter.invite(addrTexts).then((completionStates) => {
// Show user any errors
const errorList = [];
for (const addr of Object.keys(completionStates)) {
if (addrs[addr] === "error") {
errorList.push(addr);
}
}
if (errorList.length > 0) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, {
title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}),
description: errorList.join(", "),
});
}
}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, {
title: _t("Failed to invite users to community"),
description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}),
});
});
}
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const matrixClient = MatrixClientPeg.get();
const groupStore = GroupStoreCache.getGroupStore(groupId);
const errorList = [];
return Promise.all(addrs.map((addr) => {
return groupStore
.addRoomToGroup(addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); })
.then(() => {
const roomId = addr.address;
const room = matrixClient.getRoom(roomId);
// Can the user change related groups?
if (!room || !room.currentState.mayClientSendStateEvent("m.room.related_groups", matrixClient)) {
return;
}
// Get the related groups
const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', '');
const groups = relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [];
// Add this group as related
if (!groups.includes(groupId)) {
groups.push(groupId);
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, '');
}
}).reflect();
})).then(() => {
if (errorList.length === 0) {
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog(
'Failed to add the following room to the group',
'', ErrorDialog,
{
title: _t(
"Failed to add the following rooms to %(groupId)s:",
{groupId},
),
description: errorList.join(", "),
});
});
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -16,40 +17,67 @@ limitations under the License.
'use strict';
var React = require('react');
var sanitizeHtml = require('sanitize-html');
var highlight = require('highlight.js');
var linkifyMatrix = require('./linkify-matrix');
const React = require('react');
const sanitizeHtml = require('sanitize-html');
const highlight = require('highlight.js');
const linkifyMatrix = require('./linkify-matrix');
import escape from 'lodash/escape';
import emojione from 'emojione';
import classNames from 'classnames';
import MatrixClientPeg from './MatrixClientPeg';
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';
// 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
// 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)
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
// And this is emojione's complete regex
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
/*
* Return true if the given string contains emoji
* Uses a much, much simpler regex than emojione'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) {
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
*/
export function unicodeToImage(str) {
let replaceWith, unicode, alt;
function unicodeToImage(str) {
let replaceWith, unicode, alt, short, 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 {
} 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
alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode];
const title = mappedUnicode[unicode];
replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${unicode}.svg${emojione.cacheBustParam}"/>`;
replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${fname}.svg${emojione.cacheBustParam}"/>`;
return replaceWith;
}
});
@ -57,7 +85,30 @@ export function unicodeToImage(str) {
return str;
}
export function stripParagraphs(html: string): string {
/**
* Given one or more unicode characters (represented by unicode
* character number), return an image node with the corresponding
* emoji.
*
* @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
*/
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 processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = html;
@ -66,10 +117,21 @@ export function stripParagraphs(html: string): string {
}
let contentHTML = "";
for (let i=0; i<contentDiv.children.length; i++) {
for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML + '<br />';
contentHTML += element.innerHTML;
// Don't add a <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> is
// redundant. This is a workaround for a bug in draft-js-export-html:
// https://github.com/sstur/draft-js-export-html/issues/62
contentHTML += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} else {
const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true));
@ -80,32 +142,39 @@ export function stripParagraphs(html: string): string {
return contentHTML;
}
var sanitizeHtmlParams = {
/*
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
*/
export function sanitizedHtmlNode(insaneHtml) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
const sanitizeHtmlParams = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
// deliberately no h1/h2 to stop people shouting.
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre'
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
],
allowedAttributes: {
// custom ones first:
font: [ 'color' ], // custom to matrix
a: [ 'href', 'name', 'target', 'rel' ], // remote target: custom to matrix
// We don't currently allow img itself by default, but this
// would make sense if we did
img: [ 'src' ],
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
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
},
// Lots of these won't come up by default because we don't allow them
selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ],
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ],
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'],
// DO NOT USE. sanitize-html allows all URL starting with '//'
// so this will always allow links to whatever scheme the
// host page is served over.
allowedSchemesByTag: {},
allowProtocolRelative: false,
transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
@ -113,28 +182,86 @@ var sanitizeHtmlParams = {
if (attribs.href) {
attribs.target = '_blank'; // by default
var m;
let m;
// FIXME: horrible duplication with linkify-matrix
m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN);
if (m) {
attribs.href = m[1];
delete attribs.target;
}
m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN);
if (m) {
var entity = m[1];
if (entity[0] === '@') {
attribs.href = '#/user/' + entity;
} else {
m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN);
if (m) {
const entity = m[1];
if (entity[0] === '@') {
attribs.href = '#/user/' + entity;
} else if (entity[0] === '#' || entity[0] === '!') {
attribs.href = '#/room/' + entity;
}
delete attribs.target;
}
else if (entity[0] === '#' || entity[0] === '!') {
attribs.href = '#/room/' + entity;
}
delete attribs.target;
}
}
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName: tagName, attribs : attribs };
return { tagName: tagName, attribs: attribs };
},
'img': function(tagName, attribs) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
if (!attribs.src || !attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}};
}
attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
attribs.src,
attribs.width || 800,
attribs.height || 600,
);
return { tagName: tagName, attribs: attribs };
},
'code': function(tagName, attribs) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-');
});
attribs.class = classes.join(' ');
}
return {
tagName: tagName,
attribs: attribs,
};
},
'*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
// Sanitise and transform data-mx-color and data-mx-bg-color to their CSS
// equivalents
const customCSSMapper = {
'data-mx-color': 'color',
'data-mx-bg-color': 'background-color',
// $customAttributeKey: $cssAttributeKey
};
let style = "";
Object.keys(customCSSMapper).forEach((customAttributeKey) => {
const cssAttributeKey = customCSSMapper[customAttributeKey];
const customAttributeValue = attribs[customAttributeKey];
if (customAttributeValue &&
typeof customAttributeValue === 'string' &&
COLOR_REGEX.test(customAttributeValue)
) {
style += cssAttributeKey + ":" + customAttributeValue + ";";
delete attribs[customAttributeKey];
}
});
if (style) {
attribs.style = style;
}
return { tagName: tagName, attribs: attribs };
},
},
};
@ -157,11 +284,11 @@ class BaseHighlighter {
* TextHighlighter).
*/
applyHighlights(safeSnippet, safeHighlights) {
var lastOffset = 0;
var offset;
var nodes = [];
let lastOffset = 0;
let offset;
let nodes = [];
var safeHighlight = safeHighlights[0];
const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble
if (offset > lastOffset) {
@ -171,7 +298,7 @@ class BaseHighlighter {
// do highlight. use the original string rather than safeHighlight
// to preserve the original casing.
var endOffset = offset + safeHighlight.length;
const endOffset = offset + safeHighlight.length;
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
lastOffset = endOffset;
@ -189,8 +316,7 @@ class BaseHighlighter {
if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
}
else {
} else {
// no more highlights to be found, just return the unhighlighted string
return [this._processSnippet(safeSnippet, false)];
}
@ -211,7 +337,7 @@ class HtmlHighlighter extends BaseHighlighter {
return snippet;
}
var span = "<span class=\""+this.highlightClass+"\">"
let span = "<span class=\""+this.highlightClass+"\">"
+ snippet + "</span>";
if (this.highlightLink) {
@ -236,15 +362,15 @@ class TextHighlighter extends BaseHighlighter {
* returns a React node
*/
_processSnippet(snippet, highlight) {
var key = this._key++;
const key = this._key++;
var node =
<span key={key} className={highlight ? this.highlightClass : null }>
let node =
<span key={key} className={highlight ? this.highlightClass : null}>
{ snippet }
</span>;
if (highlight && this.highlightLink) {
node = <a key={key} href={this.highlightLink}>{node}</a>;
node = <a key={key} href={this.highlightLink}>{ node }</a>;
}
return node;
@ -259,22 +385,23 @@ class TextHighlighter extends BaseHighlighter {
* highlights: optional list of words to highlight, ordered by longest word first
*
* opts.highlightLink: optional href to add to highlighted words
* opts.disableBigEmoji: optional argument to disable the big emoji class.
*/
export function bodyToHtml(content, highlights, opts) {
opts = opts || {};
export function bodyToHtml(content, highlights, opts={}) {
const isHtml = (content.format === "org.matrix.custom.html");
const body = isHtml ? content.formatted_body : escape(content.body);
var isHtml = (content.format === "org.matrix.custom.html");
let body = isHtml ? content.formatted_body : escape(content.body);
let bodyHasEmoji = false;
var safeBody;
let safeBody;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
// by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either
try {
if (highlights && highlights.length > 0) {
var highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
var safeHighlights = highlights.map(function(highlight) {
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
const safeHighlights = highlights.map(function(highlight) {
return sanitizeHtml(highlight, sanitizeHtmlParams);
});
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure.
@ -283,23 +410,26 @@ export function bodyToHtml(content, highlights, opts) {
};
}
safeBody = sanitizeHtml(body, sanitizeHtmlParams);
safeBody = unicodeToImage(safeBody);
}
finally {
bodyHasEmoji = containsEmoji(body);
if (bodyHasEmoji) safeBody = unicodeToImage(safeBody);
} finally {
delete sanitizeHtmlParams.textFilter;
}
EMOJI_REGEX.lastIndex = 0;
let contentBodyTrimmed = content.body.trim();
let match = EMOJI_REGEX.exec(contentBodyTrimmed);
let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
EMOJI_REGEX.lastIndex = 0;
const contentBodyTrimmed = content.body !== undefined ? content.body.trim() : '';
const match = EMOJI_REGEX.exec(contentBodyTrimmed);
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
}
const className = classNames({
'mx_EventTile_body': true,
'mx_EventTile_bigEmoji': emojiBody,
'markdown-body': isHtml,
});
return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} />;
return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" />;
}
export function emojifyText(text) {

View file

@ -42,16 +42,15 @@ module.exports = {
// no scaling needs to be applied
return fullHeight;
}
var widthMulti = thumbWidth / fullWidth;
var heightMulti = thumbHeight / fullHeight;
const widthMulti = thumbWidth / fullWidth;
const heightMulti = thumbHeight / fullHeight;
if (widthMulti < heightMulti) {
// width is the dominant dimension so scaling will be fixed on that
return Math.floor(widthMulti * fullHeight);
}
else {
} else {
// height is the dominant dimension so scaling will be fixed on that
return Math.floor(heightMulti * fullHeight);
}
},
}
};

View file

@ -1,83 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import MultiInviter from './utils/MultiInviter';
const emailRegex = /^\S+@\S+\.\S+$/;
export function getAddressType(inputText) {
const isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText);
const isMatrixId = inputText[0] === '@' && inputText.indexOf(":") > 0;
// sanity check the input for user IDs
if (isEmailAddress) {
return 'email';
} else if (isMatrixId) {
return 'mx';
} else {
return null;
}
}
export function inviteToRoom(roomId, addr) {
const addrType = getAddressType(addr);
if (addrType == 'email') {
return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType == 'mx') {
return MatrixClientPeg.get().invite(roomId, addr);
} else {
throw new Error('Unsupported address');
}
}
/**
* Invites multiple addresses to a room
* Simpler interface to utils/MultiInviter but with
* no option to cancel.
*
* @param {roomId} The ID of the room to invite to
* @param {array} Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @returns Promise
*/
export function inviteMultipleToRoom(roomId, addrs) {
this.inviter = new MultiInviter(roomId);
return this.inviter.invite(addrs);
}
/**
* Checks is the supplied address is valid
*
* @param {addr} The mx userId or email address to check
* @returns true, false, or null for unsure
*/
export function isValidAddress(addr) {
// Check if the addr is a valid type
var addrType = this.getAddressType(addr);
if (addrType === "mx") {
let user = MatrixClientPeg.get().getUser(addr);
if (user) {
return true;
} else {
return null;
}
} else if (addrType === "email") {
return true;
} else {
return false;
}
}

138
src/KeyRequestHandler.js Normal file
View file

@ -0,0 +1,138 @@
/*
Copyright 2017 Vector Creations 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 sdk from './index';
import Modal from './Modal';
export default class KeyRequestHandler {
constructor(matrixClient) {
this._matrixClient = matrixClient;
// the user/device for which we currently have a dialog open
this._currentUser = null;
this._currentDevice = null;
// userId -> deviceId -> [keyRequest]
this._pendingKeyRequests = Object.create(null);
}
handleKeyRequest(keyRequest) {
const userId = keyRequest.userId;
const deviceId = keyRequest.deviceId;
const requestId = keyRequest.requestId;
if (!this._pendingKeyRequests[userId]) {
this._pendingKeyRequests[userId] = Object.create(null);
}
if (!this._pendingKeyRequests[userId][deviceId]) {
this._pendingKeyRequests[userId][deviceId] = [];
}
// check if we already have this request
const requests = this._pendingKeyRequests[userId][deviceId];
if (requests.find((r) => r.requestId === requestId)) {
console.log("Already have this key request, ignoring");
return;
}
requests.push(keyRequest);
if (this._currentUser) {
// ignore for now
console.log("Key request, but we already have a dialog open");
return;
}
this._processNextRequest();
}
handleKeyRequestCancellation(cancellation) {
// see if we can find the request in the queue
const userId = cancellation.userId;
const deviceId = cancellation.deviceId;
const requestId = cancellation.requestId;
if (userId === this._currentUser && deviceId === this._currentDevice) {
console.log(
"room key request cancellation for the user we currently have a"
+ " dialog open for",
);
// TODO: update the dialog. For now, we just ignore the
// cancellation.
return;
}
if (!this._pendingKeyRequests[userId]) {
return;
}
const requests = this._pendingKeyRequests[userId][deviceId];
if (!requests) {
return;
}
const idx = requests.findIndex((r) => r.requestId === requestId);
if (idx < 0) {
return;
}
console.log("Forgetting room key request");
requests.splice(idx, 1);
if (requests.length === 0) {
delete this._pendingKeyRequests[userId][deviceId];
if (Object.keys(this._pendingKeyRequests[userId]).length === 0) {
delete this._pendingKeyRequests[userId];
}
}
}
_processNextRequest() {
const userId = Object.keys(this._pendingKeyRequests)[0];
if (!userId) {
return;
}
const deviceId = Object.keys(this._pendingKeyRequests[userId])[0];
if (!deviceId) {
return;
}
console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`);
const finished = (r) => {
this._currentUser = null;
this._currentDevice = null;
if (r) {
for (const req of this._pendingKeyRequests[userId][deviceId]) {
req.share();
}
}
delete this._pendingKeyRequests[userId][deviceId];
if (Object.keys(this._pendingKeyRequests[userId]).length === 0) {
delete this._pendingKeyRequests[userId];
}
this._processNextRequest();
};
const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog");
Modal.createTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, {
matrixClient: this._matrixClient,
userId: userId,
deviceId: deviceId,
onFinished: finished,
});
this._currentUser = userId;
this._currentDevice = deviceId;
}
}

79
src/Keyboard.js Normal file
View file

@ -0,0 +1,79 @@
/*
Copyright 2016 OpenMarket Ltd
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.
*/
/* a selection of key codes, as used in KeyboardEvent.keyCode */
export const KeyCode = {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
SHIFT: 16,
ESCAPE: 27,
SPACE: 32,
PAGE_UP: 33,
PAGE_DOWN: 34,
END: 35,
HOME: 36,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
DELETE: 46,
KEY_A: 65,
KEY_B: 66,
KEY_C: 67,
KEY_D: 68,
KEY_E: 69,
KEY_F: 70,
KEY_G: 71,
KEY_H: 72,
KEY_I: 73,
KEY_J: 74,
KEY_K: 75,
KEY_L: 76,
KEY_M: 77,
KEY_N: 78,
KEY_O: 79,
KEY_P: 80,
KEY_Q: 81,
KEY_R: 82,
KEY_S: 83,
KEY_T: 84,
KEY_U: 85,
KEY_V: 86,
KEY_W: 87,
KEY_X: 88,
KEY_Y: 89,
KEY_Z: 90,
};
export function isOnlyCtrlOrCmdKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
}
export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey;
} else {
return ev.ctrlKey && !ev.altKey && !ev.metaKey;
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,42 +15,38 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import q from 'q';
import Promise from 'bluebird';
import Matrix from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg';
import Notifier from './Notifier'
import createMatrixClient from './utils/createMatrixClient';
import Analytics from './Analytics';
import Notifier from './Notifier';
import UserActivity from './UserActivity';
import Presence from './Presence';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import RtsClient from './RtsClient';
import Modal from './Modal';
import sdk from './index';
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
* a number of things:
*
* 0. if it looks like we are in the middle of a registration process, it does
* nothing.
*
* 1. if we have a loginToken in the (real) query params, it uses that to log
* in.
*
* 2. if we have a guest access token in the fragment query params, it uses
* 1. if we have a guest access token in the fragment query params, it uses
* that.
*
* 3. if an access token is stored in local storage (from a previous session),
* 2. if an access token is stored in local storage (from a previous session),
* it uses that.
*
* 4. it attempts to auto-register as a guest user.
* 3. it attempts to auto-register as a guest user.
*
* If any of steps 1-4 are successful, it will call {setLoggedIn}, which in
* If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in
* turn will raise on_logged_in and will_start_client events.
*
* It returns a promise which resolves when the above process completes.
*
* @param {object} opts.realQueryParams: string->string map of the
* query-parameters extracted from the real query-string of the starting
* URI.
* @param {object} opts
*
* @param {object} opts.fragmentQueryParams: string->string map of the
* query-parameters extracted from the #-fragment of the starting URI.
@ -63,66 +60,72 @@ import DMRoomMap from './utils/DMRoomMap';
* @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is
* true; defines the IS to use.
*
* @returns {Promise} a promise which resolves when the above process completes.
* Resolves to `true` if we ended up starting a session, or `false` if we
* failed.
*/
export function loadSession(opts) {
const realQueryParams = opts.realQueryParams || {};
const fragmentQueryParams = opts.fragmentQueryParams || {};
let enableGuest = opts.enableGuest || false;
const guestHsUrl = opts.guestHsUrl;
const guestIsUrl = opts.guestIsUrl;
const defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
if (fragmentQueryParams.client_secret && fragmentQueryParams.sid) {
// this happens during email validation: the email contains a link to the
// IS, which in turn redirects back to vector. We let MatrixChat create a
// Registration component which completes the next stage of registration.
console.log("Not registering as guest: registration already in progress.");
return q();
}
if (!guestHsUrl) {
console.warn("Cannot enable guest access: can't determine HS URL to use");
enableGuest = false;
}
if (realQueryParams.loginToken) {
if (!realQueryParams.homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use");
} else {
return _loginWithToken(realQueryParams, defaultDeviceDisplayName);
}
}
if (enableGuest &&
fragmentQueryParams.guest_user_id &&
fragmentQueryParams.guest_access_token
) {
console.log("Using guest access credentials");
setLoggedIn({
return _doSetLoggedIn({
userId: fragmentQueryParams.guest_user_id,
accessToken: fragmentQueryParams.guest_access_token,
homeserverUrl: guestHsUrl,
identityServerUrl: guestIsUrl,
guest: true,
});
return q();
}, true).then(() => true);
}
if (_restoreFromLocalStorage()) {
return q();
}
return _restoreFromLocalStorage().then((success) => {
if (success) {
return true;
}
if (enableGuest) {
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
}
if (enableGuest) {
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
}
// fall back to login screen
return q();
// fall back to login screen
return false;
});
}
function _loginWithToken(queryParams, defaultDeviceDisplayName) {
/**
* @param {Object} queryParams string->string map of the
* query-parameters extracted from the real query-string of the starting
* URI.
*
* @param {String} defaultDeviceDisplayName
*
* @returns {Promise} promise which resolves to true if we completed the token
* login, else false
*/
export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
if (!queryParams.loginToken) {
return Promise.resolve(false);
}
if (!queryParams.homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use");
return Promise.resolve(false);
}
// create a temporary MatrixClient to do the login
var client = Matrix.createClient({
const client = Matrix.createClient({
baseUrl: queryParams.homeserver,
});
@ -133,28 +136,32 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) {
},
).then(function(data) {
console.log("Logged in with token");
setLoggedIn({
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
homeserverUrl: queryParams.homeserver,
identityServerUrl: queryParams.identityServer,
guest: false,
})
}, (err) => {
return _clearStorage().then(() => {
_persistCredentialsToLocalStorage({
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
homeserverUrl: queryParams.homeserver,
identityServerUrl: queryParams.identityServer,
guest: false,
});
return true;
});
}).catch((err) => {
console.error("Failed to log in with login token: " + err + " " +
err.data);
return false;
});
}
function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
console.log("Doing guest login on %s", hsUrl);
console.log(`Doing guest login on ${hsUrl}`);
// TODO: we should probably de-duplicate this and Signup.Login.loginAsGuest.
// TODO: we should probably de-duplicate this and Login.loginAsGuest.
// Not really sure where the right home for it is.
// create a temporary MatrixClient to do the login
var client = Matrix.createClient({
const client = Matrix.createClient({
baseUrl: hsUrl,
});
@ -163,119 +170,227 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
initial_device_display_name: defaultDeviceDisplayName,
},
}).then((creds) => {
console.log("Registered as guest: %s", creds.user_id);
setLoggedIn({
console.log(`Registered as guest: ${creds.user_id}`);
return _doSetLoggedIn({
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
guest: true,
});
}, true).then(() => true);
}, (err) => {
console.error("Failed to register as guest: " + err + " " + err.data);
return false;
});
}
// returns true if a session is found in localstorage
// returns a promise which resolves to true if a session is found in
// localstorage
//
// N.B. Lifecycle.js should not maintain any further localStorage state, we
// are moving towards using SessionStore to keep track of state related
// to the current session (which is typically backed by localStorage).
//
// 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. teamToken, isGuest etc.)
function _restoreFromLocalStorage() {
if (!localStorage) {
return false;
return Promise.resolve(false);
}
const hs_url = localStorage.getItem("mx_hs_url");
const is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const access_token = localStorage.getItem("mx_access_token");
const user_id = localStorage.getItem("mx_user_id");
const device_id = localStorage.getItem("mx_device_id");
const hsUrl = localStorage.getItem("mx_hs_url");
const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const accessToken = localStorage.getItem("mx_access_token");
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
let is_guest;
let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) {
is_guest = localStorage.getItem("mx_is_guest") === "true";
isGuest = localStorage.getItem("mx_is_guest") === "true";
} else {
// legacy key name
is_guest = localStorage.getItem("matrix-is-guest") === "true";
isGuest = localStorage.getItem("matrix-is-guest") === "true";
}
if (access_token && user_id && hs_url) {
console.log("Restoring session for %s", user_id);
if (accessToken && userId && hsUrl) {
console.log(`Restoring session for ${userId}`);
try {
setLoggedIn({
userId: user_id,
deviceId: device_id,
accessToken: access_token,
homeserverUrl: hs_url,
identityServerUrl: is_url,
guest: is_guest,
});
return true;
return _doSetLoggedIn({
userId: userId,
deviceId: deviceId,
accessToken: accessToken,
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
guest: isGuest,
}, false).then(() => true);
} catch (e) {
console.log("Unable to restore session", e);
var msg = e.message;
if (msg == "OLM.BAD_LEGACY_ACCOUNT_PICKLE") {
msg = "You need to log back in to generate end-to-end encryption keys "
+ "for this device and submit the public key to your homeserver. "
+ "This is a once off; sorry for the inconvenience.";
}
// don't leak things into the new session
_clearLocalStorage();
throw new Error("Unable to restore previous session: " + msg);
return _handleRestoreFailure(e);
}
} else {
console.log("No previous session found.");
return false;
return Promise.resolve(false);
}
}
function _handleRestoreFailure(e) {
console.log("Unable to restore session", e);
const def = Promise.defer();
const SessionRestoreErrorDialog =
sdk.getComponent('views.dialogs.SessionRestoreErrorDialog');
Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
error: e.message,
onFinished: (success) => {
def.resolve(success);
},
});
return def.promise.then((success) => {
if (success) {
// user clicked continue.
_clearStorage();
return false;
}
// try, try again
return _restoreFromLocalStorage();
});
}
let rtsClient = null;
export function initRtsClient(url) {
if (url) {
rtsClient = new RtsClient(url);
} else {
rtsClient = null;
}
}
/**
* Transitions to a logged-in state using the given credentials
* Transitions to a logged-in state using the given credentials.
*
* Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in.
*
* Also stops the old MatrixClient and clears old credentials/etc out of
* storage before starting the new client.
*
* @param {MatrixClientCreds} credentials The credentials to use
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
export function setLoggedIn(credentials) {
credentials.guest = Boolean(credentials.guest);
console.log("setLoggedIn => %s (guest=%s) hs=%s",
credentials.userId, credentials.guest,
credentials.homeserverUrl);
stopMatrixClient();
return _doSetLoggedIn(credentials, true);
}
/**
* fires on_logging_in, optionally clears localstorage, persists new credentials
* to localstorage, starts the new client.
*
* @param {MatrixClientCreds} credentials
* @param {Boolean} clearStorage
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
async function _doSetLoggedIn(credentials, clearStorage) {
credentials.guest = Boolean(credentials.guest);
console.log(
"setLoggedIn: mxid: " + credentials.userId +
" deviceId: " + credentials.deviceId +
" guest: " + credentials.guest +
" hs: " + credentials.homeserverUrl,
);
// This is dispatched to indicate that the user is still in the process of logging in
// because `teamPromise` may take some time to resolve, breaking the assumption that
// `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms
// later than MatrixChat might assume.
//
// we fire it *synchronously* to make sure it fires before on_logged_in.
// (dis.dispatch uses `setTimeout`, which does not guarantee ordering.)
dis.dispatch({action: 'on_logging_in'}, true);
if (clearStorage) {
await _clearStorage();
}
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl);
// Resolves by default
let teamPromise = Promise.resolve(null);
// persist the session
if (localStorage) {
try {
localStorage.setItem("mx_hs_url", credentials.homeserverUrl);
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));
_persistCredentialsToLocalStorage(credentials);
// if we didn't get a deviceId from the login, leave mx_device_id unset,
// rather than setting it to "undefined".
//
// (in this case MatrixClient doesn't bother with the crypto stuff
// - that's fine for us).
if (credentials.deviceId) {
localStorage.setItem("mx_device_id", credentials.deviceId);
// The user registered as a PWLU (PassWord-Less User), the generated password
// is cached here such that the user can change it at a later time.
if (credentials.password) {
// Update SessionStore
dis.dispatch({
action: 'cached_password',
cachedPassword: credentials.password,
});
}
console.log("Session persisted for %s", credentials.userId);
} catch (e) {
console.warn("Error using local storage: can't persist session!", e);
}
if (rtsClient && !credentials.guest) {
teamPromise = rtsClient.login(credentials.userId).then((body) => {
if (body.team_token) {
localStorage.setItem("mx_team_token", body.team_token);
}
return body.team_token;
}, (err) => {
console.warn(`Failed to get team token on login: ${err}` );
return null;
});
}
} else {
console.warn("No local storage available: can't persist session!");
}
MatrixClientPeg.replaceUsingCreds(credentials);
dis.dispatch({action: 'on_logged_in'});
teamPromise.then((teamToken) => {
dis.dispatch({action: 'on_logged_in', teamToken: teamToken});
});
startMatrixClient();
return MatrixClientPeg.get();
}
function _persistCredentialsToLocalStorage(credentials) {
localStorage.setItem("mx_hs_url", credentials.homeserverUrl);
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));
// if we didn't get a deviceId from the login, leave mx_device_id unset,
// rather than setting it to "undefined".
//
// (in this case MatrixClient doesn't bother with the crypto stuff
// - that's fine for us).
if (credentials.deviceId) {
localStorage.setItem("mx_device_id", credentials.deviceId);
}
console.log(`Session persisted for ${credentials.userId}`);
}
/**
* Logs the current session out and transitions to the logged-out state
*/
export function logout() {
if (!MatrixClientPeg.get()) return;
if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session
@ -289,7 +404,7 @@ export function logout() {
return;
}
return MatrixClientPeg.get().logout().then(onLoggedOut,
MatrixClientPeg.get().logout().then(onLoggedOut,
(err) => {
// Just throwing an error here is going to be very unhelpful
// if you're trying to log out because your server's down and
@ -300,15 +415,17 @@ export function logout() {
// change your password).
console.log("Failed to call logout API: token will not be invalidated");
onLoggedOut();
}
);
},
).done();
}
/**
* Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in.
*/
export function startMatrixClient() {
function startMatrixClient() {
console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used
// to add listeners for the 'sync' event so otherwise we'd have
// a race condition (and we need to dispatch synchronously for this
@ -321,43 +438,58 @@ export function startMatrixClient() {
DMRoomMap.makeShared().start();
MatrixClientPeg.start();
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
dis.dispatch({action: 'client_started'});
}
/*
* Stops a running client and all related services, used after
* a session has been logged out / ended.
* Stops a running client and all related services, and clears persistent
* storage. Used after a session has been logged out.
*/
export function onLoggedOut() {
_clearLocalStorage();
stopMatrixClient();
_clearStorage().done();
dis.dispatch({action: 'on_logged_out'});
}
function _clearLocalStorage() {
if (!window.localStorage) {
return;
}
const hsUrl = window.localStorage.getItem("mx_hs_url");
const isUrl = window.localStorage.getItem("mx_is_url");
window.localStorage.clear();
/**
* @returns {Promise} promise which resolves once the stores have been cleared
*/
function _clearStorage() {
Analytics.logout();
// preserve our HS & IS URLs for convenience
// N.B. we cache them in hsUrl/isUrl and can't really inline them
// as getCurrentHsUrl() may call through to localStorage.
// NB. We do clear the device ID (as well as all the settings)
if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl);
if (isUrl) window.localStorage.setItem("mx_is_url", isUrl);
if (window.localStorage) {
const hsUrl = window.localStorage.getItem("mx_hs_url");
const isUrl = window.localStorage.getItem("mx_is_url");
window.localStorage.clear();
// preserve our HS & IS URLs for convenience
// N.B. we cache them in hsUrl/isUrl and can't really inline them
// as getCurrentHsUrl() may call through to localStorage.
// NB. We do clear the device ID (as well as all the settings)
if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl);
if (isUrl) window.localStorage.setItem("mx_is_url", isUrl);
}
// create a temporary client to clear out the persistent stores.
const cli = createMatrixClient({
// we'll never make any requests, so can pass a bogus HS URL
baseUrl: "",
});
return cli.clearStores();
}
/**
* Stop all the background processes related to the current client
* Stop all the background processes related to the current client.
*/
export function stopMatrixClient() {
Notifier.stop();
UserActivity.stop();
Presence.stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop();
var cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.get();
if (cli) {
cli.stopClient();
cli.removeAllListeners();

243
src/Login.js Normal file
View file

@ -0,0 +1,243 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations 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 Matrix from "matrix-js-sdk";
import { _t } from "./languageHandler";
import Promise from 'bluebird';
import url from 'url';
export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
this._hsUrl = hsUrl;
this._isUrl = isUrl;
this._fallbackHsUrl = fallbackHsUrl;
this._currentFlowIndex = 0;
this._flows = [];
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
}
getHomeserverUrl() {
return this._hsUrl;
}
getIdentityServerUrl() {
return this._isUrl;
}
setHomeserverUrl(hsUrl) {
this._hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._isUrl = isUrl;
}
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
*/
_createTemporaryClient() {
return Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
});
}
getFlows() {
const self = this;
const client = this._createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
// technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here.
return self._flows;
});
}
chooseFlow(flowIndex) {
this._currentFlowIndex = flowIndex;
}
getCurrentFlowStep() {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
const flowStep = this._flows[this._currentFlowIndex];
return flowStep ? flowStep.type : null;
}
loginAsGuest() {
const client = this._createTemporaryClient();
return client.registerGuest({
body: {
initial_device_display_name: this._defaultDeviceDisplayName,
},
}).then((creds) => {
return {
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
homeserverUrl: this._hsUrl,
identityServerUrl: this._isUrl,
guest: true,
};
}, (error) => {
throw error;
});
}
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
const self = this;
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 = {
password: pass,
identifier: identifier,
initial_device_display_name: this._defaultDeviceDisplayName,
};
Object.assign(loginParams, legacyParams);
const client = this._createTemporaryClient();
const tryFallbackHs = (originalError) => {
const fbClient = Matrix.createClient({
baseUrl: self._fallbackHsUrl,
idBaseUrl: this._isUrl,
});
return fbClient.login('m.login.password', loginParams).then(function(data) {
return Promise.resolve({
homeserverUrl: self._fallbackHsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
});
}).catch((fallback_error) => {
console.log("fallback HS login failed", fallback_error);
// throw the original error
throw originalError;
});
};
const tryLowercaseUsername = (originalError) => {
const loginParamsLowercase = Object.assign({}, loginParams, {
user: username.toLowerCase(),
identifier: {
user: username.toLowerCase(),
},
});
return client.login('m.login.password', loginParamsLowercase).then(function(data) {
return Promise.resolve({
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
});
}).catch((fallback_error) => {
console.log("Lowercase username login failed", fallback_error);
// throw the original error
throw originalError;
});
};
let originalLoginError = null;
return client.login('m.login.password', loginParams).then(function(data) {
return Promise.resolve({
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
});
}).catch((error) => {
originalLoginError = error;
if (error.httpStatus === 403) {
if (self._fallbackHsUrl) {
return tryFallbackHs(originalLoginError);
}
}
throw originalLoginError;
}).catch((error) => {
// We apparently squash case at login serverside these days:
// https://github.com/matrix-org/synapse/blob/1189be43a2479f5adf034613e8d10e3f4f452eb9/synapse/handlers/auth.py#L475
// so this wasn't needed after all. Keeping the code around in case the
// the situation changes...
/*
if (
error.httpStatus === 403 &&
loginParams.identifier.type === 'm.id.user' &&
username.search(/[A-Z]/) > -1
) {
return tryLowercaseUsername(originalLoginError);
}
*/
throw originalLoginError;
}).catch((error) => {
console.log("Login failed", error);
throw error;
});
}
redirectToCas() {
const client = this._createTemporaryClient();
const parsedUrl = url.parse(window.location.href, true);
// XXX: at this point, the fragment will always be #/login, which is no
// use to anyone. Ideally, we would get the intended fragment from
// MatrixChat.screenAfterLogin so that you could follow #/room links etc
// through a CAS login.
parsedUrl.hash = "";
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
const casUrl = client.getCasLoginUrl(url.format(parsedUrl));
window.location.href = casUrl;
}
}

View file

@ -14,115 +14,153 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import marked from 'marked';
import commonmark from 'commonmark';
import escape from 'lodash/escape';
// marked only applies the default options on the high
// level marked() interface, so we do it here.
const marked_options = Object.assign({}, marked.defaults, {
gfm: true,
tables: true,
breaks: true,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false,
xhtml: true, // return self closing tags (ie. <br /> not <br>)
});
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
// These types of node are definitely text
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
function is_allowed_html_tag(node) {
// Regex won't work for tags with attrs, but we only
// allow <del> anyway.
const matches = /^<\/?(.*)>$/.exec(node.literal);
if (matches && matches.length == 2) {
const tag = matches[1];
return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
}
return false;
}
function html_if_tag_allowed(node) {
if (is_allowed_html_tag(node)) {
this.lit(node.literal);
return;
} else {
this.lit(escape(node.literal));
}
}
/*
* Returns true if the parse output containing the node
* comprises multiple block level elements (ie. lines),
* or false if it is only a single line.
*/
function is_multi_line(node) {
let par = node;
while (par.parent) {
par = par.parent;
}
return par.firstChild != par.lastChild;
}
/**
* Class that wraps marked, adding the ability to see whether
* Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether
* it's plain text.
*/
export default class Markdown {
constructor(input) {
const lexer = new marked.Lexer(marked_options);
this.tokens = lexer.lex(input);
}
this.input = input;
_copyTokens() {
// copy tokens (the parser modifies its input arg)
const tokens_copy = this.tokens.slice();
// it also has a 'links' property, because this is javascript
// and why wouldn't you have an array that also has properties?
return Object.assign(tokens_copy, this.tokens);
const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input);
}
isPlainText() {
// we determine if the message requires markdown by
// running the parser on the tokens with a dummy
// rendered and seeing if any of the renderer's
// functions are called other than those noted below.
// In case you were wondering, no we can't just examine
// the tokens because the tokens we have are only the
// output of the *first* tokenizer: any line-based
// markdown is processed by marked within Parser by
// the 'inline lexer'...
let is_plain = true;
const walker = this.parsed.walker();
function setNotPlain() {
is_plain = false;
}
const dummy_renderer = {};
for (const k of Object.keys(marked.Renderer.prototype)) {
dummy_renderer[k] = setNotPlain;
}
// text and paragraph are just text
dummy_renderer.text = function(t){return t;}
dummy_renderer.paragraph = function(t){return t;}
// ignore links where text is just the url:
// this ignores plain URLs that markdown has
// detected whilst preserving markdown syntax links
dummy_renderer.link = function(href, title, text) {
if (text != href) {
is_plain = false;
let ev;
while ( (ev = walker.next()) ) {
const node = ev.node;
if (TEXT_NODES.indexOf(node.type) > -1) {
// definitely text
continue;
} else if (node.type == 'html_inline' || node.type == 'html_block') {
// if it's an allowed html tag, we need to render it and therefore
// we will need to use HTML. If it's not allowed, it's not HTML since
// we'll just be treating it as text.
if (is_allowed_html_tag(node)) {
return false;
}
} else {
return false;
}
}
const dummy_options = Object.assign({}, marked_options, {
renderer: dummy_renderer,
});
const dummy_parser = new marked.Parser(dummy_options);
dummy_parser.parse(this._copyTokens());
return is_plain;
return true;
}
toHTML() {
const real_renderer = new marked.Renderer();
real_renderer.link = function(href, title, text) {
// prevent marked from turning plain URLs
// into links, because its algorithm is fairly
// poor. Let's send plain URLs rather than
// badly linkified ones (the linkifier Vector
// uses on message display is way better, eg.
// handles URLs with closing parens at the end).
if (text == href) {
return href;
}
return marked.Renderer.prototype.link.apply(this, arguments);
}
const renderer = new commonmark.HtmlRenderer({
safe: false,
real_renderer.paragraph = (text) => {
// The tokens at the top level are the 'blocks', so if we
// have more than one, there are multiple 'paragraphs'.
// If there is only one top level token, just return the
// bare text: it's a single line of text and so should be
// 'inline', rather than necessarily wrapped in its own
// p tag. If, however, we have multiple tokens, each gets
// its own p tag to keep them as separate paragraphs.
if (this.tokens.length == 1) {
return text;
}
return '<p>' + text + '</p>';
}
const real_options = Object.assign({}, marked_options, {
renderer: real_renderer,
// Set soft breaks to hard HTML breaks: commonmark
// puts softbreaks in for multiple lines in a blockquote,
// so if these are just newline characters then the
// block quote ends up all on one line
// (https://github.com/vector-im/riot-web/issues/3154)
softbreak: '<br />',
});
const real_parser = new marked.Parser(real_options);
return real_parser.parse(this._copyTokens());
const real_paragraph = renderer.paragraph;
renderer.paragraph = function(node, entering) {
// If there is only one top level node, just return the
// bare text: it's a single line of text and so should be
// 'inline', rather than unnecessarily wrapped in its own
// p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs.
if (is_multi_line(node)) {
real_paragraph.call(this, node, entering);
}
};
renderer.html_inline = html_if_tag_allowed;
renderer.html_block = function(node) {
// as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown.
const isMultiLine = is_multi_line(node);
if (isMultiLine) this.cr();
html_if_tag_allowed.call(this, node);
if (isMultiLine) this.cr();
};
return renderer.render(this.parsed);
}
/*
* Render the markdown message to plain text. That is, essentially
* just remove any backslashes escaping what would otherwise be
* markdown syntax
* (to fix https://github.com/vector-im/riot-web/issues/2870)
*/
toPlaintext() {
const renderer = new commonmark.HtmlRenderer({safe: false});
const real_paragraph = renderer.paragraph;
// The default `out` function only sends the input through an XML
// escaping function, which causes messages to be entity encoded,
// which we don't want in this case.
renderer.out = function(s) {
// The `lit` function adds a string literal to the output buffer.
this.lit(s);
};
renderer.paragraph = function(node, entering) {
// as with toHTML, only append lines to paragraphs if there are
// multiple paragraphs
if (is_multi_line(node)) {
if (!entering && node.next) {
this.lit('\n\n');
}
}
};
renderer.html_block = function(node) {
this.lit(node.literal);
if (is_multi_line(node) && node.next) this.lit('\n\n');
};
return renderer.render(this.parsed);
}
}

View file

@ -1,5 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd.
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.
@ -16,12 +18,12 @@ limitations under the License.
'use strict';
import Matrix from 'matrix-js-sdk';
import utils from 'matrix-js-sdk/lib/utils';
import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline';
import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set';
const localStorage = window.localStorage;
import createMatrixClient from './utils/createMatrixClient';
import SettingsStore from './settings/SettingsStore';
import MatrixActionCreators from './actions/MatrixActionCreators';
interface MatrixClientCreds {
homeserverUrl: string,
@ -51,12 +53,25 @@ class MatrixClientPeg {
};
}
/**
* Sets the script href passed to the IndexedDB web worker
* If set, a separate web worker will be started to run the IndexedDB
* queries on.
*
* @param {string} script href to the script to be passed to the web worker
*/
setIndexedDbWorkerScript(script) {
createMatrixClient.indexedDbWorkerScript = script;
}
get(): MatrixClient {
return this.matrixClient;
}
unset() {
this.matrixClient = null;
MatrixActionCreators.stop();
}
/**
@ -67,11 +82,42 @@ class MatrixClientPeg {
this._createClient(creds);
}
start() {
async start() {
// 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) {
await this.matrixClient.initCrypto();
}
} catch (e) {
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
console.warn("Unable to initialise e2e: " + e);
}
const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached";
opts.disablePresence = true; // we do this manually
try {
const promise = this.matrixClient.store.startup();
console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`);
await promise;
} catch (err) {
// log any errors when starting up the database (if one exists)
console.error(`Error starting matrixclient store: ${err}`);
}
// regardless of errors, start the client. If we did error out, we'll
// just end up doing a full initial /sync.
// Connect the matrix client to the dispatcher
MatrixActionCreators.start(this.matrixClient);
console.log(`MatrixClientPeg: really starting MatrixClient`);
this.get().startClient(opts);
console.log(`MatrixClientPeg: MatrixClient started`);
}
getCredentials(): MatrixClientCreds {
@ -99,20 +145,17 @@ class MatrixClientPeg {
}
_createClient(creds: MatrixClientCreds) {
var opts = {
const opts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,
userId: creds.userId,
deviceId: creds.deviceId,
timelineSupport: true,
forceTURN: SettingsStore.getValue('webRtcForceTURN', false),
};
if (localStorage) {
opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
}
this.matrixClient = Matrix.createClient(opts);
this.matrixClient = createMatrixClient(opts, this.indexedDbWorkerScript);
// we're going to add eventlisteners for each matrix event tile, so the
// potential number of event listeners is quite high.
@ -120,8 +163,8 @@ class MatrixClientPeg {
this.matrixClient.setGuest(Boolean(creds.guest));
var notifTimelineSet = new EventTimelineSet(null, {
timelineSupport: true
const notifTimelineSet = new EventTimelineSet(null, {
timelineSupport: true,
});
// XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync.
notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);

View file

@ -17,46 +17,196 @@ limitations under the License.
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
const React = require('react');
const ReactDOM = require('react-dom');
import PropTypes from 'prop-types';
import Analytics from './Analytics';
import sdk from './index';
module.exports = {
DialogContainerId: "mx_Dialog_Container",
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
getOrCreateContainer: function() {
var container = document.getElementById(this.DialogContainerId);
/**
* Wrap an asynchronous loader function with a react component which shows a
* spinner until the real component loads.
*/
const AsyncWrapper = React.createClass({
propTypes: {
/** A function which takes a 'callback' argument which it will call
* with the real component once it loads.
*/
loader: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
component: null,
};
},
componentWillMount: function() {
this._unmounted = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Starting load of AsyncWrapper for modal');
this.props.loader((e) => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('AsyncWrapper load completed with '+e.displayName);
if (this._unmounted) {
return;
}
this.setState({component: e});
});
},
componentWillUnmount: function() {
this._unmounted = true;
},
render: function() {
const {loader, ...otherProps} = this.props;
if (this.state.component) {
const Component = this.state.component;
return <Component {...otherProps} />;
} else {
// show a spinner until the component is loaded.
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});
class ModalManager {
constructor() {
this._counter = 0;
/** list of the modals we have stacked up, with the most recent at [0] */
this._modals = [
/* {
elem: React component for this dialog
onFinished: caller-supplied onFinished callback
className: CSS class for the dialog wrapper div
} */
];
this.closeAll = this.closeAll.bind(this);
}
getOrCreateContainer() {
let container = document.getElementById(DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = this.DialogContainerId;
container.id = DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
},
}
createDialog: function (Element, props, className) {
var self = this;
createTrackedDialog(analyticsAction, analyticsInfo, Element, props, className) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialog(Element, props, className);
}
// never call this via modal.close() from onFinished() otherwise it will loop
var closeDialog = function() {
createDialog(Element, props, className) {
return this.createDialogAsync((cb) => {cb(Element);}, props, className);
}
createTrackedDialogAsync(analyticsAction, analyticsInfo, loader, props, className) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialogAsync(loader, props, className);
}
/**
* Open a modal view.
*
* This can be used to display a react component which is loaded as an asynchronous
* webpack component. To do this, set 'loader' as:
*
* (cb) => {
* require(['<module>'], cb);
* }
*
* @param {Function} loader a function which takes a 'callback' argument,
* which it should call with a React component which will be displayed as
* the modal view.
*
* @param {Object} props properties to pass to the displayed
* component. (We will also pass an 'onFinished' property.)
*
* @param {String} className CSS class to apply to the modal wrapper
*/
createDialogAsync(loader, props, className) {
const self = this;
const modal = {};
// never call this from onFinished() otherwise it will loop
//
// nb explicit function() rather than arrow function, to get `arguments`
const closeDialog = function() {
if (props && props.onFinished) props.onFinished.apply(null, arguments);
ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
const i = self._modals.indexOf(modal);
if (i >= 0) {
self._modals.splice(i, 1);
}
self._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!
var dialog = (
<div className={"mx_Dialog_wrapper " + className}>
modal.elem = (
<AsyncWrapper key={modalCount} loader={loader} {...props}
onFinished={closeDialog} />
);
modal.onFinished = props ? props.onFinished : null;
modal.className = className;
this._modals.unshift(modal);
this._reRender();
return {close: closeDialog};
}
closeAll() {
const modals = this._modals;
this._modals = [];
for (let i = 0; i < modals.length; i++) {
const m = modals[i];
if (m.onFinished) {
m.onFinished(false);
}
}
this._reRender();
}
_reRender() {
if (this._modals.length == 0) {
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
return;
}
const modal = this._modals[0];
const dialog = (
<div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}>
<div className="mx_Dialog">
<Element {...props} onFinished={closeDialog}/>
{ modal.elem }
</div>
<div className="mx_Dialog_background" onClick={ closeDialog.bind(this, false) }></div>
<div className="mx_Dialog_background" onClick={this.closeAll}></div>
</div>
);
ReactDOM.render(dialog, this.getOrCreateContainer());
}
}
return {close: closeDialog};
},
};
if (!global.singletonModalManager) {
global.singletonModalManager = new ModalManager();
}
export default global.singletonModalManager;

View file

@ -1,5 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
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.
@ -14,13 +16,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var MatrixClientPeg = require("./MatrixClientPeg");
var PlatformPeg = require("./PlatformPeg");
var TextForEvent = require('./TextForEvent');
var Avatar = require('./Avatar');
var dis = require("./dispatcher");
import MatrixClientPeg from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import TextForEvent from './TextForEvent';
import Analytics from './Analytics';
import Avatar from './Avatar';
import dis from './dispatcher';
import sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
/*
* Dispatches:
@ -30,9 +35,16 @@ var dis = require("./dispatcher");
* }
*/
var Notifier = {
const MAX_PENDING_ENCRYPTED = 20;
const Notifier = {
notifsByRoom: {},
// A list of event IDs that we've received but need to wait until
// they're decrypted until we decide whether to notify for them
// or not
pendingEncryptedEventIds: [],
notificationMessageForEvent: function(ev) {
return TextForEvent.textForEvent(ev);
},
@ -49,16 +61,16 @@ var Notifier = {
return;
}
var msg = this.notificationMessageForEvent(ev);
let msg = this.notificationMessageForEvent(ev);
if (!msg) return;
var title;
if (!ev.sender || room.name == ev.sender.name) {
let title;
if (!ev.sender || room.name === ev.sender.name) {
title = room.name;
// notificationMessageForEvent includes sender,
// but we already have the sender here
if (ev.getContent().body) msg = ev.getContent().body;
} else if (ev.getType() == 'm.room.member') {
} else if (ev.getType() === 'm.room.member') {
// context is all in the message here, we don't need
// to display sender info
title = room.name;
@ -69,10 +81,11 @@ var Notifier = {
if (ev.getContent().body) msg = ev.getContent().body;
}
var avatarUrl = ev.sender ? Avatar.avatarUrlForMember(
ev.sender, 40, 40, 'crop'
) : null;
if (!this.isBodyEnabled()) {
msg = '';
}
const avatarUrl = ev.sender ? Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop') : null;
const notif = plaf.displayNotification(title, msg, avatarUrl, room);
// if displayNotification returns non-null, the platform supports
@ -84,31 +97,33 @@ var Notifier = {
},
_playAudioNotification: function(ev, room) {
var e = document.getElementById("messageAudio");
const e = document.getElementById("messageAudio");
if (e) {
e.load();
e.play();
};
}
},
start: function() {
this.boundOnRoomTimeline = this.onRoomTimeline.bind(this);
this.boundOnEvent = this.onEvent.bind(this);
this.boundOnSyncStateChange = this.onSyncStateChange.bind(this);
this.boundOnRoomReceipt = this.onRoomReceipt.bind(this);
MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline);
MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt);
this.boundOnEventDecrypted = this.onEventDecrypted.bind(this);
MatrixClientPeg.get().on('event', this.boundOnEvent);
MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt);
MatrixClientPeg.get().on('Event.decrypted', this.boundOnEventDecrypted);
MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange);
this.toolbarHidden = false;
this.isPrepared = false;
this.isSyncing = false;
},
stop: function() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline);
MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt);
if (MatrixClientPeg.get() && this.boundOnRoomTimeline) {
MatrixClientPeg.get().removeListener('Event', this.boundOnEvent);
MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt);
MatrixClientPeg.get().removeListener('Event.decrypted', this.boundOnEventDecrypted);
MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange);
}
this.isPrepared = false;
this.isSyncing = false;
},
supportsDesktopNotifications: function() {
@ -119,12 +134,17 @@ var Notifier = {
setEnabled: function(enable, callback) {
const plaf = PlatformPeg.get();
if (!plaf) return;
// Dev note: We don't set the "notificationsEnabled" setting to true here because it is a
// calculated value. It is determined based upon whether or not the master rule is enabled
// and other flags. Setting it here would cause a circular reference.
Analytics.trackEvent('Notifier', 'Set Enabled', enable);
// make sure that we persist the current setting audio_enabled setting
// before changing anything
if (global.localStorage) {
if(global.localStorage.getItem('audio_notifications_enabled') == null) {
this.setAudioEnabled(this.isEnabled());
}
if (SettingsStore.isLevelSupported(SettingLevel.DEVICE)) {
SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, this.isEnabled());
}
if (enable) {
@ -132,116 +152,119 @@ var Notifier = {
plaf.requestNotificationPermission().done((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 was not given permission to send notifications - please try again');
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, {
title: _t('Unable to enable Notifications'),
description,
});
return;
}
if (global.localStorage) {
global.localStorage.setItem('notifications_enabled', 'true');
}
if (callback) callback();
dis.dispatch({
action: "notifier_enabled",
value: true
value: true,
});
});
// clear the notifications_hidden flag, so that if notifications are
// disabled again in the future, we will show the banner again.
this.setToolbarHidden(false);
this.setToolbarHidden(true);
} else {
if (!global.localStorage) return;
global.localStorage.setItem('notifications_enabled', 'false');
dis.dispatch({
action: "notifier_enabled",
value: false
value: false,
});
}
},
isEnabled: function() {
return this.isPossible() && SettingsStore.getValue("notificationsEnabled");
},
isPossible: function() {
const plaf = PlatformPeg.get();
if (!plaf) return false;
if (!plaf.supportsNotifications()) return false;
if (!plaf.maySendNotifications()) return false;
if (!global.localStorage) return true;
var enabled = global.localStorage.getItem('notifications_enabled');
if (enabled === null) return true;
return enabled === 'true';
return true; // possible, but not necessarily enabled
},
setAudioEnabled: function(enable) {
if (!global.localStorage) return;
global.localStorage.setItem('audio_notifications_enabled',
enable ? 'true' : 'false');
isBodyEnabled: function() {
return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled");
},
isAudioEnabled: function(enable) {
if (!global.localStorage) return true;
var enabled = global.localStorage.getItem(
'audio_notifications_enabled');
// default to true if the popups are enabled
if (enabled === null) return this.isEnabled();
return enabled === 'true';
isAudioEnabled: function() {
return this.isEnabled() && SettingsStore.getValue("audioNotificationsEnabled");
},
setToolbarHidden: function(hidden, persistent = true) {
this.toolbarHidden = hidden;
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden);
// XXX: why are we dispatching this here?
// this is nothing to do with notifier_enabled
dis.dispatch({
action: "notifier_enabled",
value: this.isEnabled()
value: this.isEnabled(),
});
// update the info to localStorage for persistent settings
if (persistent && global.localStorage) {
global.localStorage.setItem('notifications_hidden', hidden);
global.localStorage.setItem("notifications_hidden", hidden);
}
},
isToolbarHidden: function() {
// Check localStorage for any such meta data
if (global.localStorage) {
if (global.localStorage.getItem('notifications_hidden') === 'true') {
return true;
}
return global.localStorage.getItem("notifications_hidden") === "true";
}
return this.toolbarHidden;
},
onSyncStateChange: function(state) {
if (state === "PREPARED" || state === "SYNCING") {
this.isPrepared = true;
}
else if (state === "STOPPED" || state === "ERROR") {
this.isPrepared = false;
if (state === "SYNCING") {
this.isSyncing = true;
} else if (state === "STOPPED" || state === "ERROR") {
this.isSyncing = false;
}
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
if (toStartOfTimeline) return;
if (!room) return;
if (!this.isPrepared) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
onEvent: function(ev) {
if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return;
var actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) {
if (this.isEnabled()) {
this._displayPopupNotification(ev, room);
}
if (actions.tweaks.sound && this.isAudioEnabled()) {
this._playAudioNotification(ev, room);
// If it's an encrypted event and the type is still 'm.room.encrypted',
// it hasn't yet been decrypted, so wait until it is.
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
this.pendingEncryptedEventIds.push(ev.getId());
// don't let the list fill up indefinitely
while (this.pendingEncryptedEventIds.length > MAX_PENDING_ENCRYPTED) {
this.pendingEncryptedEventIds.shift();
}
return;
}
this._evaluateEvent(ev);
},
onEventDecrypted: function(ev) {
const idx = this.pendingEncryptedEventIds.indexOf(ev.getId());
if (idx === -1) return;
this.pendingEncryptedEventIds.splice(idx, 1);
this._evaluateEvent(ev);
},
onRoomReceipt: function(ev, room) {
if (room.getUnreadNotificationCount() == 0) {
if (room.getUnreadNotificationCount() === 0) {
// ideally we would clear each notification when it was read,
// but we have no way, given a read receipt, to know whether
// the receipt comes before or after an event, so we can't
@ -256,7 +279,21 @@ var Notifier = {
}
delete this.notifsByRoom[room.roomId];
}
}
},
_evaluateEvent: function(ev) {
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) {
if (this.isEnabled()) {
this._displayPopupNotification(ev, room);
}
if (actions.tweaks.sound && this.isAudioEnabled()) {
PlatformPeg.get().loudNotification(ev, room);
this._playAudioNotification(ev, room);
}
}
},
};
if (!global.mxNotifier) {

View file

@ -23,8 +23,8 @@ limitations under the License.
* { key: $KEY, val: $VALUE, place: "add|del" }
*/
module.exports.getKeyValueArrayDiffs = function(before, after) {
var results = [];
var delta = {};
const results = [];
const delta = {};
Object.keys(before).forEach(function(beforeKey) {
delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially
delta[beforeKey]--; // keys present in the past have -ve values
@ -46,9 +46,9 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
results.push({ place: "del", key: muxedKey, val: beforeVal });
});
break;
case 0: // A mix of added/removed keys
case 0: {// A mix of added/removed keys
// compare old & new vals
var itemDelta = {};
const itemDelta = {};
before[muxedKey].forEach(function(beforeVal) {
itemDelta[beforeVal] = itemDelta[beforeVal] || 0;
itemDelta[beforeVal]--;
@ -64,13 +64,13 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
} else if (itemDelta[item] === -1) {
results.push({ place: "del", key: muxedKey, val: item });
} else {
// itemDelta of 0 means it was unchanged between before/after
// itemDelta of 0 means it was unchanged between before/after
}
});
break;
}
default:
console.error("Calculated key delta of " + delta[muxedKey] +
" - this should never happen!");
console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!");
break;
}
});
@ -79,8 +79,10 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
};
/**
* Shallow-compare two objects for equality: each key and value must be
* identical
* Shallow-compare two objects for equality: each key and value must be identical
* @param {Object} objA First object to compare against the second
* @param {Object} objB Second object to compare against the first
* @return {boolean} whether the two objects have same key=values
*/
module.exports.shallowEqual = function(objA, objB) {
if (objA === objB) {
@ -92,15 +94,15 @@ module.exports.shallowEqual = function(objA, objB) {
return false;
}
var keysA = Object.keys(objA);
var keysB = Object.keys(objB);
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
for (var i = 0; i < keysA.length; i++) {
var key = keysA[i];
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) {
return false;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,9 +17,12 @@ limitations under the License.
/** The types of page which can be shown by the LoggedInView */
export default {
HomePage: "home_page",
RoomView: "room_view",
UserSettings: "user_settings",
CreateRoom: "create_room",
RoomDirectory: "room_directory",
UserView: "user_view",
GroupView: "group_view",
MyGroups: "my_groups",
};

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var Matrix = require("matrix-js-sdk");
import * as Matrix from 'matrix-js-sdk';
import { _t } from './languageHandler';
/**
* Allows a user to reset their password on a homeserver.
@ -33,7 +34,7 @@ class PasswordReset {
constructor(homeserverUrl, identityUrl) {
this.client = Matrix.createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl
idBaseUrl: identityUrl,
});
this.clientSecret = this.client.generateClientSecret();
this.identityServerDomain = identityUrl.split("://")[1];
@ -52,8 +53,8 @@ class PasswordReset {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode == 'M_THREEPID_NOT_FOUND') {
err.message = "This email address was not found";
if (err.errcode === 'M_THREEPID_NOT_FOUND') {
err.message = _t('This email address was not found');
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
@ -74,16 +75,15 @@ class PasswordReset {
threepid_creds: {
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: this.identityServerDomain
}
id_server: this.identityServerDomain,
},
}, this.password).catch(function(err) {
if (err.httpStatus === 401) {
err.message = "Failed to verify email address: make sure you clicked the link in the email";
}
else if (err.httpStatus === 404) {
err.message = "Your email address does not appear to be associated with a Matrix ID on this Homeserver.";
}
else if (err.httpStatus) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
} else if (err.httpStatus === 404) {
err.message =
_t('Your email address does not appear to be associated with a Matrix ID on this Homeserver.');
} else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`;
}
throw err;

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var MatrixClientPeg = require("./MatrixClientPeg");
var dis = require("./dispatcher");
const MatrixClientPeg = require("./MatrixClientPeg");
const dis = require("./dispatcher");
// Time in ms after that a user is considered as unavailable/away
var UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
var PRESENCE_STATES = ["online", "offline", "unavailable"];
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
const PRESENCE_STATES = ["online", "offline", "unavailable"];
class Presence {
@ -56,13 +56,27 @@ class Presence {
return this.state;
}
/**
* Get the current status message.
* @returns {String} the status message, may be null
*/
getStatusMessage() {
return this.statusMessage;
}
/**
* Set the presence state.
* If the state has changed, the Home Server will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
* @param {String} statusMessage an optional status message for the presence
* @param {boolean} maintain true to have this status maintained by this tracker
*/
setState(newState) {
if (newState === this.state) {
setState(newState, statusMessage=null, maintain=false) {
if (this.maintain) {
// Don't update presence if we're maintaining a particular status
return;
}
if (newState === this.state && statusMessage === this.statusMessage) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
@ -71,22 +85,38 @@ class Presence {
if (!this.running) {
return;
}
var old_state = this.state;
const old_state = this.state;
const old_message = this.statusMessage;
this.state = newState;
this.statusMessage = statusMessage;
this.maintain = maintain;
if (MatrixClientPeg.get().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}
var self = this;
MatrixClientPeg.get().setPresence(this.state).done(function() {
const updateContent = {
presence: this.state,
status_msg: this.statusMessage ? this.statusMessage : '',
};
const self = this;
MatrixClientPeg.get().setPresence(updateContent).done(function() {
console.log("Presence: %s", newState);
// We have to dispatch because the js-sdk is unreliable at telling us about our own presence
dis.dispatch({action: "self_presence_updated", statusInfo: updateContent});
}, function(err) {
console.error("Failed to set presence: %s", err);
self.state = old_state;
self.statusMessage = old_message;
});
}
stopMaintainingStatus() {
this.maintain = false;
}
/**
* Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
* @private
@ -95,7 +125,8 @@ class Presence {
this.setState("unavailable");
}
_onUserActivity() {
_onUserActivity(payload) {
if (payload.action === "sync_state" || payload.action === "self_presence_updated") return;
this._resetTimer();
}
@ -104,14 +135,14 @@ class Presence {
* @private
*/
_resetTimer() {
var self = this;
const self = this;
this.setState("online");
// Re-arm the timer
clearTimeout(this.timer);
this.timer = setTimeout(function() {
self._onUnavailableTimerFire();
}, UNAVAILABLE_TIME_MS);
}
}
}
module.exports = new Presence();

View file

@ -14,31 +14,44 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var MatrixClientPeg = require('./MatrixClientPeg');
var dis = require('./dispatcher');
import MatrixClientPeg from './MatrixClientPeg';
import dis from './dispatcher';
import { EventStatus } from 'matrix-js-sdk';
module.exports = {
resendUnsentEvents: function(room) {
room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
module.exports.resend(event);
});
},
cancelUnsentEvents: function(room) {
room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
module.exports.removeFromQueue(event);
});
},
resend: function(event) {
MatrixClientPeg.get().resendEvent(
event, MatrixClientPeg.get().getRoom(event.getRoomId())
).done(function() {
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent(event, room).done(function(res) {
dis.dispatch({
action: 'message_sent',
event: event
event: event,
});
}, function() {
}, function(err) {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')');
dis.dispatch({
action: 'message_send_failed',
event: event
event: event,
});
});
},
removeFromQueue: function(event) {
MatrixClientPeg.get().cancelPendingEvent(event);
dis.dispatch({
action: 'message_send_cancelled',
event: event
});
},
};

View file

@ -12,10 +12,11 @@ import {
SelectionState,
Entity,
} from 'draft-js';
import * as sdk from './index';
import * as sdk from './index';
import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter";
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
@ -30,17 +31,35 @@ const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export const contentStateToHTML = stateToHTML;
const ZWS_CODE = 8203;
const ZWS = String.fromCharCode(ZWS_CODE); // zero width space
export function stateToMarkdown(state) {
return __stateToMarkdown(state)
.replace(
ZWS, // draft-js-export-markdown adds these
''); // this is *not* a zero width space, trust me :)
}
export function HTMLtoContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html));
export const contentStateToHTML = (contentState: ContentState) => {
return stateToHTML(contentState, {
inlineStyles: {
UNDERLINE: {
element: 'u',
},
},
});
};
export function htmlToContentState(html: string): ContentState {
const blockArray = convertFromHTML(html).contentBlocks;
return ContentState.createFromBlockArray(blockArray);
}
function unicodeToEmojiUri(str) {
let replaceWith, unicode, alt;
if ((!emojione.unicodeAlt) || (emojione.sprites)) {
// if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames
let mappedUnicode = emojione.mapUnicodeToShort();
const mappedUnicode = emojione.mapUnicodeToShort();
}
str = str.replace(emojione.regUnicode, function(unicodeChar) {
@ -48,8 +67,14 @@ function unicodeToEmojiUri(str) {
// if the unicodeChar doesnt exist just return the entire match
return unicodeChar;
} else {
// Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below
if (unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') {
unicodeChar = unicodeChar[0];
}
// get the unicode codepoint from the actual char
unicode = emojione.jsEscapeMap[unicodeChar];
return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam;
}
});
@ -71,14 +96,14 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb
}
// Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = {
strategy: (contentBlock, callback) => {
const emojiDecorator = {
strategy: (contentState, contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
let uri = unicodeToEmojiUri(props.children[0].props.text);
let shortname = emojione.toShort(props.children[0].props.text);
let style = {
const uri = unicodeToEmojiUri(props.children[0].props.text);
const shortname = emojione.toShort(props.children[0].props.text);
const style = {
display: 'inline-block',
width: '1em',
maxHeight: '1em',
@ -87,7 +112,7 @@ let emojiDecorator = {
backgroundPosition: 'center center',
overflow: 'hidden',
};
return (<span title={shortname} style={style}><span style={{opacity: 0}}>{props.children}</span></span>);
return (<span title={shortname} style={style}><span style={{opacity: 0}}>{ props.children }</span></span>);
},
};
@ -95,60 +120,35 @@ let emojiDecorator = {
* Returns a composite decorator which has access to provided scope.
*/
export function getScopedRTDecorators(scope: any): CompositeDecorator {
let MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
let usernameDecorator = {
strategy: (contentBlock, callback) => {
findWithRegex(USERNAME_REGEX, contentBlock, callback);
},
component: (props) => {
let member = scope.room.getMember(props.children[0].props.text);
// unused until we make these decorators immutable (autocomplete needed)
let name = member ? member.name : null;
let avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null;
return <span className="mx_UserPill">{avatar}{props.children}</span>;
}
};
let roomDecorator = {
strategy: (contentBlock, callback) => {
findWithRegex(ROOM_REGEX, contentBlock, callback);
},
component: (props) => {
return <span className="mx_RoomPill">{props.children}</span>;
}
};
// TODO Re-enable usernameDecorator and roomDecorator
return [emojiDecorator];
}
export function getScopedMDDecorators(scope: any): CompositeDecorator {
let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
const markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
},
component: (props) => (
<span className={"mx_MarkdownElement mx_Markdown_" + style}>
{props.children}
{ props.children }
</span>
)
),
}));
markdownDecorators.push({
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
},
component: (props) => (
<a href="#" className="mx_MarkdownElement mx_Markdown_LINK">
{props.children}
{ props.children }
</a>
)
),
});
markdownDecorators.push(emojiDecorator);
return markdownDecorators;
// markdownDecorators.push(emojiDecorator);
// TODO Consider renabling "syntax highlighting" when we can do it properly
return [emojiDecorator];
}
/**
@ -167,7 +167,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
for (let currentKey = startKey;
currentKey && currentKey !== endKey;
currentKey = contentState.getKeyAfter(currentKey)) {
let blockText = getText(currentKey);
const blockText = getText(currentKey);
text += blockText.substring(startOffset, blockText.length);
// from now on, we'll take whole blocks
@ -188,7 +188,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
export function selectionStateToTextOffsets(selectionState: SelectionState,
contentBlocks: Array<ContentBlock>): {start: number, end: number} {
let offset = 0, start = 0, end = 0;
for (let block of contentBlocks) {
for (const block of contentBlocks) {
if (selectionState.getStartKey() === block.getKey()) {
start = offset + selectionState.getStartOffset();
}
@ -208,31 +208,36 @@ export function selectionStateToTextOffsets(selectionState: SelectionState,
export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();
for (let block of contentBlocks) {
let blockLength = block.getLength();
if (start !== -1 && start < blockLength) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
anchorOffset: start,
});
start = -1;
} else {
start -= blockLength;
// Subtract block lengths from `start` and `end` until they are less than the current
// block length (accounting for the NL at the end of each block). Set them to -1 to
// indicate that the corresponding selection state has been determined.
for (const block of contentBlocks) {
const blockLength = block.getLength();
// -1 indicating that the position start position has been found
if (start !== -1) {
if (start < blockLength + 1) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
anchorOffset: start,
});
start = -1; // selection state for the start calculated
} else {
start -= blockLength + 1; // +1 to account for newline between blocks
}
}
if (end !== -1 && end <= blockLength) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
focusOffset: end,
});
end = -1;
} else {
end -= blockLength;
// -1 indicating that the position end position has been found
if (end !== -1) {
if (end < blockLength + 1) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
focusOffset: end,
});
end = -1; // selection state for the end calculated
} else {
end -= blockLength + 1; // +1 to account for newline between blocks
}
}
}
return selectionState;
}
@ -249,7 +254,7 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
const existingEntityKey = block.getEntityAt(start);
if (existingEntityKey) {
// avoid manipulation in case the emoji already has an entity
const entity = Entity.get(existingEntityKey);
const entity = newContentState.getEntity(existingEntityKey);
if (entity && entity.get('type') === 'emoji') {
return;
}
@ -259,7 +264,10 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
.set('anchorOffset', start)
.set('focusOffset', end);
const emojiText = plainText.substring(start, end);
const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText });
newContentState = newContentState.createEntity(
'emoji', 'IMMUTABLE', { emojiUnicode: emojiText },
);
const entityKey = newContentState.getLastCreatedEntityKey();
newContentState = Modifier.replaceText(
newContentState,
selection,
@ -286,3 +294,14 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
return editorState;
}
export function hasMultiLineSelection(editorState: EditorState): boolean {
const selectionState = editorState.getSelection();
const anchorKey = selectionState.getAnchorKey();
const currentContent = editorState.getCurrentContent();
const currentContentBlock = currentContent.getBlockForKey(anchorKey);
const start = selectionState.getStartOffset();
const end = selectionState.getEndOffset();
const selectedText = currentContentBlock.getText().slice(start, end);
return selectedText.includes('\n');
}

35
src/Roles.js Normal file
View file

@ -0,0 +1,35 @@
/*
Copyright 2017 Vector Creations 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 { _t } from './languageHandler';
export function levelRoleMap(usersDefault) {
return {
undefined: _t('Default'),
0: _t('Restricted'),
[usersDefault]: _t('Default'),
50: _t('Moderator'),
100: _t('Admin'),
};
}
export function textualPowerLevel(level, usersDefault) {
const LEVEL_ROLE_MAP = this.levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`);
} else {
return level;
}
}

205
src/RoomInvite.js Normal file
View file

@ -0,0 +1,205 @@
/*
Copyright 2016 OpenMarket Ltd
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 MatrixClientPeg from './MatrixClientPeg';
import MultiInviter from './utils/MultiInviter';
import Modal from './Modal';
import { getAddressType } from './UserAddress';
import createRoom from './createRoom';
import sdk from './';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import { _t } from './languageHandler';
export function inviteToRoom(roomId, addr) {
const addrType = getAddressType(addr);
if (addrType == 'email') {
return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType == 'mx-user-id') {
return MatrixClientPeg.get().invite(roomId, addr);
} else {
throw new Error('Unsupported address');
}
}
/**
* Invites multiple addresses to a room
* Simpler interface to utils/MultiInviter but with
* no option to cancel.
*
* @param {string} roomId The ID of the room to invite to
* @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @returns {Promise} Promise
*/
export function inviteMultipleToRoom(roomId, addrs) {
const inviter = new MultiInviter(roomId);
return inviter.invite(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"),
button: _t("Start Chat"),
onFinished: _onStartChatFinished,
});
}
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"),
onFinished: (shouldInvite, addrs) => {
_onRoomInviteFinished(roomId, shouldInvite, addrs);
},
});
}
function _onStartChatFinished(shouldInvite, addrs) {
if (!shouldInvite) return;
const addrTexts = addrs.map((addr) => addr.address);
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;
} 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"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
} else if (addrTexts.length === 1) {
// 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"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
} else {
// Start multi user chat
let room;
createRoom().then((roomId) => {
room = MatrixClientPeg.get().getRoom(roomId);
return inviteMultipleToRoom(roomId, addrTexts);
}).then((addrs) => {
return _showAnyInviteErrors(addrs, room);
}).catch((err) => {
console.error(err.stack);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
}
function _onRoomInviteFinished(roomId, shouldInvite, addrs) {
if (!shouldInvite) return;
const addrTexts = addrs.map((addr) => addr.address);
// Invite new users to a room
inviteMultipleToRoom(roomId, addrTexts).then((addrs) => {
const room = MatrixClientPeg.get().getRoom(roomId);
return _showAnyInviteErrors(addrs, room);
}).catch((err) => {
console.error(err.stack);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
function _isDmChat(addrTexts) {
if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') {
return true;
} else {
return false;
}
}
function _showAnyInviteErrors(addrs, room) {
// Show user any errors
const errorList = [];
for (const addr of Object.keys(addrs)) {
if (addrs[addr] === "error") {
errorList.push(addr);
}
}
if (errorList.length > 0) {
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(", "),
});
}
return addrs;
}
function _getDirectMessageRooms(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
const rooms = [];
dmRooms.forEach((dmRoom) => {
const room = MatrixClientPeg.get().getRoom(dmRoom);
if (room) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (me.membership == 'join') {
rooms.push(room);
}
}
});
return rooms;
}

View file

@ -19,18 +19,17 @@ limitations under the License.
function tsOfNewestEvent(room) {
if (room.timeline.length) {
return room.timeline[room.timeline.length - 1].getTs();
}
else {
} else {
return Number.MAX_SAFE_INTEGER;
}
}
function mostRecentActivityFirst(roomList) {
return roomList.sort(function(a,b) {
return roomList.sort(function(a, b) {
return tsOfNewestEvent(b) - tsOfNewestEvent(a);
});
}
module.exports = {
mostRecentActivityFirst: mostRecentActivityFirst
mostRecentActivityFirst,
};

View file

@ -16,7 +16,7 @@ limitations under the License.
import MatrixClientPeg from './MatrixClientPeg';
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import q from 'q';
import Promise from 'bluebird';
export const ALL_MESSAGES_LOUD = 'all_messages_loud';
export const ALL_MESSAGES = 'all_messages';
@ -52,7 +52,7 @@ export function getRoomNotifsState(roomId) {
}
export function setRoomNotifsState(roomId, newState) {
if (newState == MUTE) {
if (newState === MUTE) {
return setRoomNotifsStateMuted(roomId);
} else {
return setRoomNotifsStateUnmuted(roomId, newState);
@ -80,14 +80,14 @@ function setRoomNotifsStateMuted(roomId) {
kind: 'event_match',
key: 'room_id',
pattern: roomId,
}
},
],
actions: [
'dont_notify',
]
],
}));
return q.all(promises);
return Promise.all(promises);
}
function setRoomNotifsStateUnmuted(roomId, newState) {
@ -99,16 +99,16 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id));
}
if (newState == 'all_messages') {
if (newState === 'all_messages') {
const roomRule = cli.getRoomPushRule('global', roomId);
if (roomRule) {
promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id));
}
} else if (newState == 'mentions_only') {
} else if (newState === 'mentions_only') {
promises.push(cli.addPushRule('global', 'room', roomId, {
actions: [
'dont_notify',
]
],
}));
// https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
@ -119,14 +119,14 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
{
set_tweak: 'sound',
value: 'default',
}
]
},
],
}));
// https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
}
return q.all(promises);
return Promise.all(promises);
}
function findOverrideMuteRule(roomId) {
@ -145,20 +145,10 @@ function isRuleForRoom(roomId, rule) {
return false;
}
const cond = rule.conditions[0];
if (
cond.kind == 'event_match' &&
cond.key == 'room_id' &&
cond.pattern == roomId
) {
return true;
}
return false;
return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId);
}
function isMuteRule(rule) {
return (
rule.actions.length == 1 &&
rule.actions[0] == 'dont_notify'
);
return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify');
}

View file

@ -15,8 +15,7 @@ limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
import q from 'q';
import Promise from 'bluebird';
/**
* Given a room object, return the alias we should use for it,
@ -37,14 +36,14 @@ export function getOnlyOtherMember(room, me) {
if (joinedMembers.length === 2) {
return joinedMembers.filter(function(m) {
return m.userId !== me.userId
return m.userId !== me.userId;
})[0];
}
return null;
}
export function isConfCallRoom(room, me, conferenceHandler) {
function _isConfCallRoom(room, me, conferenceHandler) {
if (!conferenceHandler) return false;
if (me.membership != "join") {
@ -59,12 +58,31 @@ export function isConfCallRoom(room, me, conferenceHandler) {
if (conferenceHandler.isConferenceUser(otherMember.userId)) {
return true;
}
return false;
}
// Cache whether a room is a conference call. Assumes that rooms will always
// either will or will not be a conference call room.
const isConfCallRoomCache = {
// $roomId: bool
};
export function isConfCallRoom(room, me, conferenceHandler) {
if (isConfCallRoomCache[room.roomId] !== undefined) {
return isConfCallRoomCache[room.roomId];
}
const result = _isConfCallRoom(room, me, conferenceHandler);
isConfCallRoomCache[room.roomId] = result;
return result;
}
export function looksLikeDirectMessageRoom(room, me) {
if (me.membership == "join" || me.membership === "ban" ||
(me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey()))
{
(me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) {
// Used to split rooms via tags
const tagNames = Object.keys(room.tags);
// Used for 1:1 direct chats
@ -79,6 +97,20 @@ export function looksLikeDirectMessageRoom(room, me) {
return false;
}
export function guessAndSetDMRoom(room, isDirect) {
let newTarget;
if (isDirect) {
const guessedTarget = guessDMRoomTarget(
room, room.getMember(MatrixClientPeg.get().credentials.userId),
);
newTarget = guessedTarget.userId;
} else {
newTarget = null;
}
return setDMRoom(room.roomId, newTarget);
}
/**
* Marks or unmarks the given room as being as a DM room.
* @param {string} roomId The ID of the room to modify
@ -89,7 +121,7 @@ export function looksLikeDirectMessageRoom(room, me) {
*/
export function setDMRoom(roomId, userId) {
if (MatrixClientPeg.get().isGuest()) {
return q();
return Promise.resolve();
}
const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');
@ -131,7 +163,18 @@ export function guessDMRoomTarget(room, me) {
let oldestTs;
let oldestUser;
// Pick the user who's been here longest (and isn't us)
// Pick the joined user who's been here longest (and isn't us),
for (const user of room.getJoinedMembers()) {
if (user.userId == me.userId) continue;
if (oldestTs === undefined || user.events.member.getTs() < oldestTs) {
oldestUser = user;
oldestTs = user.events.member.getTs();
}
}
if (oldestUser) return oldestUser;
// if there are no joined members other than us, use the oldest member
for (const user of room.currentState.getMembers()) {
if (user.userId == me.userId) continue;

104
src/RtsClient.js Normal file
View file

@ -0,0 +1,104 @@
import 'whatwg-fetch';
let fetchFunction = fetch;
function checkStatus(response) {
if (!response.ok) {
return response.text().then((text) => {
throw new Error(text);
});
}
return response;
}
function parseJson(response) {
return response.json();
}
function encodeQueryParams(params) {
return '?' + Object.keys(params).map((k) => {
return k + '=' + encodeURIComponent(params[k]);
}).join('&');
}
const request = (url, opts) => {
if (opts && opts.qs) {
url += encodeQueryParams(opts.qs);
delete opts.qs;
}
if (opts && opts.body) {
if (!opts.headers) {
opts.headers = {};
}
opts.body = JSON.stringify(opts.body);
opts.headers['Content-Type'] = 'application/json';
}
return fetchFunction(url, opts)
.then(checkStatus)
.then(parseJson);
};
export default class RtsClient {
constructor(url) {
this._url = url;
}
getTeamsConfig() {
return request(this._url + '/teams');
}
/**
* Track a referral with the Riot Team Server. This should be called once a referred
* user has been successfully registered.
* @param {string} referrer the user ID of one who referred the user to Riot.
* @param {string} sid the sign-up identity server session ID .
* @param {string} clientSecret the sign-up client secret.
* @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon
* success.
*/
trackReferral(referrer, sid, clientSecret) {
return request(this._url + '/register',
{
body: {
referrer: referrer,
session_id: sid,
client_secret: clientSecret,
},
method: 'POST',
},
);
}
getTeam(teamToken) {
return request(this._url + '/teamConfiguration',
{
qs: {
team_token: teamToken,
},
},
);
}
/**
* Signal to the RTS that a login has occurred and that a user requires their team's
* token.
* @param {string} userId the user ID of the user who is a member of a team.
* @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon
* success.
*/
login(userId) {
return request(this._url + '/login',
{
qs: {
user_id: userId,
},
},
);
}
// allow fetch to be replaced, for testing.
static setFetch(fn) {
fetchFunction = fn;
}
}

View file

@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var q = require("q");
var request = require('browser-request');
import Promise from 'bluebird';
import SettingsStore from "./settings/SettingsStore";
const request = require('browser-request');
var SdkConfig = require('./SdkConfig');
var MatrixClientPeg = require('./MatrixClientPeg');
const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require('./MatrixClientPeg');
class ScalarAuthClient {
@ -38,11 +39,53 @@ class ScalarAuthClient {
// Returns a scalar_token string
getScalarToken() {
var tok = window.localStorage.getItem("mx_scalar_token");
if (tok) return q(tok);
const token = window.localStorage.getItem("mx_scalar_token");
// No saved token, so do the dance to get one. First, we
// need an openid bearer token from the HS.
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 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) {
let url = SdkConfig.get().integrations_rest_url + "/account";
const defer = Promise.defer();
request({
method: "GET",
uri: url,
qs: {scalar_token: token},
json: true,
}, (err, response, body) => {
if (err) {
defer.reject(err);
} else if (response.statusCode / 100 !== 2) {
defer.reject({statusCode: response.statusCode});
} else if (!body || !body.user_id) {
defer.reject(new Error("Missing user_id in response"));
} else {
defer.resolve(body.user_id);
}
});
return defer.promise;
}
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);
@ -53,9 +96,9 @@ class ScalarAuthClient {
}
exchangeForScalarToken(openid_token_object) {
var defer = q.defer();
const defer = Promise.defer();
var scalar_rest_url = SdkConfig.get().integrations_rest_url;
const scalar_rest_url = SdkConfig.get().integrations_rest_url;
request({
method: 'POST',
uri: scalar_rest_url+'/register',
@ -76,10 +119,46 @@ class ScalarAuthClient {
return defer.promise;
}
getScalarInterfaceUrlForRoom(roomId) {
var url = SdkConfig.get().integrations_ui_url;
getScalarPageTitle(url) {
const defer = Promise.defer();
let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup';
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
request({
method: 'GET',
uri: scalarPageLookupUrl,
json: true,
}, (err, response, body) => {
if (err) {
defer.reject(err);
} else if (response.statusCode / 100 !== 2) {
defer.reject({statusCode: response.statusCode});
} else if (!body) {
defer.reject(new Error("Missing page title in response"));
} else {
let title = "";
if (body.page_title_cache_item && body.page_title_cache_item.cached_title) {
title = body.page_title_cache_item.cached_title;
}
defer.resolve(title);
}
});
return defer.promise;
}
getScalarInterfaceUrlForRoom(roomId, screen, id) {
let url = SdkConfig.get().integrations_ui_url;
url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
url += "&room_id=" + encodeURIComponent(roomId);
url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme"));
if (id) {
url += '&integ_id=' + encodeURIComponent(id);
}
if (screen) {
url += '&screen=' + encodeURIComponent(screen);
}
return url;
}
@ -89,4 +168,3 @@ class ScalarAuthClient {
}
module.exports = ScalarAuthClient;

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,7 +18,7 @@ limitations under the License.
/*
Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed:
{
action: "invite" | "membership_state" | "bot_options" | "set_bot_options",
action: "invite" | "membership_state" | "bot_options" | "set_bot_options" | etc... ,
room_id: $ROOM_ID,
user_id: $USER_ID
// additional request fields
@ -94,6 +95,115 @@ Example:
}
}
get_membership_count
--------------------
Get the number of joined users in the room.
Request:
- room_id is the room to get the count in.
Response:
78
Example:
{
action: "get_membership_count",
room_id: "!foo:bar",
response: 78
}
can_send_event
--------------
Check if the client can send the given event into the given room. If the client
is unable to do this, an error response is returned instead of 'response: false'.
Request:
- room_id is the room to do the check in.
- event_type is the event type which will be sent.
- is_state is true if the event to be sent is a state event.
Response:
true
Example:
{
action: "can_send_event",
is_state: false,
event_type: "m.room.message",
room_id: "!foo:bar",
response: true
}
set_widget
----------
Set a new widget in the room. Clobbers based on the ID.
Request:
- `room_id` (String) is the room to set the widget in.
- `widget_id` (String) is the ID of the widget to add (or replace if it already exists).
It can be an arbitrary UTF8 string and is purely for distinguishing between widgets.
- `url` (String) is the URL that clients should load in an iframe to run the widget.
All widgets must have a valid URL. If the URL is `null` (not `undefined`), the
widget will be removed from the room.
- `type` (String) is the type of widget, which is provided as a hint for matrix clients so they
can configure/lay out the widget in different ways. All widgets must have a type.
- `name` (String) is an optional human-readable string about the widget.
- `data` (Object) is some optional data about the widget, and can contain arbitrary key/value pairs.
Response:
{
success: true
}
Example:
{
action: "set_widget",
room_id: "!foo:bar",
widget_id: "abc123",
url: "http://widget.url",
type: "example",
response: {
success: true
}
}
get_widgets
-----------
Get a list of all widgets in the room. The response is an array
of state events.
Request:
- `room_id` (String) is the room to get the widgets in.
Response:
[
{
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
type: "grafana",
url: "https://grafanaurl",
name: "dashboard",
data: {key: "val"}
}
room_id: !foo:bar,
sender: "@alice:localhost"
}
]
Example:
{
action: "get_widgets",
room_id: "!foo:bar",
response: [
{
type: "im.vector.modular.widgets",
state_key: "wid1",
content: {
type: "grafana",
url: "https://grafanaurl",
name: "dashboard",
data: {key: "val"}
}
room_id: !foo:bar,
sender: "@alice:localhost"
}
]
}
membership_state AND bot_options
--------------------------------
Get the content of the "m.room.member" or "m.room.bot.options" state event respectively.
@ -125,6 +235,7 @@ const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require("./MatrixClientPeg");
const MatrixEvent = require("matrix-js-sdk").MatrixEvent;
const dis = require("./dispatcher");
import { _t } from './languageHandler';
function sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
@ -150,7 +261,7 @@ function inviteUser(event, roomId, userId) {
console.log(`Received request to invite ${userId} into room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
@ -170,10 +281,107 @@ function inviteUser(event, roomId, userId) {
success: true,
});
}, function(err) {
sendError(event, "You need to be able to invite users to do that.", err);
sendError(event, _t('You need to be able to invite users to do that.'), err);
});
}
function setWidget(event, roomId) {
const widgetId = event.data.widget_id;
const widgetType = event.data.type;
const widgetUrl = event.data.url;
const widgetName = event.data.name; // optional
const widgetData = event.data.data; // optional
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
// both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields."));
return;
}
if (widgetUrl !== null) { // if url is null it is being deleted, don't need to check name/type/etc
// check types of fields
if (widgetName !== undefined && typeof widgetName !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'name' must be a string."));
return;
}
if (widgetData !== undefined && !(widgetData instanceof Object)) {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object."));
return;
}
if (typeof widgetType !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string."));
return;
}
if (typeof widgetUrl !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'url' must be a string or null."));
return;
}
}
let content = {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
};
if (widgetUrl === null) { // widget is being deleted
content = {};
}
client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, _t('Failed to send request.'), err);
});
}
function getWidgets(event, roomId) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets");
// Only return widgets which have required fields
const widgetStateEvents = [];
stateEvents.forEach((ev) => {
if (ev.getContent().type && ev.getContent().url) {
widgetStateEvents.push(ev.event); // return the raw event
}
});
sendResponse(event, widgetStateEvents);
}
function getRoomEncState(event, roomId) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
sendResponse(event, roomIsEncrypted);
}
function setPlumbingState(event, roomId, status) {
if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string');
@ -181,15 +389,15 @@ function setPlumbingState(event, roomId, status) {
console.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
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 }).done(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : "Failed to send request.", err);
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
});
}
@ -197,7 +405,7 @@ function setBotOptions(event, roomId, userId) {
console.log(`Received request to set options for bot ${userId} in room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
sendError(event, _t('You need to be logged in.'));
return;
}
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => {
@ -205,29 +413,29 @@ function setBotOptions(event, roomId, userId) {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : "Failed to send request.", err);
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
});
}
function setBotPower(event, roomId, userId, level) {
if (!(Number.isInteger(level) && level >= 0)) {
sendError(event, "Power level must be positive integer.");
sendError(event, _t('Power level must be positive integer.'));
return;
}
console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
sendError(event, _t('You need to be logged in.'));
return;
}
client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => {
let powerEvent = new MatrixEvent(
const powerEvent = new MatrixEvent(
{
type: "m.room.power_levels",
content: powerLevels,
}
},
);
client.setPowerLevel(roomId, userId, level, powerEvent).done(() => {
@ -235,7 +443,7 @@ function setBotPower(event, roomId, userId, level) {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : "Failed to send request.", err);
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
});
});
}
@ -255,15 +463,65 @@ function botOptions(event, roomId, userId) {
returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId);
}
function returnStateEvent(event, roomId, eventType, stateKey) {
function getMembershipCount(event, roomId) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, "This room is not recognised.");
sendError(event, _t('This room is not recognised.'));
return;
}
const count = room.getJoinedMembers().length;
sendResponse(event, count);
}
function canSendEvent(event, roomId) {
const evType = "" + event.data.event_type; // force stringify
const isState = Boolean(event.data.is_state);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const me = client.credentials.userId;
const member = room.getMember(me);
if (!member || member.membership !== "join") {
sendError(event, _t('You are not in this room.'));
return;
}
let canSend = false;
if (isState) {
canSend = room.currentState.maySendStateEvent(evType, me);
} else {
canSend = room.currentState.maySendEvent(evType, me);
}
if (!canSend) {
sendError(event, _t('You do not have permission to do that in this room.'));
return;
}
sendResponse(event, true);
}
function returnStateEvent(event, roomId, eventType, stateKey) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const stateEvent = room.currentState.getStateEvents(eventType, stateKey);
@ -274,8 +532,8 @@ function returnStateEvent(event, roomId, eventType, stateKey) {
sendResponse(event, stateEvent.getContent());
}
var currentRoomId = null;
var currentRoomAlias = null;
let currentRoomId = null;
let currentRoomAlias = null;
// Listen for when a room is viewed
dis.register(onAction);
@ -299,8 +557,16 @@ const onMessage = function(event) {
//
// All strings start with the empty string, so for sanity return if the length
// of the event origin is 0.
let url = SdkConfig.get().integrations_ui_url;
if (event.origin.length === 0 || !url.startsWith(event.origin)) {
//
// 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) ||
!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
}
@ -313,13 +579,13 @@ const onMessage = function(event) {
const roomId = event.data.room_id;
const userId = event.data.user_id;
if (!roomId) {
sendError(event, "Missing room_id in request");
sendError(event, _t('Missing room_id in request'));
return;
}
let promise = Promise.resolve(currentRoomId);
if (!currentRoomId) {
if (!currentRoomAlias) {
sendError(event, "Must be viewing a room");
sendError(event, _t('Must be viewing a room'));
return;
}
// no room ID but there is an alias, look it up.
@ -331,21 +597,36 @@ const onMessage = function(event) {
promise.then((viewingRoomId) => {
if (roomId !== viewingRoomId) {
sendError(event, "Room " + roomId + " not visible");
sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId}));
return;
}
// Getting join rules does not require userId
// These APIs don't require userId
if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId);
return;
} else if (event.data.action === "set_plumbing_state") {
setPlumbingState(event, roomId, event.data.status);
return;
} else if (event.data.action === "get_membership_count") {
getMembershipCount(event, roomId);
return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
} else if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} else if (event.data.action === "get_room_enc_state") {
getRoomEncState(event, roomId);
return;
} else if (event.data.action === "can_send_event") {
canSendEvent(event, roomId);
return;
}
if (!userId) {
sendError(event, "Missing user_id in request");
sendError(event, _t('Missing user_id in request'));
return;
}
switch (event.data.action) {
@ -370,16 +651,31 @@ const onMessage = function(event) {
}
}, (err) => {
console.error(err);
sendError(event, "Failed to lookup current room.");
})
sendError(event, _t('Failed to lookup current room') + '.');
});
};
let listenerCount = 0;
module.exports = {
startListening: function() {
window.addEventListener("message", onMessage, false);
if (listenerCount === 0) {
window.addEventListener("message", onMessage, false);
}
listenerCount += 1;
},
stopListening: function() {
window.removeEventListener("message", onMessage);
listenerCount -= 1;
if (listenerCount === 0) {
window.removeEventListener("message", onMessage);
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"ScalarMessaging: mismatched startListening / stopListening detected." +
" Negative count",
);
console.error(e);
}
},
};

View file

@ -14,22 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var DEFAULTS = {
const DEFAULTS = {
// URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server
integrations_rest_url: "https://scalar.vector.im/api",
// Where to send bug reports. If not specified, bugs cannot be sent.
bug_report_endpoint_url: null,
piwik: {
url: "https://piwik.riot.im/",
whitelistedHSUrls: ["https://matrix.org"],
whitelistedISUrls: ["https://vector.im", "https://matrix.org"],
siteId: 1,
},
};
class SdkConfig {
static get() {
return global.mxReactSdkConfig;
return global.mxReactSdkConfig || {};
}
static put(cfg) {
var defaultKeys = Object.keys(DEFAULTS);
for (var i = 0; i < defaultKeys.length; ++i) {
const defaultKeys = Object.keys(DEFAULTS);
for (let i = 0; i < defaultKeys.length; ++i) {
if (cfg[defaultKeys[i]] === undefined) {
cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]];
}
@ -43,3 +52,4 @@ class SdkConfig {
}
module.exports = SdkConfig;
module.exports.DEFAULTS = DEFAULTS;

View file

@ -1,451 +0,0 @@
"use strict";
import Matrix from "matrix-js-sdk";
var MatrixClientPeg = require("./MatrixClientPeg");
var SignupStages = require("./SignupStages");
var dis = require("./dispatcher");
var q = require("q");
var url = require("url");
const EMAIL_STAGE_TYPE = "m.login.email.identity";
/**
* A base class for common functionality between Registration and Login e.g.
* storage of HS/IS URLs.
*/
class Signup {
constructor(hsUrl, isUrl, opts) {
this._hsUrl = hsUrl;
this._isUrl = isUrl;
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
}
getHomeserverUrl() {
return this._hsUrl;
}
getIdentityServerUrl() {
return this._isUrl;
}
setHomeserverUrl(hsUrl) {
this._hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._isUrl = isUrl;
}
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
*/
_createTemporaryClient() {
return Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
});
}
}
/**
* Registration logic class
* This exists for the lifetime of a user's attempt to register an account,
* so if their registration attempt fails for whatever reason and they
* try again, call register() on the same instance again.
*
* TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It
* would be nice to make use of that rather than rolling our own version of it.
*/
class Register extends Signup {
constructor(hsUrl, isUrl, opts) {
super(hsUrl, isUrl, opts);
this.setStep("START");
this.data = null; // from the server
// random other stuff (e.g. query params, NOT params from the server)
this.params = {};
this.credentials = null;
this.activeStage = null;
this.registrationPromise = null;
// These values MUST be undefined else we'll send "username: null" which
// will error on Synapse rather than having the key absent.
this.username = undefined; // desired
this.email = undefined; // desired
this.password = undefined; // desired
}
setClientSecret(secret) {
this.params.clientSecret = secret;
}
setSessionId(sessionId) {
this.params.sessionId = sessionId;
}
setRegistrationUrl(regUrl) {
this.params.registrationUrl = regUrl;
}
setIdSid(idSid) {
this.params.idSid = idSid;
}
setGuestAccessToken(token) {
this.guestAccessToken = token;
}
getStep() {
return this._step;
}
getCredentials() {
return this.credentials;
}
getServerData() {
return this.data || {};
}
getPromise() {
return this.registrationPromise;
}
setStep(step) {
this._step = 'Register.' + step;
// TODO:
// It's a shame this is going to the global dispatcher, we only really
// want things which have an instance of this class to be able to add
// listeners...
console.log("Dispatching 'registration_step_update' for step %s", this._step);
dis.dispatch({
action: "registration_step_update"
});
}
/**
* Starts the registration process from the first stage
*/
register(formVals) {
var {username, password, email} = formVals;
this.email = email;
this.username = username;
this.password = password;
const client = this._createTemporaryClient();
this.activeStage = null;
// If there hasn't been a client secret set by this point,
// generate one for this session. It will only be used if
// we do email verification, but far simpler to just make
// sure we have one.
// We re-use this same secret over multiple calls to register
// so that the identity server can honour the sendAttempt
// parameter and not re-send email unless we actually want
// another mail to be sent.
if (!this.params.clientSecret) {
this.params.clientSecret = client.generateClientSecret();
}
return this._tryRegister(client);
}
_tryRegister(client, authDict, poll_for_success) {
var self = this;
var bindEmail;
if (this.username && this.password) {
// only need to bind_email when sending u/p - sending it at other
// times clobbers the u/p resulting in M_MISSING_PARAM (password)
bindEmail = true;
}
// TODO need to figure out how to send the device display name to /register.
return client.register(
this.username, this.password, this.params.sessionId, authDict, bindEmail,
this.guestAccessToken
).then(function(result) {
self.credentials = result;
self.setStep("COMPLETE");
return result; // contains the credentials
}, function(error) {
if (error.httpStatus === 401) {
if (error.data && error.data.flows) {
// Remember the session ID from the server:
// Either this is our first 401 in which case we need to store the
// session ID for future calls, or it isn't in which case this
// is just a no-op since it ought to be the same (or if it isn't,
// we should use the latest one from the server in any case).
self.params.sessionId = error.data.session;
self.data = error.data || {};
var flow = self.chooseFlow(error.data.flows);
if (flow) {
console.log("Active flow => %s", JSON.stringify(flow));
var flowStage = self.firstUncompletedStage(flow);
if (!self.activeStage || flowStage != self.activeStage.type) {
return self._startStage(client, flowStage).catch(function(err) {
self.setStep('START');
throw err;
});
}
}
}
if (poll_for_success) {
return q.delay(5000).then(function() {
return self._tryRegister(client, authDict, poll_for_success);
});
} else {
throw new Error("Authorisation failed!");
}
} else {
if (error.errcode === 'M_USER_IN_USE') {
throw new Error("Username in use");
} else if (error.errcode == 'M_INVALID_USERNAME') {
throw new Error("User names may only contain alphanumeric characters, underscores or dots!");
} else if (error.httpStatus >= 400 && error.httpStatus < 500) {
throw new Error(`Registration failed! (${error.httpStatus})`);
} else if (error.httpStatus >= 500 && error.httpStatus < 600) {
throw new Error(
`Server error during registration! (${error.httpStatus})`
);
} else if (error.name == "M_MISSING_PARAM") {
// The HS hasn't remembered the login params from
// the first try when the login email was sent.
throw new Error(
"This home server does not support resuming registration."
);
}
}
});
}
firstUncompletedStage(flow) {
for (var i = 0; i < flow.stages.length; ++i) {
if (!this.hasCompletedStage(flow.stages[i])) {
return flow.stages[i];
}
}
}
hasCompletedStage(stageType) {
var completed = (this.data || {}).completed || [];
return completed.indexOf(stageType) !== -1;
}
_startStage(client, stageName) {
var self = this;
this.setStep(`STEP_${stageName}`);
var StageClass = SignupStages[stageName];
if (!StageClass) {
// no idea how to handle this!
throw new Error("Unknown stage: " + stageName);
}
var stage = new StageClass(client, this);
this.activeStage = stage;
return stage.complete().then(function(request) {
if (request.auth) {
console.log("Stage %s is returning an auth dict", stageName);
return self._tryRegister(client, request.auth, request.poll_for_success);
}
else {
// never resolve the promise chain. This is for things like email auth
// which display a "check your email" message and relies on the
// link in the email to actually register you.
console.log("Waiting for external action.");
return q.defer().promise;
}
});
}
chooseFlow(flows) {
// If the user gave us an email then we want to pick an email
// flow we can do, else any other flow.
var emailFlow = null;
var otherFlow = null;
flows.forEach(function(flow) {
var flowHasEmail = false;
for (var stageI = 0; stageI < flow.stages.length; ++stageI) {
var stage = flow.stages[stageI];
if (!SignupStages[stage]) {
// we can't do this flow, don't have a Stage impl.
return;
}
if (stage === EMAIL_STAGE_TYPE) {
flowHasEmail = true;
}
}
if (flowHasEmail) {
emailFlow = flow;
} else {
otherFlow = flow;
}
});
if (this.email || this.hasCompletedStage(EMAIL_STAGE_TYPE)) {
// we've been given an email or we've already done an email part
return emailFlow;
} else {
return otherFlow;
}
}
recheckState() {
// We've been given a bunch of data from a previous register step,
// this only happens for email auth currently. It's kinda ming we need
// to know this though. A better solution would be to ask the stages if
// they are ready to do something rather than accepting that we know about
// email auth and its internals.
this.params.hasEmailInfo = (
this.params.clientSecret && this.params.sessionId && this.params.idSid
);
if (this.params.hasEmailInfo) {
const client = this._createTemporaryClient();
this.registrationPromise = this._startStage(client, EMAIL_STAGE_TYPE);
}
return this.registrationPromise;
}
tellStage(stageName, data) {
if (this.activeStage && this.activeStage.type === stageName) {
console.log("Telling stage %s about something..", stageName);
this.activeStage.onReceiveData(data);
}
}
}
class Login extends Signup {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
super(hsUrl, isUrl, opts);
this._fallbackHsUrl = fallbackHsUrl;
this._currentFlowIndex = 0;
this._flows = [];
}
getFlows() {
var self = this;
var client = this._createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
// technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here.
return self._flows;
});
}
chooseFlow(flowIndex) {
this._currentFlowIndex = flowIndex;
}
getCurrentFlowStep() {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
var flowStep = this._flows[this._currentFlowIndex];
return flowStep ? flowStep.type : null;
}
loginAsGuest() {
var client = this._createTemporaryClient();
return client.registerGuest({
body: {
initial_device_display_name: this._defaultDeviceDisplayName,
},
}).then((creds) => {
return {
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
homeserverUrl: this._hsUrl,
identityServerUrl: this._isUrl,
guest: true
};
}, (error) => {
if (error.httpStatus === 403) {
error.friendlyText = "Guest access is disabled on this Home Server.";
} else {
error.friendlyText = "Failed to register as guest: " + error.data;
}
throw error;
});
}
loginViaPassword(username, pass) {
var self = this;
var isEmail = username.indexOf("@") > 0;
var loginParams = {
password: pass,
initial_device_display_name: this._defaultDeviceDisplayName,
};
if (isEmail) {
loginParams.medium = 'email';
loginParams.address = username;
} else {
loginParams.user = username;
}
var client = this._createTemporaryClient();
return client.login('m.login.password', loginParams).then(function(data) {
return q({
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token
});
}, function(error) {
if (error.httpStatus == 400 && loginParams.medium) {
error.friendlyText = (
'This Home Server does not support login using email address.'
);
}
else if (error.httpStatus === 403) {
error.friendlyText = (
'Incorrect username and/or password.'
);
if (self._fallbackHsUrl) {
var fbClient = Matrix.createClient({
baseUrl: self._fallbackHsUrl,
idBaseUrl: this._isUrl,
});
return fbClient.login('m.login.password', loginParams).then(function(data) {
return q({
homeserverUrl: self._fallbackHsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token
});
}, function(fallback_error) {
// throw the original error
throw error;
});
}
}
else {
error.friendlyText = (
'There was a problem logging in. (HTTP ' + error.httpStatus + ")"
);
}
throw error;
});
}
redirectToCas() {
var client = this._createTemporaryClient();
var parsedUrl = url.parse(window.location.href, true);
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
var casUrl = client.getCasLoginUrl(url.format(parsedUrl));
window.location.href = casUrl;
}
}
module.exports.Register = Register;
module.exports.Login = Login;

View file

@ -1,166 +0,0 @@
"use strict";
var q = require("q");
/**
* An interface class which login types should abide by.
*/
class Stage {
constructor(type, matrixClient, signupInstance) {
this.type = type;
this.client = matrixClient;
this.signupInstance = signupInstance;
}
complete() {
// Return a promise which is:
// RESOLVED => With an Object which has an 'auth' key which is the auth dict
// to submit.
// REJECTED => With an Error if there was a problem with this stage.
// Has a "message" string and an "isFatal" flag.
return q.reject("NOT IMPLEMENTED");
}
onReceiveData() {
// NOP
}
}
Stage.TYPE = "NOT IMPLEMENTED";
/**
* This stage requires no auth.
*/
class DummyStage extends Stage {
constructor(matrixClient, signupInstance) {
super(DummyStage.TYPE, matrixClient, signupInstance);
}
complete() {
return q({
auth: {
type: DummyStage.TYPE
}
});
}
}
DummyStage.TYPE = "m.login.dummy";
/**
* This stage uses Google's Recaptcha to do auth.
*/
class RecaptchaStage extends Stage {
constructor(matrixClient, signupInstance) {
super(RecaptchaStage.TYPE, matrixClient, signupInstance);
this.defer = q.defer(); // resolved with the captcha response
}
// called when the recaptcha has been completed.
onReceiveData(data) {
if (!data || !data.response) {
return;
}
this.defer.resolve({
auth: {
type: 'm.login.recaptcha',
response: data.response,
}
});
}
complete() {
return this.defer.promise;
}
}
RecaptchaStage.TYPE = "m.login.recaptcha";
/**
* This state uses the IS to verify email addresses.
*/
class EmailIdentityStage extends Stage {
constructor(matrixClient, signupInstance) {
super(EmailIdentityStage.TYPE, matrixClient, signupInstance);
}
_completeVerify() {
// pull out the host of the IS URL by creating an anchor element
var isLocation = document.createElement('a');
isLocation.href = this.signupInstance.getIdentityServerUrl();
var clientSecret = this.clientSecret || this.signupInstance.params.clientSecret;
var sid = this.sid || this.signupInstance.params.idSid;
return q({
auth: {
type: 'm.login.email.identity',
threepid_creds: {
sid: sid,
client_secret: clientSecret,
id_server: isLocation.host
}
}
});
}
/**
* Complete the email stage.
*
* This is called twice under different circumstances:
* 1) When requesting an email token from the IS
* 2) When validating query parameters received from the link in the email
*/
complete() {
// TODO: The Registration class shouldn't really know this info.
if (this.signupInstance.params.hasEmailInfo) {
return this._completeVerify();
}
this.clientSecret = this.signupInstance.params.clientSecret;
if (!this.clientSecret) {
return q.reject(new Error("No client secret specified by Signup class!"));
}
var nextLink = this.signupInstance.params.registrationUrl +
'?client_secret=' +
encodeURIComponent(this.clientSecret) +
"&hs_url=" +
encodeURIComponent(this.signupInstance.getHomeserverUrl()) +
"&is_url=" +
encodeURIComponent(this.signupInstance.getIdentityServerUrl()) +
"&session_id=" +
encodeURIComponent(this.signupInstance.getServerData().session);
var self = this;
return this.client.requestRegisterEmailToken(
this.signupInstance.email,
this.clientSecret,
1, // TODO: Multiple send attempts?
nextLink
).then(function(response) {
self.sid = response.sid;
return self._completeVerify();
}).then(function(request) {
request.poll_for_success = true;
return request;
}, function(error) {
console.error(error);
var e = {
isFatal: true
};
if (error.errcode == 'M_THREEPID_IN_USE') {
e.message = "This email address is already registered";
} else {
e.message = 'Unable to contact the given identity server';
}
throw e;
});
}
}
EmailIdentityStage.TYPE = "m.login.email.identity";
module.exports = {
[DummyStage.TYPE]: DummyStage,
[RecaptchaStage.TYPE]: RecaptchaStage,
[EmailIdentityStage.TYPE]: EmailIdentityStage
};

View file

@ -23,41 +23,46 @@ class Skinner {
if (this.components === null) {
throw new Error(
"Attempted to get a component before a skin has been loaded."+
"This is probably because either:"+
" This is probably because either:"+
" a) Your app has not called sdk.loadSkin(), or"+
" b) A component has called getComponent at the root level"
" b) A component has called getComponent at the root level",
);
}
var comp = this.components[name];
if (comp) {
return comp;
}
let comp = this.components[name];
// XXX: Temporarily also try 'views.' as we're currently
// leaving the 'views.' off views.
var comp = this.components['views.'+name];
if (comp) {
return comp;
if (!comp) {
comp = this.components['views.'+name];
}
throw new Error("No such component: "+name);
if (!comp) {
throw new Error("No such component: "+name);
}
// components have to be functions.
const validType = typeof comp === 'function';
if (!validType) {
throw new Error(`Not a valid component: ${name}.`);
}
return comp;
}
load(skinObject) {
if (this.components !== null) {
throw new Error(
"Attempted to load a skin while a skin is already loaded"+
"If you want to change the active skin, call resetSkin first"
);
"If you want to change the active skin, call resetSkin first");
}
this.components = {};
var compKeys = Object.keys(skinObject.components);
for (var i = 0; i < compKeys.length; ++i) {
var comp = skinObject.components[compKeys[i]];
const compKeys = Object.keys(skinObject.components);
for (let i = 0; i < compKeys.length; ++i) {
const comp = skinObject.components[compKeys[i]];
this.addComponent(compKeys[i], comp);
}
}
addComponent(name, comp) {
var slot = name;
let slot = name;
if (comp.replaces !== undefined) {
if (comp.replaces.indexOf('.') > -1) {
slot = comp.replaces;
@ -79,6 +84,9 @@ class Skinner {
// behaviour with multiple copies of files etc. is erratic at best.
// XXX: We can still end up with the same file twice in the resulting
// JS bundle which is nonideal.
// See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/
// or https://nodejs.org/api/modules.html#modules_module_caching_caveats
// ("Modules are cached based on their resolved filename")
if (global.mxSkinner === undefined) {
global.mxSkinner = new Skinner();
}

View file

@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var MatrixClientPeg = require("./MatrixClientPeg");
var dis = require("./dispatcher");
var Tinter = require("./Tinter");
import MatrixClientPeg from "./MatrixClientPeg";
import dis from "./dispatcher";
import Tinter from "./Tinter";
import sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
class Command {
@ -41,58 +43,64 @@ class Command {
}
getUsage() {
return "Usage: " + this.getCommandWithArgs()
return _t('Usage') + ': ' + this.getCommandWithArgs();
}
}
var reject = function(msg) {
function reject(msg) {
return {
error: msg
error: msg,
};
};
}
var success = function(promise) {
function success(promise) {
return {
promise: promise
promise: promise,
};
};
}
var commands = {
/* Disable the "unexpected this" error for these commands - all of the run
* functions are called with `this` bound to the Command instance.
*/
/* eslint-disable babel/no-invalid-this */
const commands = {
ddg: new Command("ddg", "<query>", function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createDialog(ErrorDialog, {
title: "/ddg is not a command",
description: "To use it, just wait for autocomplete results to load and tab through them.",
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
});
return success();
}),
// Change your nickname
nick: new Command("nick", "<display_name>", function(room_id, args) {
nick: new Command("nick", "<display_name>", function(roomId, args) {
if (args) {
return success(
MatrixClientPeg.get().setDisplayName(args)
MatrixClientPeg.get().setDisplayName(args),
);
}
return reject(this.getUsage());
}),
// Changes the colorscheme of your current room
tint: new Command("tint", "<color1> [<color2>]", function(room_id, args) {
tint: new Command("tint", "<color1> [<color2>]", function(roomId, args) {
if (args) {
var matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/);
const matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/);
if (matches) {
Tinter.tint(matches[1], matches[4]);
var colorScheme = {}
const colorScheme = {};
colorScheme.primary_color = matches[1];
if (matches[4]) {
colorScheme.secondary_color = matches[4];
} else {
colorScheme.secondary_color = colorScheme.primary_color;
}
return success(
MatrixClientPeg.get().setRoomAccountData(
room_id, "org.matrix.room.color_scheme", colorScheme
)
SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
);
}
}
@ -100,22 +108,22 @@ var commands = {
}),
// Change the room topic
topic: new Command("topic", "<topic>", function(room_id, args) {
topic: new Command("topic", "<topic>", function(roomId, args) {
if (args) {
return success(
MatrixClientPeg.get().setRoomTopic(room_id, args)
MatrixClientPeg.get().setRoomTopic(roomId, args),
);
}
return reject(this.getUsage());
}),
// Invite a user
invite: new Command("invite", "<userId>", function(room_id, args) {
invite: new Command("invite", "<userId>", function(roomId, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
const matches = args.match(/^(\S+)$/);
if (matches) {
return success(
MatrixClientPeg.get().invite(room_id, matches[1])
MatrixClientPeg.get().invite(roomId, matches[1]),
);
}
}
@ -123,21 +131,21 @@ var commands = {
}),
// Join a room
join: new Command("join", "#alias:domain", function(room_id, args) {
join: new Command("join", "#alias:domain", function(roomId, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
const matches = args.match(/^(\S+)$/);
if (matches) {
var room_alias = matches[1];
if (room_alias[0] !== '#') {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') {
return reject(this.getUsage());
}
if (!room_alias.match(/:/)) {
room_alias += ':' + MatrixClientPeg.get().getDomain();
if (!roomAlias.match(/:/)) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
dis.dispatch({
action: 'view_room',
room_alias: room_alias,
room_alias: roomAlias,
auto_join: true,
});
@ -147,29 +155,29 @@ var commands = {
return reject(this.getUsage());
}),
part: new Command("part", "[#alias:domain]", function(room_id, args) {
var targetRoomId;
part: new Command("part", "[#alias:domain]", function(roomId, args) {
let targetRoomId;
if (args) {
var matches = args.match(/^(\S+)$/);
const matches = args.match(/^(\S+)$/);
if (matches) {
var room_alias = matches[1];
if (room_alias[0] !== '#') {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') {
return reject(this.getUsage());
}
if (!room_alias.match(/:/)) {
room_alias += ':' + MatrixClientPeg.get().getDomain();
if (!roomAlias.match(/:/)) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
// Try to find a room with this alias
var rooms = MatrixClientPeg.get().getRooms();
for (var i = 0; i < rooms.length; i++) {
var aliasEvents = rooms[i].currentState.getStateEvents(
"m.room.aliases"
const rooms = MatrixClientPeg.get().getRooms();
for (let i = 0; i < rooms.length; i++) {
const aliasEvents = rooms[i].currentState.getStateEvents(
"m.room.aliases",
);
for (var j = 0; j < aliasEvents.length; j++) {
var aliases = aliasEvents[j].getContent().aliases || [];
for (var k = 0; k < aliases.length; k++) {
if (aliases[k] === room_alias) {
for (let j = 0; j < aliasEvents.length; j++) {
const aliases = aliasEvents[j].getContent().aliases || [];
for (let k = 0; k < aliases.length; k++) {
if (aliases[k] === roomAlias) {
targetRoomId = rooms[i].roomId;
break;
}
@ -178,27 +186,28 @@ var commands = {
}
if (targetRoomId) { break; }
}
}
if (!targetRoomId) {
return reject("Unrecognised room alias: " + room_alias);
if (!targetRoomId) {
return reject(_t("Unrecognised room alias:") + ' ' + roomAlias);
}
}
}
if (!targetRoomId) targetRoomId = room_id;
if (!targetRoomId) targetRoomId = roomId;
return success(
MatrixClientPeg.get().leave(targetRoomId).then(
function() {
dis.dispatch({action: 'view_next_room'});
})
function() {
dis.dispatch({action: 'view_next_room'});
},
),
);
}),
// Kick a user from the room with an optional reason
kick: new Command("kick", "<userId> [<reason>]", function(room_id, args) {
kick: new Command("kick", "<userId> [<reason>]", function(roomId, args) {
if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/);
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(
MatrixClientPeg.get().kick(room_id, matches[1], matches[3])
MatrixClientPeg.get().kick(roomId, matches[1], matches[3]),
);
}
}
@ -206,12 +215,12 @@ var commands = {
}),
// Ban a user from the room with an optional reason
ban: new Command("ban", "<userId> [<reason>]", function(room_id, args) {
ban: new Command("ban", "<userId> [<reason>]", function(roomId, args) {
if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/);
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(
MatrixClientPeg.get().ban(room_id, matches[1], matches[3])
MatrixClientPeg.get().ban(roomId, matches[1], matches[3]),
);
}
}
@ -219,13 +228,66 @@ var commands = {
}),
// Unban a user from the room
unban: new Command("unban", "<userId>", function(room_id, args) {
unban: new Command("unban", "<userId>", function(roomId, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
const matches = args.match(/^(\S+)$/);
if (matches) {
// Reset the user membership to "leave" to unban him
return success(
MatrixClientPeg.get().unban(room_id, matches[1])
MatrixClientPeg.get().unban(roomId, matches[1]),
);
}
}
return reject(this.getUsage());
}),
ignore: new Command("ignore", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, {
title: _t("Ignored user"),
description: (
<div>
<p>{ _t("You are now ignoring %(userId)s", {userId: userId}) }</p>
</div>
),
hasCancelButton: false,
});
}),
);
}
}
return reject(this.getUsage());
}),
unignore: new Command("unignore", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
const index = ignoredUsers.indexOf(userId);
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, {
title: _t("Unignored user"),
description: (
<div>
<p>{ _t("You are no longer ignoring %(userId)s", {userId: userId}) }</p>
</div>
),
hasCancelButton: false,
});
}),
);
}
}
@ -233,27 +295,27 @@ var commands = {
}),
// Define the power level of a user
op: new Command("op", "<userId> [<power level>]", function(room_id, args) {
op: new Command("op", "<userId> [<power level>]", function(roomId, args) {
if (args) {
var matches = args.match(/^(\S+?)( +(\d+))?$/);
var powerLevel = 50; // default power level for op
const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
let powerLevel = 50; // default power level for op
if (matches) {
var user_id = matches[1];
const userId = matches[1];
if (matches.length === 4 && undefined !== matches[3]) {
powerLevel = parseInt(matches[3]);
}
if (powerLevel !== NaN) {
var room = MatrixClientPeg.get().getRoom(room_id);
if (!isNaN(powerLevel)) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return reject("Bad room ID: " + room_id);
return reject("Bad room ID: " + roomId);
}
var powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", ""
const powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", "",
);
return success(
MatrixClientPeg.get().setPowerLevel(
room_id, user_id, powerLevel, powerLevelEvent
)
roomId, userId, powerLevel, powerLevelEvent,
),
);
}
}
@ -262,33 +324,102 @@ var commands = {
}),
// Reset the power level of a user
deop: new Command("deop", "<userId>", function(room_id, args) {
deop: new Command("deop", "<userId>", function(roomId, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
const matches = args.match(/^(\S+)$/);
if (matches) {
var room = MatrixClientPeg.get().getRoom(room_id);
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return reject("Bad room ID: " + room_id);
return reject("Bad room ID: " + roomId);
}
var powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", ""
const powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", "",
);
return success(
MatrixClientPeg.get().setPowerLevel(
room_id, args, undefined, powerLevelEvent
)
roomId, args, undefined, powerLevelEvent,
),
);
}
}
return reject(this.getUsage());
})
}),
// Open developer tools
devtools: new Command("devtools", "", function(roomId) {
const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog");
Modal.createDialog(DevtoolsDialog, { roomId });
return success();
}),
// Verify a user, device, and pubkey tuple
verify: new Command("verify", "<userId> <deviceId> <deviceSigningKey>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
if (matches) {
const userId = matches[1];
const deviceId = matches[2];
const fingerprint = matches[3];
return success(
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => {
if (!device) {
throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`);
}
if (device.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw new Error(_t(`Device already verified!`));
} else {
throw new Error(_t(`WARNING: Device already verified, but keys do NOT MATCH!`));
}
}
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new Error(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key' +
' "%(fingerprint)s". This could mean your communications are being intercepted!',
{deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint}));
}
return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, {
title: _t("Verified key"),
description: (
<div>
<p>
{
_t("The signing key you provided matches the signing key you received " +
"from %(userId)s's device %(deviceId)s. Device marked as verified.",
{userId: userId, deviceId: deviceId})
}
</p>
</div>
),
hasCancelButton: false,
});
}),
);
}
}
return reject(this.getUsage());
}),
};
/* eslint-enable babel/no-invalid-this */
// helpful aliases
var aliases = {
j: "join"
}
const aliases = {
j: "join",
};
module.exports = {
/**
@ -304,13 +435,13 @@ module.exports = {
// IRC-style commands
input = input.replace(/\s+$/, "");
if (input[0] === "/" && input[1] !== "/") {
var bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
var cmd, args;
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
let cmd;
let args;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[3];
}
else {
} else {
cmd = input;
}
if (cmd === "me") return null;
@ -319,9 +450,8 @@ module.exports = {
}
if (commands[cmd]) {
return commands[cmd].run(roomId, args);
}
else {
return reject("Unrecognised command: " + input);
} else {
return reject(_t("Unrecognised command:") + ' ' + input);
}
}
return null; // not a command
@ -329,12 +459,12 @@ module.exports = {
getCommandList: function() {
// Return all the commands plus /me and /markdown which aren't handled like normal commands
var cmds = Object.keys(commands).sort().map(function(cmdKey) {
const cmds = Object.keys(commands).sort().map(function(cmdKey) {
return commands[cmdKey];
})
cmds.push(new Command("me", "<action>", function(){}));
cmds.push(new Command("markdown", "<on|off>", function(){}));
});
cmds.push(new Command("me", "<action>", function() {}));
cmds.push(new Command("markdown", "<on|off>", function() {}));
return cmds;
}
},
};

View file

@ -1,391 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Entry, MemberEntry, CommandEntry } from './TabCompleteEntries';
import SlashCommands from './SlashCommands';
import MatrixClientPeg from './MatrixClientPeg';
const DELAY_TIME_MS = 1000;
const KEY_TAB = 9;
const KEY_SHIFT = 16;
const KEY_WINDOWS = 91;
// NB: DO NOT USE \b its "words" are roman alphabet only!
//
// Capturing group containing the start
// of line or a whitespace char
// \_______________ __________Capturing group of 0 or more non-whitespace chars
// _|__ _|_ followed by the end of line
// / \/ \
const MATCH_REGEX = /(^|\s)(\S*)$/;
class TabComplete {
constructor(opts) {
opts.allowLooping = opts.allowLooping || false;
opts.autoEnterTabComplete = opts.autoEnterTabComplete || false;
opts.onClickCompletes = opts.onClickCompletes || false;
this.opts = opts;
this.completing = false;
this.list = []; // full set of tab-completable things
this.matchedList = []; // subset of completable things to loop over
this.currentIndex = 0; // index in matchedList currently
this.originalText = null; // original input text when tab was first hit
this.textArea = opts.textArea; // DOMElement
this.isFirstWord = false; // true if you tab-complete on the first word
this.enterTabCompleteTimerId = null;
this.inPassiveMode = false;
// Map tracking ordering of the room members.
// userId: integer, highest comes first.
this.memberTabOrder = {};
// monotonically increasing counter used for tracking ordering of members
this.memberOrderSeq = 0;
}
/**
* Call this when a a UI element representing a tab complete entry has been clicked
* @param {entry} The entry that was clicked
*/
onEntryClick(entry) {
if (this.opts.onClickCompletes) {
this.completeTo(entry);
}
}
loadEntries(room) {
this._makeEntries(room);
this._initSorting(room);
this._sortEntries();
}
onMemberSpoke(member) {
if (this.memberTabOrder[member.userId] === undefined) {
this.list.push(new MemberEntry(member));
}
this.memberTabOrder[member.userId] = this.memberOrderSeq++;
this._sortEntries();
}
/**
* @param {DOMElement}
*/
setTextArea(textArea) {
this.textArea = textArea;
}
/**
* @return {Boolean}
*/
isTabCompleting() {
// actually have things to tab over
return this.completing && this.matchedList.length > 1;
}
stopTabCompleting() {
this.completing = false;
this.currentIndex = 0;
this._notifyStateChange();
}
startTabCompleting(passive) {
this.originalText = this.textArea.value; // cache starting text
// grab the partial word from the text which we'll be tab-completing
var res = MATCH_REGEX.exec(this.originalText);
if (!res) {
this.matchedList = [];
return;
}
// ES6 destructuring; ignore first element (the complete match)
var [ , boundaryGroup, partialGroup] = res;
if (partialGroup.length === 0 && passive) {
return;
}
this.isFirstWord = partialGroup.length === this.originalText.length;
this.completing = true;
this.currentIndex = 0;
this.matchedList = [
new Entry(partialGroup) // first entry is always the original partial
];
// find matching entries in the set of entries given to us
this.list.forEach((entry) => {
if (entry.text.toLowerCase().indexOf(partialGroup.toLowerCase()) === 0) {
this.matchedList.push(entry);
}
});
// console.log("calculated completions => %s", JSON.stringify(this.matchedList));
}
/**
* Do an auto-complete with the given word. This terminates the tab-complete.
* @param {Entry} entry The tab-complete entry to complete to.
*/
completeTo(entry) {
this.textArea.value = this._replaceWith(
entry.getFillText(), true, entry.getSuffix(this.isFirstWord)
);
this.stopTabCompleting();
// keep focus on the text area
this.textArea.focus();
}
/**
* @param {Number} numAheadToPeek Return *up to* this many elements.
* @return {Entry[]}
*/
peek(numAheadToPeek) {
if (this.matchedList.length === 0) {
return [];
}
var peekList = [];
// return the current match item and then one with an index higher, and
// so on until we've reached the requested limit. If we hit the end of
// the list of options we're done.
for (var i = 0; i < numAheadToPeek; i++) {
var nextIndex;
if (this.opts.allowLooping) {
nextIndex = (this.currentIndex + i) % this.matchedList.length;
}
else {
nextIndex = this.currentIndex + i;
if (nextIndex === this.matchedList.length) {
break;
}
}
peekList.push(this.matchedList[nextIndex]);
}
// console.log("Peek list(%s): %s", numAheadToPeek, JSON.stringify(peekList));
return peekList;
}
handleTabPress(passive, shiftKey) {
var wasInPassiveMode = this.inPassiveMode && !passive;
this.inPassiveMode = passive;
if (!this.completing) {
this.startTabCompleting(passive);
}
if (shiftKey) {
this.nextMatchedEntry(-1);
}
else {
// if we were in passive mode we got out of sync by incrementing the
// index to show the peek view but not set the text area. Therefore,
// we want to set the *current* index rather than the *next* index.
this.nextMatchedEntry(wasInPassiveMode ? 0 : 1);
}
this._notifyStateChange();
}
/**
* @param {DOMEvent} e
*/
onKeyDown(ev) {
if (!this.textArea) {
console.error("onKeyDown called before a <textarea> was set!");
return;
}
if (ev.keyCode !== KEY_TAB) {
// pressing any key (except shift, windows, cmd (OSX) and ctrl/alt combinations)
// aborts the current tab completion
if (this.completing && ev.keyCode !== KEY_SHIFT &&
!ev.metaKey && !ev.ctrlKey && !ev.altKey && ev.keyCode !== KEY_WINDOWS) {
// they're resuming typing; reset tab complete state vars.
this.stopTabCompleting();
}
// explicitly pressing any key except tab removes passive mode. Tab doesn't remove
// passive mode because handleTabPress needs to know when passive mode is toggling
// off so it can resync the textarea/peek list. If tab did remove passive mode then
// handleTabPress would never be able to tell when passive mode toggled off.
this.inPassiveMode = false;
// pressing any key at all (except tab) restarts the automatic tab-complete timer
if (this.opts.autoEnterTabComplete) {
const cachedText = ev.target.value;
clearTimeout(this.enterTabCompleteTimerId);
this.enterTabCompleteTimerId = setTimeout(() => {
if (this.completing) {
// If you highlight text and CTRL+X it, tab-completing will not be reset.
// This check makes sure that if something like a cut operation has been
// done, that we correctly refresh the tab-complete list. Normal backspace
// operations get caught by the stopTabCompleting() section above, but
// because the CTRL key is held, this does not execute for CTRL+X.
if (cachedText !== this.textArea.value) {
this.stopTabCompleting();
}
}
if (!this.completing) {
this.handleTabPress(true, false);
}
}, DELAY_TIME_MS);
}
return;
}
// ctrl-tab/alt-tab etc shouldn't trigger a complete
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
// tab key has been pressed at this point
this.handleTabPress(false, ev.shiftKey)
// prevent the default TAB operation (typically focus shifting)
ev.preventDefault();
}
/**
* Set the textarea to the next value in the matched list.
* @param {Number} offset Offset to apply *before* setting the next value.
*/
nextMatchedEntry(offset) {
if (this.matchedList.length === 0) {
return;
}
// work out the new index, wrapping if necessary.
this.currentIndex += offset;
if (this.currentIndex >= this.matchedList.length) {
this.currentIndex = 0;
}
else if (this.currentIndex < 0) {
this.currentIndex = this.matchedList.length - 1;
}
var isTransitioningToOriginalText = (
// impossible to transition if they've never hit tab
!this.inPassiveMode && this.currentIndex === 0
);
if (!this.inPassiveMode) {
// set textarea to this new value
this.textArea.value = this._replaceWith(
this.matchedList[this.currentIndex].getFillText(),
this.currentIndex !== 0, // don't suffix the original text!
this.matchedList[this.currentIndex].getSuffix(this.isFirstWord)
);
}
// visual display to the user that we looped - TODO: This should be configurable
if (isTransitioningToOriginalText) {
this.textArea.style["background-color"] = "#faa";
setTimeout(() => { // yay for lexical 'this'!
this.textArea.style["background-color"] = "";
}, 150);
if (!this.opts.allowLooping) {
this.stopTabCompleting();
}
}
else {
this.textArea.style["background-color"] = ""; // cancel blinks TODO: required?
}
}
_replaceWith(newVal, includeSuffix, suffix) {
// The regex to replace the input matches a character of whitespace AND
// the partial word. If we just use string.replace() with the regex it will
// replace the partial word AND the character of whitespace. We want to
// preserve whatever that character is (\n, \t, etc) so find out what it is now.
var boundaryChar;
var res = MATCH_REGEX.exec(this.originalText);
if (res) {
boundaryChar = res[1]; // the first captured group
}
if (boundaryChar === undefined) {
console.warn("Failed to find boundary char on text: '%s'", this.originalText);
boundaryChar = "";
}
suffix = suffix || "";
if (!includeSuffix) {
suffix = "";
}
var replacementText = boundaryChar + newVal + suffix;
return this.originalText.replace(MATCH_REGEX, function() {
return replacementText; // function form to avoid `$` special-casing
});
}
_notifyStateChange() {
if (this.opts.onStateChange) {
this.opts.onStateChange(this.completing);
}
}
_sortEntries() {
// largest comes first
const KIND_ORDER = {
command: 1,
member: 2,
};
this.list.sort((a, b) => {
const kindOrderDifference = KIND_ORDER[b.kind] - KIND_ORDER[a.kind];
if (kindOrderDifference != 0) {
return kindOrderDifference;
}
if (a.kind == 'member') {
let orderA = this.memberTabOrder[a.member.userId];
let orderB = this.memberTabOrder[b.member.userId];
if (orderA === undefined) orderA = -1;
if (orderB === undefined) orderB = -1;
return orderB - orderA;
}
// anything else we have no ordering for
return 0;
});
}
_makeEntries(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
this.list = MemberEntry.fromMemberList(members).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
);
}
_initSorting(room) {
this.memberTabOrder = {};
this.memberOrderSeq = 0;
for (const ev of room.getLiveTimeline().getEvents()) {
this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++;
}
}
};
module.exports = TabComplete;

View file

@ -1,126 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
var sdk = require("./index");
class Entry {
constructor(text) {
this.text = text;
}
/**
* @return {string} The text to display in this entry.
*/
getText() {
return this.text;
}
/**
* @return {string} The text to insert into the input box. Most of the time
* this is the same as getText().
*/
getFillText() {
return this.text;
}
/**
* @return {ReactClass} Raw JSX
*/
getImageJsx() {
return null;
}
/**
* @return {?string} The unique key= prop for React dedupe
*/
getKey() {
return null;
}
/**
* @return {?string} The suffix to append to the tab-complete, or null to
* not do this.
*/
getSuffix(isFirstWord) {
return null;
}
/**
* Called when this entry is clicked.
*/
onClick() {
// NOP
}
}
class CommandEntry extends Entry {
constructor(cmd, cmdWithArgs) {
super(cmdWithArgs);
this.kind = 'command';
this.cmd = cmd;
}
getFillText() {
return this.cmd;
}
getKey() {
return this.getFillText();
}
getSuffix(isFirstWord) {
return " "; // force a space after the command.
}
}
CommandEntry.fromCommands = function(commandArray) {
return commandArray.map(function(cmd) {
return new CommandEntry(cmd.getCommand(), cmd.getCommandWithArgs());
});
}
class MemberEntry extends Entry {
constructor(member) {
super((member.name || member.userId).replace(' (IRC)', ''));
this.member = member;
this.kind = 'member';
}
getImageJsx() {
var MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
return (
<MemberAvatar member={this.member} width={24} height={24} />
);
}
getKey() {
return this.member.userId;
}
getSuffix(isFirstWord) {
return isFirstWord ? ": " : " ";
}
}
MemberEntry.fromMemberList = function(members) {
return members.map(function(m) {
return new MemberEntry(m);
});
}
module.exports.Entry = Entry;
module.exports.MemberEntry = MemberEntry;
module.exports.CommandEntry = CommandEntry;

View file

@ -13,194 +13,310 @@ 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.
*/
var MatrixClientPeg = require("./MatrixClientPeg");
var CallHandler = require("./CallHandler");
import MatrixClientPeg from './MatrixClientPeg';
import CallHandler from './CallHandler';
import { _t } from './languageHandler';
import * as Roles from './Roles';
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
var senderName = ev.sender ? ev.sender.name : ev.getSender();
var targetName = ev.target ? ev.target.name : ev.getStateKey();
var ConferenceHandler = CallHandler.getConferenceHandler();
var reason = ev.getContent().reason ? (
" Reason: " + ev.getContent().reason
) : "";
switch (ev.getContent().membership) {
case 'invite':
var threePidContent = ev.getContent().third_party_invite;
const senderName = ev.sender ? ev.sender.name : ev.getSender();
const targetName = ev.target ? ev.target.name : ev.getStateKey();
const prevContent = ev.getPrevContent();
const content = ev.getContent();
const ConferenceHandler = CallHandler.getConferenceHandler();
const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : '';
switch (content.membership) {
case 'invite': {
const threePidContent = content.third_party_invite;
if (threePidContent) {
if (threePidContent.display_name) {
return targetName + " accepted the invitation for " +
threePidContent.display_name + ".";
return _t('%(targetName)s accepted the invitation for %(displayName)s.', {
targetName,
displayName: threePidContent.display_name,
});
} else {
return targetName + " accepted an invitation.";
return _t('%(targetName)s accepted an invitation.', {targetName});
}
}
else {
} else {
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
return senderName + " requested a VoIP conference";
}
else {
return senderName + " invited " + targetName + ".";
return _t('%(senderName)s requested a VoIP conference.', {senderName});
} else {
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
}
}
}
case 'ban':
return senderName + " banned " + targetName + "." + reason;
return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason;
case 'join':
if (ev.getPrevContent() && ev.getPrevContent().membership == 'join') {
if (ev.getPrevContent().displayname && ev.getContent().displayname && ev.getPrevContent().displayname != ev.getContent().displayname) {
return ev.getSender() + " changed their display name from " +
ev.getPrevContent().displayname + " to " +
ev.getContent().displayname;
} else if (!ev.getPrevContent().displayname && ev.getContent().displayname) {
return ev.getSender() + " set their display name to " + ev.getContent().displayname;
} else if (ev.getPrevContent().displayname && !ev.getContent().displayname) {
return ev.getSender() + " removed their display name (" + ev.getPrevContent().displayname + ")";
} else if (ev.getPrevContent().avatar_url && !ev.getContent().avatar_url) {
return senderName + " removed their profile picture";
} else if (ev.getPrevContent().avatar_url && ev.getContent().avatar_url && ev.getPrevContent().avatar_url != ev.getContent().avatar_url) {
return senderName + " changed their profile picture";
} else if (!ev.getPrevContent().avatar_url && ev.getContent().avatar_url) {
return senderName + " set a profile picture";
if (prevContent && prevContent.membership === 'join') {
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', {
oldDisplayName: prevContent.displayname,
displayName: content.displayname,
});
} else if (!prevContent.displayname && content.displayname) {
return _t('%(senderName)s set their display name to %(displayName)s.', {
senderName,
displayName: content.displayname,
});
} else if (prevContent.displayname && !content.displayname) {
return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', {
senderName,
oldDisplayName: prevContent.displayname,
});
} else if (prevContent.avatar_url && !content.avatar_url) {
return _t('%(senderName)s removed their profile picture.', {senderName});
} else if (prevContent.avatar_url && content.avatar_url &&
prevContent.avatar_url !== content.avatar_url) {
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 {
// hacky hack for https://github.com/vector-im/vector-web/issues/2020
return senderName + " rejoined the room.";
// suppress null rejoins
return '';
}
} else {
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
return "VoIP conference started";
}
else {
return targetName + " joined the room.";
return _t('VoIP conference started.');
} else {
return _t('%(targetName)s joined the room.', {targetName});
}
}
return '';
case 'leave':
if (ev.getSender() === ev.getStateKey()) {
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
return "VoIP conference finished";
return _t('VoIP conference finished.');
} else if (prevContent.membership === "invite") {
return _t('%(targetName)s rejected the invitation.', {targetName});
} else {
return _t('%(targetName)s left the room.', {targetName});
}
else if (ev.getPrevContent().membership === "invite") {
return targetName + " rejected the invitation.";
}
else {
return targetName + " left the room.";
}
}
else if (ev.getPrevContent().membership === "ban") {
return senderName + " unbanned " + targetName + ".";
}
else if (ev.getPrevContent().membership === "join") {
return senderName + " kicked " + targetName + "." + reason;
}
else if (ev.getPrevContent().membership === "invite") {
return senderName + " withdrew " + targetName + "'s invitation." + reason;
}
else {
return targetName + " left the room.";
} 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});
}
}
}
function textForTopicEvent(ev) {
var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return senderDisplayName + ' changed the topic to "' + ev.getContent().topic + '"';
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
senderDisplayName,
topic: ev.getContent().topic,
});
}
function textForRoomNameEvent(ev) {
var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return senderDisplayName + ' changed the room name to "' + ev.getContent().name + '"';
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName});
}
return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
senderDisplayName,
roomName: ev.getContent().name,
});
}
function textForMessageEvent(ev) {
var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
var message = senderDisplayName + ': ' + ev.getContent().body;
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
let message = senderDisplayName + ': ' + ev.getContent().body;
if (ev.getContent().msgtype === "m.emote") {
message = "* " + senderDisplayName + " " + message;
} else if (ev.getContent().msgtype === "m.image") {
message = senderDisplayName + " sent an image.";
message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName});
}
return message;
}
function textForCallAnswerEvent(event) {
var senderName = event.sender ? event.sender.name : "Someone";
var supported = MatrixClientPeg.get().supportsVoip() ? "" : " (not supported by this browser)";
return senderName + " answered the call." + supported;
const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported;
}
function textForCallHangupEvent(event) {
var senderName = event.sender ? event.sender.name : "Someone";
var supported = MatrixClientPeg.get().supportsVoip() ? "" : " (not supported by this browser)";
return senderName + " ended the call." + supported;
const senderName = event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent();
let reason = "";
if (!MatrixClientPeg.get().supportsVoip()) {
reason = _t('(not supported by this browser)');
} else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") {
reason = _t('(could not connect media)');
} else if (eventContent.reason === "invite_timeout") {
reason = _t('(no answer)');
} else {
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
}
}
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
}
function textForCallInviteEvent(event) {
var senderName = event.sender ? event.sender.name : "Someone";
const senderName = event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
var type = "voice";
let callType = "voice";
if (event.getContent().offer && event.getContent().offer.sdp &&
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
type = "video";
callType = "video";
}
var supported = MatrixClientPeg.get().supportsVoip() ? "" : " (not supported by this browser)";
return senderName + " placed a " + type + " call." + supported;
const supported = MatrixClientPeg.get().supportsVoip() ? "" : _t('(not supported by this browser)');
return _t('%(senderName)s placed a %(callType)s call.', {senderName, callType}) + ' ' + supported;
}
function textForThreePidInviteEvent(event) {
var senderName = event.sender ? event.sender.name : event.getSender();
return senderName + " sent an invitation to " + event.getContent().display_name +
" to join the room.";
const senderName = event.sender ? event.sender.name : event.getSender();
return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {
senderName,
targetDisplayName: event.getContent().display_name,
});
}
function textForHistoryVisibilityEvent(event) {
var senderName = event.sender ? event.sender.name : event.getSender();
var vis = event.getContent().history_visibility;
var text = senderName + " made future room history visible to ";
if (vis === "invited") {
text += "all room members, from the point they are invited.";
const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) {
case 'invited':
return _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they are invited.', {senderName});
case 'joined':
return _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they joined.', {senderName});
case 'shared':
return _t('%(senderName)s made future room history visible to all room members.', {senderName});
case 'world_readable':
return _t('%(senderName)s made future room history visible to anyone.', {senderName});
default:
return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
senderName,
visibility: event.getContent().history_visibility,
});
}
else if (vis === "joined") {
text += "all room members, from the point they joined.";
}
else if (vis === "shared") {
text += "all room members.";
}
else if (vis === "world_readable") {
text += "anyone.";
}
else {
text += " unknown (" + vis + ")";
}
return text;
}
function textForEncryptionEvent(event) {
var senderName = event.sender ? event.sender.name : event.getSender();
return senderName + " turned on end-to-end encryption (algorithm " + event.getContent().algorithm + ")";
const senderName = event.sender ? event.sender.name : event.getSender();
return _t('%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).', {
senderName,
algorithm: event.getContent().algorithm,
});
}
var handlers = {
// 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) {
return '';
}
const userDefault = event.getContent().users_default || 0;
// Construct set of userIds
const users = [];
Object.keys(event.getContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
},
);
Object.keys(event.getPrevContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
},
);
const diff = [];
// XXX: This is also surely broken for i18n
users.forEach((userId) => {
// Previous power level
const from = event.getPrevContent().users[userId];
// Current power level
const to = event.getContent().users[userId];
if (to !== from) {
diff.push(
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
userId,
fromPowerLevel: Roles.textualPowerLevel(from, userDefault),
toPowerLevel: Roles.textualPowerLevel(to, userDefault),
}),
);
}
});
if (!diff.length) {
return '';
}
return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', {
senderName,
powerLevelDiffText: diff.join(", "),
});
}
function textForPinnedEvent(event) {
const senderName = event.getSender();
return _t("%(senderName)s changed the pinned messages for the room.", {senderName});
}
function textForWidgetEvent(event) {
const senderName = event.getSender();
const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
const {name, type, url} = event.getContent() || {};
let widgetName = name || prevName || type || prevType || '';
// Apply sentence case to widget name
if (widgetName && widgetName.length > 0) {
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' ';
}
// If the widget was removed, its content should be {}, but this is sufficiently
// equivalent to that condition.
if (url) {
if (prevUrl) {
return _t('%(widgetName)s widget modified by %(senderName)s', {
widgetName, senderName,
});
} else {
return _t('%(widgetName)s widget added by %(senderName)s', {
widgetName, senderName,
});
}
} else {
return _t('%(widgetName)s widget removed by %(senderName)s', {
widgetName, senderName,
});
}
}
const handlers = {
'm.room.message': textForMessageEvent,
'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent,
'm.room.member': textForMemberEvent,
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent,
};
const stateHandlers = {
'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent,
'm.room.member': textForMemberEvent,
'm.room.third_party_invite': textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.encryption': textForEncryptionEvent,
'm.room.power_levels': textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent,
'im.vector.modular.widgets': textForWidgetEvent,
};
module.exports = {
textForEvent: function(ev) {
var hdlr = handlers[ev.getType()];
if (!hdlr) return "";
return hdlr(ev);
}
}
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
if (handler) return handler(ev);
return '';
},
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2015 OpenMarket Ltd
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.
@ -14,149 +15,125 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var dis = require("./dispatcher");
var sdk = require("./index");
const DEBUG = 0;
// FIXME: these vars should be bundled up and attached to
// module.exports otherwise this will break when included by both
// react-sdk and apps layered on top.
// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue]
function colorToRgb(color) {
if (!color) {
return [0, 0, 0];
}
var DEBUG = 0;
// The colour keys to be replaced as referred to in CSS
var keyRgb = [
"rgb(118, 207, 166)", // Vector Green
"rgb(234, 245, 240)", // Vector Light Green
"rgb(211, 239, 225)", // BottomLeftMenu overlay (20% Vector Green)
];
// Some algebra workings for calculating the tint % of Vector Green & Light Green
// x * 118 + (1 - x) * 255 = 234
// x * 118 + 255 - 255 * x = 234
// x * 118 - x * 255 = 234 - 255
// (255 - 118) x = 255 - 234
// x = (255 - 234) / (255 - 118) = 0.16
// The colour keys to be replaced as referred to in SVGs
var keyHex = [
"#76CFA6", // Vector Green
"#EAF5F0", // Vector Light Green
"#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on Vector Light Green)
];
// cache of our replacement colours
// defaults to our keys.
var colors = [
keyHex[0],
keyHex[1],
keyHex[2],
];
var cssFixups = [
// {
// style: a style object that should be fixed up taken from a stylesheet
// attr: name of the attribute to be clobbered, e.g. 'color'
// index: ordinal of primary, secondary or tertiary
// }
];
// CSS attributes to be fixed up
var cssAttrs = [
"color",
"backgroundColor",
"borderColor",
"borderTopColor",
"borderBottomColor",
"borderLeftColor",
];
var svgAttrs = [
"fill",
"stroke",
];
var cached = false;
function calcCssFixups() {
if (DEBUG) console.log("calcSvgFixups start");
for (var i = 0; i < document.styleSheets.length; i++) {
var ss = document.styleSheets[i];
if (!ss) continue; // well done safari >:(
// Chromium apparently sometimes returns null here; unsure why.
// see $14534907369972FRXBx:matrix.org in HQ
// ...ah, it's because there's a third party extension like
// privacybadger inserting its own stylesheet in there with a
// resource:// URI or something which results in a XSS error.
// See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
// ...except some browsers apparently return stylesheets without
// hrefs, which we have no choice but ignore right now
// XXX seriously? we are hardcoding the name of vector's CSS file in
// here?
//
// Why do we need to limit it to vector's CSS file anyway - if there
// are other CSS files affecting the doc don't we want to apply the
// same transformations to them?
//
// Iterating through the CSS looking for matches to hack on feels
// pretty horrible anyway. And what if the application skin doesn't use
// Vector Green as its primary color?
if (ss.href && !ss.href.match(/\/bundle.*\.css$/)) continue;
if (!ss.cssRules) continue;
for (var j = 0; j < ss.cssRules.length; j++) {
var rule = ss.cssRules[j];
if (!rule.style) continue;
for (var k = 0; k < cssAttrs.length; k++) {
var attr = cssAttrs[k];
for (var l = 0; l < keyRgb.length; l++) {
if (rule.style[attr] === keyRgb[l]) {
cssFixups.push({
style: rule.style,
attr: attr,
index: l,
});
}
}
}
if (color[0] === '#') {
color = color.slice(1);
if (color.length === 3) {
color = color[0] + color[0] +
color[1] + color[1] +
color[2] + color[2];
}
const val = parseInt(color, 16);
const r = (val >> 16) & 255;
const g = (val >> 8) & 255;
const b = val & 255;
return [r, g, b];
} else {
const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/);
if (match) {
return [
parseInt(match[1]),
parseInt(match[2]),
parseInt(match[3]),
];
}
}
if (DEBUG) console.log("calcSvgFixups end");
return [0, 0, 0];
}
function applyCssFixups() {
if (DEBUG) console.log("applyCssFixups start");
for (var i = 0; i < cssFixups.length; i++) {
var cssFixup = cssFixups[i];
cssFixup.style[cssFixup.attr] = colors[cssFixup.index];
// utility to turn [red,green,blue] into #rrggbb
function rgbToColor(rgb) {
const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
return '#' + (0x1000000 + val).toString(16).slice(1);
}
class Tinter {
constructor() {
// The default colour keys to be replaced as referred to in CSS
// (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor)
this.keyRgb = [
"rgb(118, 207, 166)", // Vector Green
"rgb(234, 245, 240)", // Vector Light Green
"rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
];
// Some algebra workings for calculating the tint % of Vector Green & Light Green
// x * 118 + (1 - x) * 255 = 234
// x * 118 + 255 - 255 * x = 234
// x * 118 - x * 255 = 234 - 255
// (255 - 118) x = 255 - 234
// x = (255 - 234) / (255 - 118) = 0.16
// The colour keys to be replaced as referred to in SVGs
this.keyHex = [
"#76CFA6", // Vector Green
"#EAF5F0", // Vector Light Green
"#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
"#FFFFFF", // white highlights of the SVGs (for switching to dark theme)
"#000000", // black lowlights of the SVGs (for switching to dark theme)
];
// track the replacement colours actually being used
// defaults to our keys.
this.colors = [
this.keyHex[0],
this.keyHex[1],
this.keyHex[2],
this.keyHex[3],
this.keyHex[4],
];
// track the most current tint request inputs (which may differ from the
// end result stored in this.colors
this.currentTint = [
undefined,
undefined,
undefined,
undefined,
undefined,
];
this.cssFixups = [
// { theme: {
// style: a style object that should be fixed up taken from a stylesheet
// attr: name of the attribute to be clobbered, e.g. 'color'
// index: ordinal of primary, secondary or tertiary
// },
// }
];
// CSS attributes to be fixed up
this.cssAttrs = [
"color",
"backgroundColor",
"borderColor",
"borderTopColor",
"borderBottomColor",
"borderLeftColor",
];
this.svgAttrs = [
"fill",
"stroke",
];
// List of functions to call when the tint changes.
this.tintables = [];
// the currently loaded theme (if any)
this.theme = undefined;
// whether to force a tint (e.g. after changing theme)
this.forceTint = false;
}
if (DEBUG) console.log("applyCssFixups end");
}
function hexToRgb(color) {
if (color[0] === '#') color = color.slice(1);
if (color.length === 3) {
color = color[0] + color[0] +
color[1] + color[1] +
color[2] + color[2];
}
var val = parseInt(color, 16);
var r = (val >> 16) & 255;
var g = (val >> 8) & 255;
var b = val & 255;
return [r, g, b];
}
function rgbToHex(rgb) {
var val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
return '#' + (0x1000000 + val).toString(16).slice(1)
}
// List of functions to call when the tint changes.
const tintables = [];
module.exports = {
/**
* Register a callback to fire when the tint changes.
* This is used to rewrite the tintable SVGs with the new tint.
@ -168,96 +145,273 @@ module.exports = {
*
* @param {Function} tintable Function to call when the tint changes.
*/
registerTintable : function(tintable) {
tintables.push(tintable);
},
registerTintable(tintable) {
this.tintables.push(tintable);
}
tint: function(primaryColor, secondaryColor, tertiaryColor) {
getKeyRgb() {
return this.keyRgb;
}
if (!cached) {
calcCssFixups();
cached = true;
tint(primaryColor, secondaryColor, tertiaryColor) {
this.currentTint[0] = primaryColor;
this.currentTint[1] = secondaryColor;
this.currentTint[2] = tertiaryColor;
this.calcCssFixups();
if (DEBUG) {
console.log("Tinter.tint(" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
if (!primaryColor) {
primaryColor = "#76CFA6"; // Vector green
secondaryColor = "#EAF5F0"; // Vector light green
primaryColor = this.keyRgb[0];
secondaryColor = this.keyRgb[1];
tertiaryColor = this.keyRgb[2];
}
if (!secondaryColor) {
var x = 0.16; // average weighting factor calculated from vector green & light green
var rgb = hexToRgb(primaryColor);
const x = 0.16; // average weighting factor calculated from vector green & light green
const rgb = colorToRgb(primaryColor);
rgb[0] = x * rgb[0] + (1 - x) * 255;
rgb[1] = x * rgb[1] + (1 - x) * 255;
rgb[2] = x * rgb[2] + (1 - x) * 255;
secondaryColor = rgbToHex(rgb);
secondaryColor = rgbToColor(rgb);
}
if (!tertiaryColor) {
var x = 0.19;
var rgb1 = hexToRgb(primaryColor);
var rgb2 = hexToRgb(secondaryColor);
const x = 0.19;
const rgb1 = colorToRgb(primaryColor);
const rgb2 = colorToRgb(secondaryColor);
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1];
rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2];
tertiaryColor = rgbToHex(rgb1);
tertiaryColor = rgbToColor(rgb1);
}
if (colors[0] === primaryColor &&
colors[1] === secondaryColor &&
colors[2] === tertiaryColor)
{
if (this.forceTint == false &&
this.colors[0] === primaryColor &&
this.colors[1] === secondaryColor &&
this.colors[2] === tertiaryColor) {
return;
}
colors = [primaryColor, secondaryColor, tertiaryColor];
this.forceTint = false;
if (DEBUG) console.log("Tinter.tint");
this.colors[0] = primaryColor;
this.colors[1] = secondaryColor;
this.colors[2] = tertiaryColor;
if (DEBUG) {
console.log("Tinter.tint final: (" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
// go through manually fixing up the stylesheets.
applyCssFixups();
this.applyCssFixups();
// tell all the SVGs to go fix themselves up
// we don't do this as a dispatch otherwise it will visually lag
tintables.forEach(function(tintable) {
this.tintables.forEach(function(tintable) {
tintable();
});
},
}
tintSvgWhite(whiteColor) {
this.currentTint[3] = whiteColor;
if (!whiteColor) {
whiteColor = this.colors[3];
}
if (this.colors[3] === whiteColor) {
return;
}
this.colors[3] = whiteColor;
this.tintables.forEach(function(tintable) {
tintable();
});
}
tintSvgBlack(blackColor) {
this.currentTint[4] = blackColor;
if (!blackColor) {
blackColor = this.colors[4];
}
if (this.colors[4] === blackColor) {
return;
}
this.colors[4] = blackColor;
this.tintables.forEach(function(tintable) {
tintable();
});
}
setTheme(theme) {
console.trace("setTheme " + theme);
this.theme = theme;
// update keyRgb from the current theme CSS itself, if it defines it
if (document.getElementById('mx_theme_accentColor')) {
this.keyRgb[0] = window.getComputedStyle(
document.getElementById('mx_theme_accentColor')).color;
}
if (document.getElementById('mx_theme_secondaryAccentColor')) {
this.keyRgb[1] = window.getComputedStyle(
document.getElementById('mx_theme_secondaryAccentColor')).color;
}
if (document.getElementById('mx_theme_tertiaryAccentColor')) {
this.keyRgb[2] = window.getComputedStyle(
document.getElementById('mx_theme_tertiaryAccentColor')).color;
}
this.calcCssFixups();
this.forceTint = true;
this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]);
if (theme === 'dark') {
// abuse the tinter to change all the SVG's #fff to #2d2d2d
// XXX: obviously this shouldn't be hardcoded here.
this.tintSvgWhite('#2d2d2d');
this.tintSvgBlack('#dddddd');
} else {
this.tintSvgWhite('#ffffff');
this.tintSvgBlack('#000000');
}
}
calcCssFixups() {
// cache our fixups
if (this.cssFixups[this.theme]) return;
if (DEBUG) {
console.debug("calcCssFixups start for " + this.theme + " (checking " +
document.styleSheets.length +
" stylesheets)");
}
this.cssFixups[this.theme] = [];
for (let i = 0; i < document.styleSheets.length; i++) {
const ss = document.styleSheets[i];
if (!ss) continue; // well done safari >:(
// Chromium apparently sometimes returns null here; unsure why.
// see $14534907369972FRXBx:matrix.org in HQ
// ...ah, it's because there's a third party extension like
// privacybadger inserting its own stylesheet in there with a
// resource:// URI or something which results in a XSS error.
// See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
// ...except some browsers apparently return stylesheets without
// hrefs, which we have no choice but ignore right now
// XXX seriously? we are hardcoding the name of vector's CSS file in
// here?
//
// Why do we need to limit it to vector's CSS file anyway - if there
// are other CSS files affecting the doc don't we want to apply the
// same transformations to them?
//
// Iterating through the CSS looking for matches to hack on feels
// pretty horrible anyway. And what if the application skin doesn't use
// Vector Green as its primary color?
// --richvdh
// Yes, tinting assumes that you are using the Riot skin for now.
// The right solution will be to move the CSS over to react-sdk.
// And yes, the default assets for the base skin might as well use
// Vector Green as any other colour.
// --matthew
if (ss.href && !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
if (ss.disabled) continue;
if (!ss.cssRules) continue;
if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href);
for (let j = 0; j < ss.cssRules.length; j++) {
const rule = ss.cssRules[j];
if (!rule.style) continue;
if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue;
for (let k = 0; k < this.cssAttrs.length; k++) {
const attr = this.cssAttrs[k];
for (let l = 0; l < this.keyRgb.length; l++) {
if (rule.style[attr] === this.keyRgb[l]) {
this.cssFixups[this.theme].push({
style: rule.style,
attr: attr,
index: l,
});
}
}
}
}
}
if (DEBUG) {
console.log("calcCssFixups end (" +
this.cssFixups[this.theme].length +
" fixups)");
}
}
applyCssFixups() {
if (DEBUG) {
console.log("applyCssFixups start (" +
this.cssFixups[this.theme].length +
" fixups)");
}
for (let i = 0; i < this.cssFixups[this.theme].length; i++) {
const cssFixup = this.cssFixups[this.theme][i];
try {
cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index];
} catch (e) {
// Firefox Quantum explodes if you manually edit the CSS in the
// inspector and then try to do a tint, as apparently all the
// fixups are then stale.
console.error("Failed to apply cssFixup in Tinter! ", e.name);
}
}
if (DEBUG) console.log("applyCssFixups end");
}
// XXX: we could just move this all into TintableSvg, but as it's so similar
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
// keeping it here for now.
calcSvgFixups: function(svgs) {
calcSvgFixups(svgs) {
// go through manually fixing up SVG colours.
// we could do this by stylesheets, but keeping the stylesheets
// updated would be a PITA, so just brute-force search for the
// key colour; cache the element and apply.
if (DEBUG) console.log("calcSvgFixups start for " + svgs);
var fixups = [];
for (var i = 0; i < svgs.length; i++) {
var svgDoc;
const fixups = [];
for (let i = 0; i < svgs.length; i++) {
let svgDoc;
try {
svgDoc = svgs[i].contentDocument;
}
catch(e) {
var msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString();
} catch (e) {
let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString();
if (e.message) {
msg += e.message;
}
if (e.stack) {
msg += ' | stack: ' + e.stack;
}
console.error(e);
console.error(msg);
}
if (!svgDoc) continue;
var tags = svgDoc.getElementsByTagName("*");
for (var j = 0; j < tags.length; j++) {
var tag = tags[j];
for (var k = 0; k < svgAttrs.length; k++) {
var attr = svgAttrs[k];
for (var l = 0; l < keyHex.length; l++) {
if (tag.getAttribute(attr) && tag.getAttribute(attr).toUpperCase() === keyHex[l]) {
const tags = svgDoc.getElementsByTagName("*");
for (let j = 0; j < tags.length; j++) {
const tag = tags[j];
for (let k = 0; k < this.svgAttrs.length; k++) {
const attr = this.svgAttrs[k];
for (let l = 0; l < this.keyHex.length; l++) {
if (tag.getAttribute(attr) &&
tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) {
fixups.push({
node: tag,
attr: attr,
@ -271,14 +425,19 @@ module.exports = {
if (DEBUG) console.log("calcSvgFixups end");
return fixups;
},
}
applySvgFixups: function(fixups) {
applySvgFixups(fixups) {
if (DEBUG) console.log("applySvgFixups start for " + fixups);
for (var i = 0; i < fixups.length; i++) {
var svgFixup = fixups[i];
svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]);
for (let i = 0; i < fixups.length; i++) {
const svgFixup = fixups[i];
svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]);
}
if (DEBUG) console.log("applySvgFixups end");
}
};
}
if (global.singletonTinter === undefined) {
global.singletonTinter = new Tinter();
}
export default global.singletonTinter;

View file

@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var MatrixClientPeg = require('./MatrixClientPeg');
var sdk = require('./index');
const MatrixClientPeg = require('./MatrixClientPeg');
import shouldHideEvent from './shouldHideEvent';
const sdk = require('./index');
module.exports = {
/**
@ -25,17 +26,37 @@ module.exports = {
eventTriggersUnreadCount: function(ev) {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
return false;
} else if (ev.getType() == "m.room.member") {
} else if (ev.getType() == 'm.room.member') {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false;
} else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
return false;
}
var EventTile = sdk.getComponent('rooms.EventTile');
const EventTile = sdk.getComponent('rooms.EventTile');
return EventTile.haveTileForEvent(ev);
},
doesRoomHaveUnreadMessages: function(room) {
var readUpToId = room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
const myUserId = MatrixClientPeg.get().credentials.userId;
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
// despite the name of the method :((
const readUpToId = room.getEventReadUpTo(myUserId);
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/riot-web/issues/3263
// https://github.com/vector-im/riot-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/riot-web/issues/3363
if (room.timeline.length &&
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
return false;
}
// this just looks at whatever history we have, which if we've only just started
// up probably won't be very much, so if the last couple of events are ones that
// don't count, we don't know if there are any events that do count between where
@ -43,15 +64,15 @@ module.exports = {
// but currently we just guess.
// Loop through messages, starting with the most recent...
for (var i = room.timeline.length - 1; i >= 0; --i) {
var ev = room.timeline[i];
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
if (ev.getId() == readUpToId) {
// If we've read up to this event, there's nothing more recents
// that counts and we can stop looking because the user's read
// this and everything before.
return false;
} else if (this.eventTriggersUnreadCount(ev)) {
} 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.
return true;
@ -62,5 +83,5 @@ module.exports = {
// is unread on the theory that false positives are better than
// false negatives here.
return true;
}
},
};

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var dis = require("./dispatcher");
import dis from './dispatcher';
var MIN_DISPATCH_INTERVAL_MS = 500;
var CURRENTLY_ACTIVE_THRESHOLD_MS = 2000;
const MIN_DISPATCH_INTERVAL_MS = 500;
const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000;
/**
* This class watches for user activity (moving the mouse or pressing a key)
@ -32,7 +32,7 @@ class UserActivity {
start() {
document.onmousedown = this._onUserActivity.bind(this);
document.onmousemove = this._onUserActivity.bind(this);
document.onkeypress = this._onUserActivity.bind(this);
document.onkeydown = this._onUserActivity.bind(this);
// can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
@ -50,7 +50,7 @@ class UserActivity {
stop() {
document.onmousedown = undefined;
document.onmousemove = undefined;
document.onkeypress = undefined;
document.onkeydown = undefined;
window.removeEventListener('wheel', this._onUserActivity.bind(this),
{ passive: true, capture: true });
}
@ -58,16 +58,15 @@ class UserActivity {
/**
* Return true if there has been user activity very recently
* (ie. within a few seconds)
* @returns {boolean} true if user is currently/very recently active
*/
userCurrentlyActive() {
return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS;
}
_onUserActivity(event) {
if (event.screenX && event.type == "mousemove") {
if (event.screenX === this.lastScreenX &&
event.screenY === this.lastScreenY)
{
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
return;
}
@ -79,28 +78,24 @@ class UserActivity {
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) {
this.lastDispatchAtTs = this.lastActivityAtTs;
dis.dispatch({
action: 'user_activity'
action: 'user_activity',
});
if (!this.activityEndTimer) {
this.activityEndTimer = setTimeout(
this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS
);
this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS);
}
}
}
_onActivityEndTimer() {
var now = new Date().getTime();
var targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS;
const now = new Date().getTime();
const targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS;
if (now >= targetTime) {
dis.dispatch({
action: 'user_activity_end'
action: 'user_activity_end',
});
this.activityEndTimer = undefined;
} else {
this.activityEndTimer = setTimeout(
this._onActivityEndTimer.bind(this), targetTime - now
);
this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), targetTime - now);
}
}
}

58
src/UserAddress.js Normal file
View file

@ -0,0 +1,58 @@
/*
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.
*/
const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\S+:\S+$/;
import PropTypes from 'prop-types';
export const addressTypes = [
'mx-user-id', 'mx-room-id', 'email',
];
// PropType definition for an object describing
// an address that can be invited to a room (which
// could be a third party identifier or a matrix ID)
// along with some additional information about the
// address / target.
export const UserAddressType = PropTypes.shape({
addressType: PropTypes.oneOf(addressTypes).isRequired,
address: PropTypes.string.isRequired,
displayName: PropTypes.string,
avatarMxc: PropTypes.string,
// true if the address is known to be a valid address (eg. is a real
// user we've seen) or false otherwise (eg. is just an address the
// user has entered)
isKnown: PropTypes.bool,
});
export function getAddressType(inputText) {
const isEmailAddress = emailRegex.test(inputText);
const isUserId = mxUserIdRegex.test(inputText);
const isRoomId = mxRoomIdRegex.test(inputText);
// sanity check the input for user IDs
if (isEmailAddress) {
return 'email';
} else if (isUserId) {
return 'mx-user-id';
} else if (isRoomId) {
return 'mx-room-id';
} else {
return null;
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -14,26 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var q = require("q");
var MatrixClientPeg = require("./MatrixClientPeg");
var Notifier = require("./Notifier");
import Promise from 'bluebird';
import MatrixClientPeg from './MatrixClientPeg';
/*
* TODO: Make things use this. This is all WIP - see UserSettings.js for usage.
*/
module.exports = {
LABS_FEATURES: [
{
name: 'Rich Text Editor',
id: 'rich_text_editor',
default: false,
},
],
export default {
loadProfileInfo: function() {
var cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.get();
return cli.getProfileInfo(cli.credentials.userId);
},
@ -43,8 +33,8 @@ module.exports = {
loadThreePids: function() {
if (MatrixClientPeg.get().isGuest()) {
return q({
threepids: []
return Promise.resolve({
threepids: [],
}); // guests can't poke 3pid endpoint
}
return MatrixClientPeg.get().getThreePids();
@ -54,38 +44,19 @@ module.exports = {
// TODO
},
getEnableNotifications: function() {
return Notifier.isEnabled();
},
changePassword: function(oldPassword, newPassword) {
const cli = MatrixClientPeg.get();
setEnableNotifications: function(enable) {
if (!Notifier.supportsDesktopNotifications()) {
return;
}
Notifier.setEnabled(enable);
},
getEnableAudioNotifications: function() {
return Notifier.isAudioEnabled();
},
setEnableAudioNotifications: function(enable) {
Notifier.setAudioEnabled(enable);
},
changePassword: function(old_password, new_password) {
var cli = MatrixClientPeg.get();
var authDict = {
const authDict = {
type: 'm.login.password',
user: cli.credentials.userId,
password: old_password
password: oldPassword,
};
return cli.setPassword(authDict, new_password);
return cli.setPassword(authDict, newPassword);
},
/**
/*
* Returns the email pusher (pusher of type 'email') for a given
* email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most
@ -95,8 +66,8 @@ module.exports = {
if (pushers === undefined) {
return undefined;
}
for (var i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email' && pushers[i].pushkey == address) {
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i];
}
}
@ -110,7 +81,7 @@ module.exports = {
addEmailPusher: function(address, data) {
return MatrixClientPeg.get().setPusher({
kind: 'email',
app_id: "m.email",
app_id: 'm.email',
pushkey: address,
app_display_name: 'Email Notifications',
device_display_name: address,
@ -119,52 +90,4 @@ module.exports = {
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
});
},
getUrlPreviewsDisabled: function() {
var event = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls");
return (event && event.getContent().disable);
},
setUrlPreviewsDisabled: function(disabled) {
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", {
disable: disabled
});
},
getSyncedSettings: function() {
var event = MatrixClientPeg.get().getAccountData("im.vector.web.settings");
return event ? event.getContent() : {};
},
getSyncedSetting: function(type, defaultValue = null) {
var settings = this.getSyncedSettings();
return settings.hasOwnProperty(type) ? settings[type] : null;
},
setSyncedSetting: function(type, value) {
var settings = this.getSyncedSettings();
settings[type] = value;
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings);
},
isFeatureEnabled: function(feature: string): boolean {
// Disable labs for guests.
if (MatrixClientPeg.get().isGuest()) return false;
if (localStorage.getItem(`mx_labs_feature_${feature}`) === null) {
for (var i = 0; i < this.LABS_FEATURES.length; i++) {
var f = this.LABS_FEATURES[i];
if (f.id === feature) {
return f.default;
}
}
}
return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true';
},
setFeatureEnabled: function(feature: string, enabled: boolean) {
localStorage.setItem(`mx_labs_feature_${feature}`, enabled);
}
};

View file

@ -1,6 +1,7 @@
var React = require('react');
var ReactDom = require('react-dom');
var Velocity = require('velocity-vector');
const React = require('react');
const ReactDom = require('react-dom');
import PropTypes from 'prop-types';
const Velocity = require('velocity-vector');
/**
* The Velociraptor contains components and animates transitions with velocity.
@ -14,16 +15,16 @@ module.exports = React.createClass({
propTypes: {
// either a list of child nodes, or a single child.
children: React.PropTypes.any,
children: PropTypes.any,
// optional transition information for changing existing children
transition: React.PropTypes.object,
transition: PropTypes.object,
// a list of state objects to apply to each child node in turn
startStyles: React.PropTypes.array,
startStyles: PropTypes.array,
// a list of transition options from the corresponding startStyle
enterTransitionOpts: React.PropTypes.array,
enterTransitionOpts: PropTypes.array,
},
getDefaultProps: function() {
@ -46,13 +47,13 @@ module.exports = React.createClass({
* update `this.children` according to the new list of children given
*/
_updateChildren: function(newChildren) {
var self = this;
var oldChildren = this.children || {};
const self = this;
const oldChildren = this.children || {};
this.children = {};
React.Children.toArray(newChildren).forEach(function(c) {
if (oldChildren[c.key]) {
var old = oldChildren[c.key];
var oldNode = ReactDom.findDOMNode(self.nodes[old.key]);
const old = oldChildren[c.key];
const oldNode = ReactDom.findDOMNode(self.nodes[old.key]);
if (oldNode && oldNode.style.left != c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, self.props.transition).then(function() {
@ -62,27 +63,27 @@ module.exports = React.createClass({
oldNode.style.visibility = c.props.style.visibility;
}
});
if (oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') {
oldNode.style.visibility = c.props.style.visibility;
}
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
if (oldNode && oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') {
oldNode.style.visibility = c.props.style.visibility;
}
self.children[c.key] = old;
} else {
// new element. If we have a startStyle, use that as the style and go through
// the enter animations
var newProps = {};
var restingStyle = c.props.style;
const newProps = {};
const restingStyle = c.props.style;
var startStyles = self.props.startStyles;
const startStyles = self.props.startStyles;
if (startStyles.length > 0) {
var startStyle = startStyles[0]
const startStyle = startStyles[0];
newProps.style = startStyle;
// console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
}
newProps.ref = (n => self._collectNode(
c.key, n, restingStyle
newProps.ref = ((n) => self._collectNode(
c.key, n, restingStyle,
));
self.children[c.key] = React.cloneElement(c, newProps);
@ -103,9 +104,9 @@ module.exports = React.createClass({
this.nodes[k] === undefined &&
this.props.startStyles.length > 0
) {
var startStyles = this.props.startStyles;
var transitionOpts = this.props.enterTransitionOpts;
var domNode = ReactDom.findDOMNode(node);
const startStyles = this.props.startStyles;
const transitionOpts = this.props.enterTransitionOpts;
const domNode = ReactDom.findDOMNode(node);
// start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc.
for (var i = 1; i < startStyles.length; ++i) {
@ -145,7 +146,7 @@ module.exports = React.createClass({
// and the FAQ entry, "Preventing memory leaks when
// creating/destroying large numbers of elements"
// (https://github.com/julianshapiro/velocity/issues/47)
var domNode = ReactDom.findDOMNode(this.nodes[k]);
const domNode = ReactDom.findDOMNode(this.nodes[k]);
Velocity.Utilities.removeData(domNode);
}
this.nodes[k] = node;
@ -154,7 +155,7 @@ module.exports = React.createClass({
render: function() {
return (
<span>
{Object.values(this.children)}
{ Object.values(this.children) }
</span>
);
},

View file

@ -1,15 +1,17 @@
var Velocity = require('velocity-vector');
const Velocity = require('velocity-vector');
// courtesy of https://github.com/julianshapiro/velocity/issues/283
// We only use easeOutBounce (easeInBounce is just sort of nonsensical)
function bounce( p ) {
var pow2,
let pow2,
bounce = 4;
while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {}
while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {
// just sets pow2
}
return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 );
}
Velocity.Easings.easeOutBounce = function(p) {
return 1 - bounce(1 - p);
}
};

View file

@ -1,9 +1,32 @@
var MatrixClientPeg = require("./MatrixClientPeg");
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const MatrixClientPeg = require("./MatrixClientPeg");
import { _t } from './languageHandler';
module.exports = {
usersTypingApartFromMeAndIgnored: function(room) {
return this.usersTyping(
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
);
},
usersTypingApartFromMe: function(room) {
return this.usersTyping(
room, [MatrixClientPeg.get().credentials.userId]
room, [MatrixClientPeg.get().credentials.userId],
);
},
@ -12,15 +35,15 @@ module.exports = {
* to exclude, return a list of user objects who are typing.
*/
usersTyping: function(room, exclude) {
var whoIsTyping = [];
const whoIsTyping = [];
if (exclude === undefined) {
exclude = [];
}
var memberKeys = Object.keys(room.currentState.members);
for (var i = 0; i < memberKeys.length; ++i) {
var userId = memberKeys[i];
const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i];
if (room.currentState.members[userId].typing) {
if (exclude.indexOf(userId) == -1) {
@ -32,18 +55,24 @@ module.exports = {
return whoIsTyping;
},
whoIsTypingString: function(room) {
var whoIsTyping = this.usersTypingApartFromMe(room);
if (whoIsTyping.length == 0) {
return null;
} else if (whoIsTyping.length == 1) {
return whoIsTyping[0].name + ' is typing';
} else {
var names = whoIsTyping.map(function(m) {
return m.name;
});
var lastPerson = names.shift();
return names.join(', ') + ' and ' + lastPerson + ' are typing';
whoIsTypingString: function(whoIsTyping, limit) {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
}
}
if (whoIsTyping.length == 0) {
return '';
} else if (whoIsTyping.length == 1) {
return _t('%(displayName)s is typing', {displayName: whoIsTyping[0].name});
}
const names = whoIsTyping.map(function(m) {
return m.name;
});
if (othersCount>=1) {
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});
}
},
};

326
src/WidgetMessaging.js Normal file
View file

@ -0,0 +1,326 @@
/*
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.
*/
/*
Listens for incoming postMessage requests from embedded widgets. The following API is exposed:
{
api: "widget",
action: "content_loaded",
widgetId: $WIDGET_ID,
data: {}
// additional request fields
}
The complete request object is returned to the caller with an additional "response" key like so:
{
api: "widget",
action: "content_loaded",
widgetId: $WIDGET_ID,
data: {},
// additional request fields
response: { ... }
}
The "api" field is required to use this API, and must be set to "widget" in all requests.
The "action" determines the format of the request and response. All actions can return an error response.
Additional data can be sent as additional, abritrary fields. However, typically the data object should be used.
A success response is an object with zero or more keys.
An error response is a "response" object which consists of a sole "error" key to indicate an error.
They look like:
{
error: {
message: "Unable to invite user into room.",
_error: <Original Error Object>
}
}
The "message" key should be a human-friendly string.
ACTIONS
=======
** All actions must include an "api" field with valie "widget".**
All actions can return an error response instead of the response outlined below.
content_loaded
--------------
Indicates that widget contet has fully loaded
Request:
- widgetId is the unique ID of the widget instance in riot / matrix state.
- No additional fields.
Response:
{
success: true
}
Example:
{
api: "widget",
action: "content_loaded",
widgetId: $WIDGET_ID
}
api_version
-----------
Get the current version of the widget postMessage API
Request:
- No additional fields.
Response:
{
api_version: "0.0.1"
}
Example:
{
api: "widget",
action: "api_version",
}
supported_api_versions
----------------------
Get versions of the widget postMessage API that are currently supported
Request:
- No additional fields.
Response:
{
api: "widget"
supported_versions: ["0.0.1"]
}
Example:
{
api: "widget",
action: "supported_api_versions",
}
*/
import URL from 'url';
const WIDGET_API_VERSION = '0.0.1'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
'0.0.1',
];
import dis from './dispatcher';
if (!global.mxWidgetMessagingListenerCount) {
global.mxWidgetMessagingListenerCount = 0;
}
if (!global.mxWidgetMessagingMessageEndpoints) {
global.mxWidgetMessagingMessageEndpoints = [];
}
/**
* Register widget message event listeners
*/
function startListening() {
if (global.mxWidgetMessagingListenerCount === 0) {
window.addEventListener("message", onMessage, false);
}
global.mxWidgetMessagingListenerCount += 1;
}
/**
* De-register widget message event listeners
*/
function stopListening() {
global.mxWidgetMessagingListenerCount -= 1;
if (global.mxWidgetMessagingListenerCount === 0) {
window.removeEventListener("message", onMessage);
}
if (global.mxWidgetMessagingListenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"WidgetMessaging: mismatched startListening / stopListening detected." +
" Negative count",
);
console.error(e);
}
}
/**
* Register a widget endpoint for trusted postMessage communication
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
*/
function addEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn("Invalid origin:", endpointUrl);
return;
}
const origin = u.protocol + '//' + u.host;
const endpoint = new WidgetMessageEndpoint(widgetId, origin);
if (global.mxWidgetMessagingMessageEndpoints) {
if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) {
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
})) {
// Message endpoint already registered
console.warn("Endpoint already registered");
return;
}
global.mxWidgetMessagingMessageEndpoints.push(endpoint);
}
}
/**
* De-register a widget endpoint from trusted communication sources
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
* @return {boolean} True if endpoint was successfully removed
*/
function removeEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn("Invalid origin");
return;
}
const origin = u.protocol + '//' + u.host;
if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) {
const length = global.mxWidgetMessagingMessageEndpoints.length;
global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints.filter(function(endpoint) {
return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
});
return (length > global.mxWidgetMessagingMessageEndpoints.length);
}
return false;
}
/**
* Handle widget postMessage events
* @param {Event} event Event to handle
* @return {undefined}
*/
function onMessage(event) {
if (!event.origin) { // Handle chrome
event.origin = event.originalEvent.origin;
}
// Event origin is empty string if undefined
if (
event.origin.length === 0 ||
!trustedEndpoint(event.origin) ||
event.data.api !== "widget" ||
!event.data.widgetId
) {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
}
const action = event.data.action;
const widgetId = event.data.widgetId;
if (action === 'content_loaded') {
dis.dispatch({
action: 'widget_content_loaded',
widgetId: widgetId,
});
sendResponse(event, {success: true});
} else if (action === 'supported_api_versions') {
sendResponse(event, {
api: "widget",
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
});
} else if (action === 'api_version') {
sendResponse(event, {
api: "widget",
version: WIDGET_API_VERSION,
});
} else {
console.warn("Widget postMessage event unhandled");
sendError(event, {message: "The postMessage was unhandled"});
}
}
/**
* Check if message origin is registered as trusted
* @param {string} origin PostMessage origin to check
* @return {boolean} True if trusted
*/
function trustedEndpoint(origin) {
if (!origin) {
return false;
}
return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => {
return endpoint.endpointUrl === origin;
});
}
/**
* Send a postmessage response to a postMessage request
* @param {Event} event The original postMessage request event
* @param {Object} res Response data
*/
function sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
data.response = res;
event.source.postMessage(data, event.origin);
}
/**
* Send an error response to a postMessage request
* @param {Event} event The original postMessage request event
* @param {string} msg Error message
* @param {Error} nestedError Nested error event (optional)
*/
function sendError(event, msg, nestedError) {
console.error("Action:" + event.data.action + " failed with message: " + msg);
const data = JSON.parse(JSON.stringify(event.data));
data.response = {
error: {
message: msg,
},
};
if (nestedError) {
data.response.error._error = nestedError;
}
event.source.postMessage(data, event.origin);
}
/**
* Represents mapping of widget instance to URLs for trusted postMessage communication.
*/
class WidgetMessageEndpoint {
/**
* Mapping of widget instance to URL for trusted postMessage communication.
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin.
*/
constructor(widgetId, endpointUrl) {
if (!widgetId) {
throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
}
if (!endpointUrl) {
throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
}
this.widgetId = widgetId;
this.endpointUrl = endpointUrl;
}
}
export default {
startListening: startListening,
stopListening: stopListening,
addEndpoint: addEndpoint,
removeEndpoint: removeEndpoint,
};

58
src/WidgetUtils.js Normal file
View file

@ -0,0 +1,58 @@
/*
Copyright 2017 Vector Creations 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 MatrixClientPeg from './MatrixClientPeg';
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
* @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
static canUserModifyWidgets(roomId) {
if (!roomId) {
console.warn('No room ID specified');
return false;
}
const client = MatrixClientPeg.get();
if (!client) {
console.warn('User must be be logged in');
return false;
}
const room = client.getRoom(roomId);
if (!room) {
console.warn(`Room ID ${roomId} is not recognised`);
return false;
}
const me = client.credentials.userId;
if (!me) {
console.warn('Failed to get user ID');
return false;
}
const member = room.getMember(me);
if (!member || member.membership !== "join") {
console.warn(`User ${me} is not in room ${roomId}`);
return false;
}
return room.currentState.maySendStateEvent('im.vector.modular.widgets', me);
}
}

View file

@ -0,0 +1,34 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { asyncAction } from './actionCreators';
const GroupActions = {};
/**
* Creates an action thunk that will do an asynchronous request to fetch
* the groups to which a user is joined.
*
* @param {MatrixClient} matrixClient the matrix client to query.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
GroupActions.fetchJoinedGroups = function(matrixClient) {
return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups());
};
export default GroupActions;

View file

@ -0,0 +1,108 @@
/*
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 dis from '../dispatcher';
// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events
// become dispatches in the same place.
/**
* Create a MatrixActions.sync action that represents a MatrixClient `sync` event,
* each parameter mapping to a key-value in the action.
*
* @param {MatrixClient} matrixClient the matrix client
* @param {string} state the current sync state.
* @param {string} prevState the previous sync state.
* @returns {Object} an action of type MatrixActions.sync.
*/
function createSyncAction(matrixClient, state, prevState) {
return {
action: 'MatrixActions.sync',
state,
prevState,
matrixClient,
};
}
/**
* @typedef AccountDataAction
* @type {Object}
* @property {string} action 'MatrixActions.accountData'.
* @property {MatrixEvent} event the MatrixEvent that triggered the dispatch.
* @property {string} event_type the type of the MatrixEvent, e.g. "m.direct".
* @property {Object} event_content the content of the MatrixEvent.
*/
/**
* Create a MatrixActions.accountData action that represents a MatrixClient `accountData`
* matrix event.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} accountDataEvent the account data event.
* @returns {AccountDataAction} an action of type MatrixActions.accountData.
*/
function createAccountDataAction(matrixClient, accountDataEvent) {
return {
action: 'MatrixActions.accountData',
event: accountDataEvent,
event_type: accountDataEvent.getType(),
event_content: accountDataEvent.getContent(),
};
}
/**
* This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient.
*/
export default {
// A list of callbacks to call to unregister all listeners added
_matrixClientListenersStop: [],
/**
* Start listening to certain events from the MatrixClient and dispatch actions when
* they are emitted.
* @param {MatrixClient} matrixClient the MatrixClient to listen to events from
*/
start(matrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
},
/**
* Start listening to events of type eventName on matrixClient and when they are emitted,
* dispatch an action created by the actionCreator function.
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
* @param {string} eventName the event to listen to on MatrixClient.
* @param {function} actionCreator a function that should return an action to dispatch
* when given the MatrixClient as an argument as well as
* arguments emitted in the MatrixClient event.
*/
_addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => {
dis.dispatch(actionCreator(matrixClient, ...args));
};
matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => {
matrixClient.removeListener(eventName, listener);
});
},
/**
* Stop listening to events.
*/
stop() {
this._matrixClientListenersStop.forEach((stopListener) => stopListener());
},
};

View file

@ -0,0 +1,59 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Analytics from '../Analytics';
import { asyncAction } from './actionCreators';
import TagOrderStore from '../stores/TagOrderStore';
const TagOrderActions = {};
/**
* Creates an action thunk that will do an asynchronous request to
* move a tag in TagOrderStore to destinationIx.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {string} tag the tag to move.
* @param {number} destinationIx the new position of the tag.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
// Only commit tags if the state is ready, i.e. not null
let tags = TagOrderStore.getOrderedTags();
if (!tags) {
return;
}
tags = tags.filter((t) => t !== tag);
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {tags};
});
};
export default TagOrderActions;

View file

@ -0,0 +1,48 @@
/*
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.
*/
/**
* Create an action thunk that will dispatch actions indicating the current
* status of the Promise returned by fn.
*
* @param {string} id the id to give the dispatched actions. This is given a
* suffix determining whether it is pending, successful or
* a failure.
* @param {function} fn a function that returns a Promise.
* @param {function?} pendingFn a function that returns an object to assign
* to the `request` key of the ${id}.pending
* payload.
* @returns {function} an action thunk - a function that uses its single
* argument as a dispatch function to dispatch the
* following actions:
* `${id}.pending` and either
* `${id}.success` or
* `${id}.failure`.
*/
export function asyncAction(id, fn, pendingFn) {
return (dispatch) => {
dispatch({
action: id + '.pending',
request:
typeof pendingFn === 'function' ? pendingFn() : undefined,
});
fn().then((result) => {
dispatch({action: id + '.success', result});
}).catch((err) => {
dispatch({action: id + '.failure', err});
});
};
}

View file

@ -14,36 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
var sdk = require('../../../index');
var MatrixClientPeg = require("../../../MatrixClientPeg");
const React = require("react");
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const sdk = require('../../../index');
const MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({
displayName: 'EncryptedEventDialog',
propTypes: {
event: React.PropTypes.object.isRequired,
onFinished: React.PropTypes.func.isRequired,
event: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
},
getInitialState: function() {
return { device: this.refreshDevice() };
return { device: null };
},
componentWillMount: function() {
this._unmounted = false;
var client = MatrixClientPeg.get();
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
const client = MatrixClientPeg.get();
// no need to redownload keys if we already have the device
if (this.state.device) {
return;
}
client.downloadKeys([this.props.event.getSender()], true).done(()=>{
// first try to load the device from our store.
//
this.refreshDevice().then((dev) => {
if (dev) {
return dev;
}
// tell the client to try to refresh the device list for this user
return client.downloadKeys([this.props.event.getSender()], true).then(() => {
return this.refreshDevice();
});
}).then((dev) => {
if (this._unmounted) {
return;
}
this.setState({ device: this.refreshDevice() });
this.setState({ device: dev });
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
}, (err)=>{
console.log("Error downloading devices", err);
});
@ -51,19 +61,23 @@ module.exports = React.createClass({
componentWillUnmount: function() {
this._unmounted = true;
var client = MatrixClientPeg.get();
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
}
},
refreshDevice: function() {
return MatrixClientPeg.get().getEventSenderDeviceInfo(this.props.event);
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
return Promise.resolve(MatrixClientPeg.get().getEventSenderDeviceInfo(this.props.event));
},
onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.event.getSender()) {
this.setState({ device: this.refreshDevice() });
this.refreshDevice().then((dev) => {
this.setState({ device: dev });
});
}
},
@ -76,36 +90,36 @@ module.exports = React.createClass({
},
_renderDeviceInfo: function() {
var device = this.state.device;
const device = this.state.device;
if (!device) {
return (<i>unknown device</i>);
return (<i>{ _t('unknown device') }</i>);
}
var verificationStatus = (<b>NOT verified</b>);
let verificationStatus = (<b>{ _t('NOT verified') }</b>);
if (device.isBlocked()) {
verificationStatus = (<b>Blacklisted</b>);
verificationStatus = (<b>{ _t('Blacklisted') }</b>);
} else if (device.isVerified()) {
verificationStatus = "verified";
verificationStatus = _t('verified');
}
return (
<table>
<tbody>
<tr>
<td>Name</td>
<td>{ _t('Name') }</td>
<td>{ device.getDisplayName() }</td>
</tr>
<tr>
<td>Device ID</td>
<td>{ _t('Device ID') }</td>
<td><code>{ device.deviceId }</code></td>
</tr>
<tr>
<td>Verification</td>
<td>{ _t('Verification') }</td>
<td>{ verificationStatus }</td>
</tr>
<tr>
<td>Ed25519 fingerprint</td>
<td><code>{device.getFingerprint()}</code></td>
<td>{ _t('Ed25519 fingerprint') }</td>
<td><code>{ device.getFingerprint() }</code></td>
</tr>
</tbody>
</table>
@ -113,38 +127,38 @@ module.exports = React.createClass({
},
_renderEventInfo: function() {
var event = this.props.event;
const event = this.props.event;
return (
<table>
<tbody>
<tr>
<td>User ID</td>
<td>{ _t('User ID') }</td>
<td>{ event.getSender() }</td>
</tr>
<tr>
<td>Curve25519 identity key</td>
<td><code>{ event.getSenderKey() || <i>none</i> }</code></td>
<td>{ _t('Curve25519 identity key') }</td>
<td><code>{ event.getSenderKey() || <i>{ _t('none') }</i> }</code></td>
</tr>
<tr>
<td>Claimed Ed25519 fingerprint key</td>
<td><code>{ event.getKeysClaimed().ed25519 || <i>none</i> }</code></td>
<td>{ _t('Claimed Ed25519 fingerprint key') }</td>
<td><code>{ event.getKeysClaimed().ed25519 || <i>{ _t('none') }</i> }</code></td>
</tr>
<tr>
<td>Algorithm</td>
<td>{ event.getWireContent().algorithm || <i>unencrypted</i> }</td>
<td>{ _t('Algorithm') }</td>
<td>{ event.getWireContent().algorithm || <i>{ _t('unencrypted') }</i> }</td>
</tr>
{
event.getContent().msgtype === 'm.bad.encrypted' ? (
<tr>
<td>Decryption error</td>
<td>{ _t('Decryption error') }</td>
<td>{ event.getContent().body }</td>
</tr>
) : null
}
<tr>
<td>Session ID</td>
<td><code>{ event.getWireContent().session_id || <i>none</i> }</code></td>
<td>{ _t('Session ID') }</td>
<td><code>{ event.getWireContent().session_id || <i>{ _t('none') }</i> }</code></td>
</tr>
</tbody>
</table>
@ -152,36 +166,36 @@ module.exports = React.createClass({
},
render: function() {
var DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
var buttons = null;
let buttons = null;
if (this.state.device) {
buttons = (
<DeviceVerifyButtons device={ this.state.device }
userId={ this.props.event.getSender() }
<DeviceVerifyButtons device={this.state.device}
userId={this.props.event.getSender()}
/>
);
}
return (
<div className="mx_EncryptedEventDialog" onKeyDown={ this.onKeyDown }>
<div className="mx_EncryptedEventDialog" onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_title">
End-to-end encryption information
{ _t('End-to-end encryption information') }
</div>
<div className="mx_Dialog_content">
<h4>Event information</h4>
{this._renderEventInfo()}
<h4>{ _t('Event information') }</h4>
{ this._renderEventInfo() }
<h4>Sender device information</h4>
{this._renderDeviceInfo()}
<h4>{ _t('Sender device information') }</h4>
{ this._renderDeviceInfo() }
</div>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={ this.props.onFinished } autoFocus={ true }>
OK
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={true}>
{ _t('OK') }
</button>
{buttons}
{ buttons }
</div>
</div>
);
}
},
});

View file

@ -0,0 +1,183 @@
/*
Copyright 2017 Vector Creations 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 FileSaver from 'file-saver';
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as Matrix 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({
displayName: 'ExportE2eKeysDialog',
propTypes: {
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
onFinished: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
phase: PHASE_EDIT,
errStr: null,
};
},
componentWillMount: function() {
this._unmounted = false;
},
componentWillUnmount: function() {
this._unmounted = true;
},
_onPassphraseFormSubmit: function(ev) {
ev.preventDefault();
const passphrase = this.refs.passphrase1.value;
if (passphrase !== this.refs.passphrase2.value) {
this.setState({errStr: _t('Passphrases must match')});
return false;
}
if (!passphrase) {
this.setState({errStr: _t('Passphrase must not be empty')});
return false;
}
this._startExport(passphrase);
return false;
},
_startExport: function(passphrase) {
// extra Promise.resolve() to turn synchronous exceptions into
// asynchronous ones.
Promise.resolve().then(() => {
return this.props.matrixClient.exportRoomKeys();
}).then((k) => {
return MegolmExportEncryption.encryptMegolmKeyFile(
JSON.stringify(k), passphrase,
);
}).then((f) => {
const blob = new Blob([f], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'riot-keys.txt');
this.props.onFinished(true);
}).catch((e) => {
console.error("Error exporting e2e keys:", e);
if (this._unmounted) {
return;
}
const msg = e.friendlyText || _t('Unknown error');
this.setState({
errStr: msg,
phase: PHASE_EDIT,
});
});
this.setState({
errStr: null,
phase: PHASE_EXPORTING,
});
},
_onCancelClick: function(ev) {
ev.preventDefault();
this.props.onFinished(false);
return false;
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const disableForm = (this.state.phase === PHASE_EXPORTING);
return (
<BaseDialog className='mx_exportE2eKeysDialog'
onFinished={this.props.onFinished}
title={_t("Export room keys")}
>
<form onSubmit={this._onPassphraseFormSubmit}>
<div className="mx_Dialog_content">
<p>
{ _t(
'This process allows you to export the keys for messages ' +
'you have received in encrypted rooms to a local file. You ' +
'will then be able to import the file into another Matrix ' +
'client in the future, so that client will also be able to ' +
'decrypt these messages.',
) }
</p>
<p>
{ _t(
'The exported file will allow anyone who can read it to decrypt ' +
'any encrypted messages that you can see, so you should be ' +
'careful to keep it secure. To help with this, you should enter ' +
'a passphrase below, which will be used to encrypt the exported ' +
'data. It will only be possible to import the data by using the ' +
'same passphrase.',
) }
</p>
<div className='error'>
{ this.state.errStr }
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase1'>
{ _t("Enter passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase1' id='passphrase1'
autoFocus={true} size='64' type='password'
disabled={disableForm}
/>
</div>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase2'>
{ _t("Confirm passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase2' id='passphrase2'
size='64' type='password'
disabled={disableForm}
/>
</div>
</div>
</div>
</div>
<div className='mx_Dialog_buttons'>
<input className='mx_Dialog_primary' type='submit' value={_t('Export')}
disabled={disableForm}
/>
<button onClick={this._onCancelClick} disabled={disableForm}>
{ _t("Cancel") }
</button>
</div>
</form>
</BaseDialog>
);
},
});

View file

@ -0,0 +1,181 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as Matrix from 'matrix-js-sdk';
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
function readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target.result);
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
const PHASE_EDIT = 1;
const PHASE_IMPORTING = 2;
export default React.createClass({
displayName: 'ImportE2eKeysDialog',
propTypes: {
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
onFinished: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
enableSubmit: false,
phase: PHASE_EDIT,
errStr: null,
};
},
componentWillMount: function() {
this._unmounted = false;
},
componentWillUnmount: function() {
this._unmounted = true;
},
_onFormChange: function(ev) {
const files = this.refs.file.files || [];
this.setState({
enableSubmit: (this.refs.passphrase.value !== "" && files.length > 0),
});
},
_onFormSubmit: function(ev) {
ev.preventDefault();
this._startImport(this.refs.file.files[0], this.refs.passphrase.value);
return false;
},
_startImport: function(file, passphrase) {
this.setState({
errStr: null,
phase: PHASE_IMPORTING,
});
return readFileAsArrayBuffer(file).then((arrayBuffer) => {
return MegolmExportEncryption.decryptMegolmKeyFile(
arrayBuffer, passphrase,
);
}).then((keys) => {
return this.props.matrixClient.importRoomKeys(JSON.parse(keys));
}).then(() => {
// TODO: it would probably be nice to give some feedback about what we've imported here.
this.props.onFinished(true);
}).catch((e) => {
console.error("Error importing e2e keys:", e);
if (this._unmounted) {
return;
}
const msg = e.friendlyText || _t('Unknown error');
this.setState({
errStr: msg,
phase: PHASE_EDIT,
});
});
},
_onCancelClick: function(ev) {
ev.preventDefault();
this.props.onFinished(false);
return false;
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const disableForm = (this.state.phase !== PHASE_EDIT);
return (
<BaseDialog className='mx_importE2eKeysDialog'
onFinished={this.props.onFinished}
title={_t("Import room keys")}
>
<form onSubmit={this._onFormSubmit}>
<div className="mx_Dialog_content">
<p>
{ _t(
'This process allows you to import encryption keys ' +
'that you had previously exported from another Matrix ' +
'client. You will then be able to decrypt any ' +
'messages that the other client could decrypt.',
) }
</p>
<p>
{ _t(
'The export file will be protected with a passphrase. ' +
'You should enter the passphrase here, to decrypt the file.',
) }
</p>
<div className='error'>
{ this.state.errStr }
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='importFile'>
{ _t("File to import") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='file' id='importFile' type='file'
autoFocus={true}
onChange={this._onFormChange}
disabled={disableForm} />
</div>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase'>
{ _t("Enter passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase' id='passphrase'
size='64' type='password'
onChange={this._onFormChange}
disabled={disableForm} />
</div>
</div>
</div>
</div>
<div className='mx_Dialog_buttons'>
<input className='mx_Dialog_primary' type='submit' value={_t('Import')}
disabled={!this.state.enableSubmit || disableForm}
/>
<button onClick={this._onCancelClick} disabled={disableForm}>
{ _t("Cancel") }
</button>
</div>
</form>
</BaseDialog>
);
},
});

View file

@ -1,8 +1,26 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
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 React from 'react';
import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) {
constructor(commandRegex?: RegExp) {
if (commandRegex) {
if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set');
@ -11,6 +29,10 @@ export default class AutocompleteProvider {
}
}
destroy() {
// stub
}
/**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/
@ -26,7 +48,7 @@ export default class AutocompleteProvider {
}
commandRegex.lastIndex = 0;
let match;
while ((match = commandRegex.exec(query)) != null) {
let matchStart = match.index,

View file

@ -1,3 +1,20 @@
/*
Copyright 2016 Aviral Dasgupta
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.
*/
// @flow
import type {Component} from 'react';
@ -6,7 +23,8 @@ import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import Q from 'q';
import NotifProvider from './NotifProvider';
import Promise from 'bluebird';
export type SelectionRange = {
start: number,
@ -18,46 +36,68 @@ export type Completion = {
component: ?Component,
range: SelectionRange,
command: ?string,
// If provided, apply a LINK entity to the completion with the
// data = { url: href }.
href: ?string,
};
const PROVIDERS = [
UserProvider,
RoomProvider,
EmojiProvider,
NotifProvider,
CommandProvider,
DuckDuckGoProvider,
].map(completer => completer.getInstance());
];
// Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000;
export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
/* Note: That this waits for all providers to return is *intentional*
otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
export default class Autocompleter {
constructor(room) {
this.room = room;
this.providers = PROVIDERS.map((p) => {
return new p(room);
});
}
It ends up containing a list of Q promise states, which are objects with
state (== "fulfilled" || "rejected") and value. */
const completionsList = await Q.allSettled(
PROVIDERS.map(provider => {
return Q(provider.getCompletions(query, selection, force))
.timeout(PROVIDER_COMPLETION_TIMEOUT);
})
);
destroy() {
this.providers.forEach((p) => {
p.destroy();
});
}
return completionsList
.filter(completion => completion.state === "fulfilled")
.map((completionsState, i) => {
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
/* Note: This intentionally waits for all providers to return,
otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
*/
const completionsList = await Promise.all(
// 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) => {
return provider
.getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT)
.reflect();
}),
);
return completionsList.filter(
(inspection) => inspection.isFulfilled(),
).map((completionsState, i) => {
return {
completions: completionsState.value,
provider: PROVIDERS[i],
completions: completionsState.value(),
provider: this.providers[i],
/* the currently matched "command" the completer tried to complete
* we pass this through so that Autocomplete can figure out when to
* re-show itself once hidden.
*/
command: PROVIDERS[i].getCurrentCommand(query, selection, force),
command: this.providers[i].getCurrentCommand(query, selection, force),
};
});
}
}

View file

@ -1,74 +1,134 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
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 React from 'react';
import { _t, _td } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import Fuse from 'fuse.js';
import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components';
// TODO merge this with the factory mechanics of SlashCommands?
// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file
const COMMANDS = [
{
command: '/me',
args: '<message>',
description: 'Displays action',
description: _td('Displays action'),
},
{
command: '/ban',
args: '<user-id> [reason]',
description: 'Bans user with given id',
description: _td('Bans user with given id'),
},
{
command: '/unban',
args: '<user-id>',
description: _td('Unbans user with given id'),
},
{
command: '/op',
args: '<user-id> [<power-level>]',
description: _td('Define the power level of a user'),
},
{
command: '/deop',
args: '<user-id>',
description: 'Deops user with given id',
description: _td('Deops user with given id'),
},
{
command: '/invite',
args: '<user-id>',
description: 'Invites user with given id to current room',
description: _td('Invites user with given id to current room'),
},
{
command: '/join',
args: '<room-alias>',
description: 'Joins room with given alias',
description: _td('Joins room with given alias'),
},
{
command: '/part',
args: '[<room-alias>]',
description: _td('Leave room'),
},
{
command: '/topic',
args: '<topic>',
description: _td('Sets the room topic'),
},
{
command: '/kick',
args: '<user-id> [reason]',
description: 'Kicks user with given id',
description: _td('Kicks user with given id'),
},
{
command: '/nick',
args: '<display-name>',
description: 'Changes your display nickname',
description: _td('Changes your display nickname'),
},
{
command: '/ddg',
args: '<query>',
description: 'Searches DuckDuckGo for results',
}
description: _td('Searches DuckDuckGo for results'),
},
{
command: '/tint',
args: '<color1> [<color2>]',
description: _td('Changes colour scheme of current room'),
},
{
command: '/verify',
args: '<user-id> <device-id> <device-signing-key>',
description: _td('Verifies a user, device, and pubkey tuple'),
},
{
command: '/ignore',
args: '<user-id>',
description: _td('Ignores a user, hiding their messages from you'),
},
{
command: '/unignore',
args: '<user-id>',
description: _td('Stops ignoring a user, showing their messages going forward'),
},
// Omitting `/markdown` as it only seems to apply to OldComposer
];
let COMMAND_RE = /(^\/\w*)/g;
let instance = null;
const COMMAND_RE = /(^\/\w*)/g;
export default class CommandProvider extends AutocompleteProvider {
constructor() {
super(COMMAND_RE);
this.fuse = new Fuse(COMMANDS, {
this.matcher = new FuzzyMatcher(COMMANDS, {
keys: ['command', 'args', 'description'],
});
}
async getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
completions = this.matcher.match(command[0]).map((result) => {
return {
completion: result.command + ' ',
component: (<TextualCompletion
title={result.command}
subtitle={result.args}
description={result.description}
description={_t(result.description)}
/>),
range,
};
@ -78,19 +138,12 @@ export default class CommandProvider extends AutocompleteProvider {
}
getName() {
return '*️⃣ Commands';
}
static getInstance(): CommandProvider {
if (instance == null)
instance = new CommandProvider();
return instance;
return '*️⃣ ' + _t('Commands');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{completions}
{ completions }
</div>;
}
}

View file

@ -1,5 +1,21 @@
/*
Copyright 2016 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 React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
/* These were earlier stateless functional components but had to be converted
@ -15,22 +31,22 @@ export class TextualCompletion extends React.Component {
subtitle,
description,
className,
...restProps,
...restProps
} = this.props;
return (
<div className={classNames('mx_Autocomplete_Completion_block', className)} {...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>
<span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
<span className="mx_Autocomplete_Completion_description">{ description }</span>
</div>
);
}
}
TextualCompletion.propTypes = {
title: React.PropTypes.string,
subtitle: React.PropTypes.string,
description: React.PropTypes.string,
className: React.PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
className: PropTypes.string,
};
export class PillCompletion extends React.Component {
@ -41,22 +57,22 @@ export class PillCompletion extends React.Component {
description,
initialComponent,
className,
...restProps,
...restProps
} = this.props;
return (
<div className={classNames('mx_Autocomplete_Completion_pill', className)} {...restProps}>
{initialComponent}
<span className="mx_Autocomplete_Completion_title">{title}</span>
<span className="mx_Autocomplete_Completion_subtitle">{subtitle}</span>
<span className="mx_Autocomplete_Completion_description">{description}</span>
{ initialComponent }
<span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
<span className="mx_Autocomplete_Completion_description">{ description }</span>
</div>
);
}
}
PillCompletion.propTypes = {
title: React.PropTypes.string,
subtitle: React.PropTypes.string,
description: React.PropTypes.string,
initialComponent: React.PropTypes.element,
className: React.PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
initialComponent: PropTypes.element,
className: PropTypes.string,
};

View file

@ -1,4 +1,23 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
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 React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import 'whatwg-fetch';
@ -7,20 +26,18 @@ import {TextualCompletion} from './Components';
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
let instance = null;
export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() {
super(DDG_REGEX);
}
static getQueryUri(query: String) {
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(query: string, selection: {start: number, end: number}) {
let {command, range} = this.getCurrentCommand(query, selection);
const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];
}
@ -29,7 +46,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
method: 'GET',
});
const json = await response.json();
let results = json.Results.map(result => {
const results = json.Results.map((result) => {
return {
completion: result.Text,
component: (
@ -75,19 +92,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
}
getName() {
return '🔍 Results from DuckDuckGo';
}
static getInstance(): DuckDuckGoProvider {
if (instance == null) {
instance = new DuckDuckGoProvider();
}
return instance;
return '🔍 ' + _t('Results from DuckDuckGo');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{completions}
{ completions }
</div>;
}
}

View file

@ -1,56 +1,158 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
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 React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js';
import {emojioneList, shortnameToImage, shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
import FuzzyMatcher from './FuzzyMatcher';
import sdk from '../index';
import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore";
const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
import EmojiData from '../stripped-emoji.json';
let instance = null;
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 + ')');
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);
},
).map((a, index) => {
return {
name: a.name,
shortname: a.shortname,
aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '',
// Include the index so that we can preserve the original order
_orderBy: index,
};
});
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.fuse = new Fuse(EMOJI_SHORTNAMES);
this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: ['aliases_ascii', 'shortname'],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
});
this.nameMatcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: ['name'],
// For removing punctuation
shouldMatchWordsOnly: true,
});
}
async getCompletions(query: string, selection: SelectionRange) {
if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them
}
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
const shortname = EMOJI_SHORTNAMES[result];
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;
}
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)
sorters.push((c) => score(matchedString, c.shortname));
// If the matchedString is not empty, sort by length of shortname. Example:
// matchedString = ":bookmark"
// completions = [":bookmark:", ":bookmark_tabs:", ...]
if (matchedString.length > 1) {
sorters.push((c) => c.shortname.length);
}
// Finally, sort by original ordering
sorters.push((c) => c._orderBy);
completions = _sortBy(_uniq(completions), sorters);
completions = completions.map((result) => {
const {shortname} = result;
const unicode = shortnameToUnicode(shortname);
return {
completion: unicode,
component: (
<PillCompletion title={shortname} initialComponent={<EmojiText style={{maxWidth: '1em'}}>{unicode}</EmojiText>} />
<PillCompletion title={shortname} initialComponent={<EmojiText style={{maxWidth: '1em'}}>{ unicode }</EmojiText>} />
),
range,
};
}).slice(0, 8);
}).slice(0, LIMIT);
}
return completions;
}
getName() {
return '😃 Emoji';
}
static getInstance() {
if (instance == null)
instance = new EmojiProvider();
return instance;
return '😃 ' + _t('Emoji');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
{ completions }
</div>;
}
}

View file

@ -0,0 +1,107 @@
/*
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 Levenshtein from 'liblevenshtein';
//import _at from 'lodash/at';
//import _flatMap from 'lodash/flatMap';
//import _sortBy from 'lodash/sortBy';
//import _sortedUniq from 'lodash/sortedUniq';
//import _keys from 'lodash/keys';
//
//class KeyMap {
// keys: Array<String>;
// objectMap: {[String]: Array<Object>};
// priorityMap: {[String]: number}
//}
//
//const DEFAULT_RESULT_COUNT = 10;
//const DEFAULT_DISTANCE = 5;
// FIXME Until Fuzzy matching works better, we use prefix matching.
import PrefixMatcher from './QueryMatcher';
export default PrefixMatcher;
//class FuzzyMatcher { // eslint-disable-line no-unused-vars
// /**
// * @param {object[]} objects the objects to perform a match on
// * @param {string[]} keys an array of keys within each object to match on
// * Keys can refer to object properties by name and as in JavaScript (for nested properties)
// *
// * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the
// * resulting KeyMap.
// *
// * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
// * @return {KeyMap}
// */
// static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
// const keyMap = new KeyMap();
// const map = {};
// const priorities = {};
//
// objects.forEach((object, i) => {
// const keyValues = _at(object, keys);
// console.log(object, keyValues, keys);
// for (const keyValue of keyValues) {
// if (!map.hasOwnProperty(keyValue)) {
// map[keyValue] = [];
// }
// map[keyValue].push(object);
// }
// priorities[object] = i;
// });
//
// keyMap.objectMap = map;
// keyMap.priorityMap = priorities;
// keyMap.keys = _sortBy(_keys(map), [(value) => priorities[value]]);
// return keyMap;
// }
//
// constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
// this.options = options;
// this.keys = options.keys;
// this.setObjects(objects);
// }
//
// setObjects(objects: Array<Object>) {
// this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys);
// console.log(this.keyMap.keys);
// this.matcher = new Levenshtein.Builder()
// .dictionary(this.keyMap.keys, true)
// .algorithm('transposition')
// .sort_candidates(false)
// .case_insensitive_sort(true)
// .include_distance(true)
// .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense
// .build();
// }
//
// match(query: String): Array<Object> {
// const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE);
// // TODO FIXME This is hideous. Clean up when possible.
// const val = _sortedUniq(_sortBy(_flatMap(candidates, (candidate) => {
// return this.keyMap.objectMap[candidate[0]].map((value) => {
// return {
// distance: candidate[1],
// ...value,
// };
// });
// }),
// [(candidate) => candidate.distance, (candidate) => this.keyMap.priorityMap[candidate]]));
// console.log(val);
// return val;
// }
//}

View file

@ -0,0 +1,62 @@
/*
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 React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler';
import MatrixClientPeg from '../MatrixClientPeg';
import {PillCompletion} from './Components';
import sdk from '../index';
const AT_ROOM_REGEX = /@\S*/g;
export default class NotifProvider extends AutocompleteProvider {
constructor(room) {
super(AT_ROOM_REGEX);
this.room = room;
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();
if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return [];
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) {
return [{
completion: '@room',
suffix: ' ',
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
),
range,
}];
}
return [];
}
getName() {
return '❗️ ' + _t('Room Notification');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions }
</div>;
}
}

View file

@ -0,0 +1,112 @@
//@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 _at from 'lodash/at';
import _flatMap from 'lodash/flatMap';
import _sortBy from 'lodash/sortBy';
import _uniq from 'lodash/uniq';
import _keys from 'lodash/keys';
class KeyMap {
keys: Array<String>;
objectMap: {[String]: Array<Object>};
priorityMap = new Map();
}
export default class QueryMatcher {
/**
* @param {object[]} objects the objects to perform a match on
* @param {string[]} keys an array of keys within each object to match on
* Keys can refer to object properties by name and as in JavaScript (for nested properties)
*
* To use, simply presort objects by required criteria, run through this function and create a QueryMatcher with the
* resulting KeyMap.
*
* TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
* @return {KeyMap}
*/
static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
const keyMap = new KeyMap();
const map = {};
objects.forEach((object, i) => {
const keyValues = _at(object, keys);
for (const keyValue of keyValues) {
if (!map.hasOwnProperty(keyValue)) {
map[keyValue] = [];
}
map[keyValue].push(object);
}
keyMap.priorityMap.set(object, i);
});
keyMap.objectMap = map;
keyMap.keys = _keys(map);
return keyMap;
}
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
this.options = options;
this.keys = options.keys;
this.setObjects(objects);
// By default, we remove any non-alphanumeric characters ([^A-Za-z0-9_]) from the
// query and the value being queried before matching
if (this.options.shouldMatchWordsOnly === undefined) {
this.options.shouldMatchWordsOnly = true;
}
// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this.options.shouldMatchPrefix === undefined) {
this.options.shouldMatchPrefix = false;
}
}
setObjects(objects: Array<Object>) {
this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys);
}
match(query: String): Array<Object> {
query = query.toLowerCase();
if (this.options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
}
if (query.length === 0) {
return [];
}
const results = [];
this.keyMap.keys.forEach((key) => {
let resultKey = key.toLowerCase();
if (this.options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this.options.shouldMatchPrefix || index === 0)) {
results.push({key, index});
}
});
return _uniq(_flatMap(_sortBy(results, (candidate) => {
return candidate.index;
}).map((candidate) => {
// return an array of objects (those given to setObjects) that have the given
// key as a property.
return this.keyMap.objectMap[candidate.key];
})));
}
}

View file

@ -1,73 +1,104 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
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 React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js';
import FuzzyMatcher from './FuzzyMatcher';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../matrix-to";
const ROOM_REGEX = /(?=#)(\S*)/g;
let instance = null;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX, {
keys: ['displayName', 'userId'],
});
this.fuse = new Fuse([], {
keys: ['name', 'roomId', 'aliases'],
super(ROOM_REGEX);
this.matcher = new FuzzyMatcher([], {
keys: ['displayedAlias', 'name'],
});
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get();
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const client = MatrixClientPeg.get();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
this.fuse.set(client.getRooms().filter(room => !!room).map(room => {
this.matcher.setObjects(client.getRooms().filter(
(room) => !!room && !!getDisplayAliasForRoom(room),
).map((room) => {
return {
room: room,
name: room.name,
aliases: room.getAliases(),
displayedAlias: getDisplayAliasForRoom(room),
};
}));
completions = this.fuse.search(command[0]).map(room => {
let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
]).map((room) => {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return {
completion: displayAlias,
suffix: ' ',
href: makeRoomPermalink(displayAlias),
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
),
range,
};
}).filter(completion => !!completion.completion && completion.completion.length > 0).slice(0, 4);
})
.filter((completion) => !!completion.completion && completion.completion.length > 0)
.slice(0, 4);
}
return completions;
}
getName() {
return '💬 Rooms';
}
static getInstance() {
if (instance == null) {
instance = new RoomProvider();
}
return instance;
return '💬 ' + _t('Rooms');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions }
</div>;
}
shouldForceComplete(): boolean {
return true;
}
}

View file

@ -1,73 +1,165 @@
//@flow
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
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 React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import Fuse from 'fuse.js';
import {PillCompletion} from './Components';
import sdk from '../index';
import FuzzyMatcher from './FuzzyMatcher';
import _pull from 'lodash/pull';
import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk';
import {makeUserPermalink} from "../matrix-to";
const USER_REGEX = /@\S*/g;
let instance = null;
export default class UserProvider extends AutocompleteProvider {
constructor() {
users: Array<RoomMember> = null;
room: Room = null;
constructor(room) {
super(USER_REGEX, {
keys: ['name', 'userId'],
keys: ['name'],
});
this.users = [];
this.fuse = new Fuse([], {
this.room = room;
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'],
shouldMatchPrefix: true,
});
this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
this._onRoomStateMemberBound = this._onRoomStateMember.bind(this);
MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound);
}
destroy() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound);
}
}
_onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
if (!room) return;
if (removed) return;
if (room.roomId !== this.room.roomId) return;
// ignore events from filtered timelines
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
// ignore anything but real-time updates at the end of the room:
// updates from pagination will happen when the paginate completes.
if (toStartOfTimeline || !data || !data.liveEvent) return;
this.onUserSpoke(ev.sender);
}
_onRoomStateMember(ev, state, member) {
// ignore members in other rooms
if (member.roomId !== this.room.roomId) {
return;
}
// blow away the users cache
this.users = null;
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) {
return [];
}
// lazy-load user list into matcher
if (this.users === null) this._makeUsers();
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection, force);
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
this.fuse.set(this.users);
completions = this.fuse.search(command[0]).map(user => {
let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let completion = displayName;
if (range.start === 0) {
completion += ': ';
} else {
completion += ' ';
}
completions = this.matcher.match(command[0]).map((user) => {
const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
return {
completion,
// Length of completion should equal length of text in decorator. draft-js
// relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''),
suffix: range.start === 0 ? ': ' : ' ',
href: makeUserPermalink(user.userId),
component: (
<PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24}/>}
initialComponent={<MemberAvatar member={user} width={24} height={24} />}
title={displayName}
description={user.userId} />
),
range,
};
}).slice(0, 4);
});
}
return completions;
}
getName() {
return '👥 Users';
return '👥 ' + _t('Users');
}
setUserList(users) {
this.users = users;
}
_makeUsers() {
const events = this.room.getLiveTimeline().getEvents();
const lastSpoken = {};
static getInstance(): UserProvider {
if (instance == null) {
instance = new UserProvider();
for (const event of events) {
lastSpoken[event.getSender()] = event.getTs();
}
return instance;
const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = this.room.getJoinedMembers().filter((member) => {
if (member.userId !== currentUserId) return true;
});
this.users = _sortBy(this.users, (member) =>
1E20 - lastSpoken[member.userId] || 1E20,
);
this.matcher.setObjects(this.users);
}
onUserSpoke(user: RoomMember) {
if (this.users === null) return;
if (user.userId === MatrixClientPeg.get().credentials.userId) return;
// Move the user that spoke to the front of the array
this.users.splice(
this.users.findIndex((user2) => user2.userId === user.userId), 1);
this.users = [user, ...this.users];
this.matcher.setObjects(this.users);
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions }
</div>;
}

View file

@ -1,235 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
* THIS FILE IS AUTO-GENERATED
* You can edit it you like, but your changes will be overwritten,
* so you'd just be trying to swim upstream like a salmon.
* You are not a salmon.
*
* To update it, run:
* ./reskindex.js -h header
*/
module.exports.components = {};
import structures$ContextualMenu from './components/structures/ContextualMenu';
structures$ContextualMenu && (module.exports.components['structures.ContextualMenu'] = structures$ContextualMenu);
import structures$CreateRoom from './components/structures/CreateRoom';
structures$CreateRoom && (module.exports.components['structures.CreateRoom'] = structures$CreateRoom);
import structures$FilePanel from './components/structures/FilePanel';
structures$FilePanel && (module.exports.components['structures.FilePanel'] = structures$FilePanel);
import structures$LoggedInView from './components/structures/LoggedInView';
structures$LoggedInView && (module.exports.components['structures.LoggedInView'] = structures$LoggedInView);
import structures$MatrixChat from './components/structures/MatrixChat';
structures$MatrixChat && (module.exports.components['structures.MatrixChat'] = structures$MatrixChat);
import structures$MessagePanel from './components/structures/MessagePanel';
structures$MessagePanel && (module.exports.components['structures.MessagePanel'] = structures$MessagePanel);
import structures$NotificationPanel from './components/structures/NotificationPanel';
structures$NotificationPanel && (module.exports.components['structures.NotificationPanel'] = structures$NotificationPanel);
import structures$RoomStatusBar from './components/structures/RoomStatusBar';
structures$RoomStatusBar && (module.exports.components['structures.RoomStatusBar'] = structures$RoomStatusBar);
import structures$RoomView from './components/structures/RoomView';
structures$RoomView && (module.exports.components['structures.RoomView'] = structures$RoomView);
import structures$ScrollPanel from './components/structures/ScrollPanel';
structures$ScrollPanel && (module.exports.components['structures.ScrollPanel'] = structures$ScrollPanel);
import structures$TimelinePanel from './components/structures/TimelinePanel';
structures$TimelinePanel && (module.exports.components['structures.TimelinePanel'] = structures$TimelinePanel);
import structures$UploadBar from './components/structures/UploadBar';
structures$UploadBar && (module.exports.components['structures.UploadBar'] = structures$UploadBar);
import structures$UserSettings from './components/structures/UserSettings';
structures$UserSettings && (module.exports.components['structures.UserSettings'] = structures$UserSettings);
import structures$login$ForgotPassword from './components/structures/login/ForgotPassword';
structures$login$ForgotPassword && (module.exports.components['structures.login.ForgotPassword'] = structures$login$ForgotPassword);
import structures$login$Login from './components/structures/login/Login';
structures$login$Login && (module.exports.components['structures.login.Login'] = structures$login$Login);
import structures$login$PostRegistration from './components/structures/login/PostRegistration';
structures$login$PostRegistration && (module.exports.components['structures.login.PostRegistration'] = structures$login$PostRegistration);
import structures$login$Registration from './components/structures/login/Registration';
structures$login$Registration && (module.exports.components['structures.login.Registration'] = structures$login$Registration);
import views$avatars$BaseAvatar from './components/views/avatars/BaseAvatar';
views$avatars$BaseAvatar && (module.exports.components['views.avatars.BaseAvatar'] = views$avatars$BaseAvatar);
import views$avatars$MemberAvatar from './components/views/avatars/MemberAvatar';
views$avatars$MemberAvatar && (module.exports.components['views.avatars.MemberAvatar'] = views$avatars$MemberAvatar);
import views$avatars$RoomAvatar from './components/views/avatars/RoomAvatar';
views$avatars$RoomAvatar && (module.exports.components['views.avatars.RoomAvatar'] = views$avatars$RoomAvatar);
import views$create_room$CreateRoomButton from './components/views/create_room/CreateRoomButton';
views$create_room$CreateRoomButton && (module.exports.components['views.create_room.CreateRoomButton'] = views$create_room$CreateRoomButton);
import views$create_room$Presets from './components/views/create_room/Presets';
views$create_room$Presets && (module.exports.components['views.create_room.Presets'] = views$create_room$Presets);
import views$create_room$RoomAlias from './components/views/create_room/RoomAlias';
views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias);
import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog';
views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog);
import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog';
views$dialogs$DeactivateAccountDialog && (module.exports.components['views.dialogs.DeactivateAccountDialog'] = views$dialogs$DeactivateAccountDialog);
import views$dialogs$EncryptedEventDialog from './components/views/dialogs/EncryptedEventDialog';
views$dialogs$EncryptedEventDialog && (module.exports.components['views.dialogs.EncryptedEventDialog'] = views$dialogs$EncryptedEventDialog);
import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog';
views$dialogs$ErrorDialog && (module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog);
import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog';
views$dialogs$InteractiveAuthDialog && (module.exports.components['views.dialogs.InteractiveAuthDialog'] = views$dialogs$InteractiveAuthDialog);
import views$dialogs$LogoutPrompt from './components/views/dialogs/LogoutPrompt';
views$dialogs$LogoutPrompt && (module.exports.components['views.dialogs.LogoutPrompt'] = views$dialogs$LogoutPrompt);
import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedToRegisterDialog';
views$dialogs$NeedToRegisterDialog && (module.exports.components['views.dialogs.NeedToRegisterDialog'] = views$dialogs$NeedToRegisterDialog);
import views$dialogs$QuestionDialog from './components/views/dialogs/QuestionDialog';
views$dialogs$QuestionDialog && (module.exports.components['views.dialogs.QuestionDialog'] = views$dialogs$QuestionDialog);
import views$dialogs$SetDisplayNameDialog from './components/views/dialogs/SetDisplayNameDialog';
views$dialogs$SetDisplayNameDialog && (module.exports.components['views.dialogs.SetDisplayNameDialog'] = views$dialogs$SetDisplayNameDialog);
import views$dialogs$TextInputDialog from './components/views/dialogs/TextInputDialog';
views$dialogs$TextInputDialog && (module.exports.components['views.dialogs.TextInputDialog'] = views$dialogs$TextInputDialog);
import views$elements$AddressSelector from './components/views/elements/AddressSelector';
views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector);
import views$elements$AddressTile from './components/views/elements/AddressTile';
views$elements$AddressTile && (module.exports.components['views.elements.AddressTile'] = views$elements$AddressTile);
import views$elements$DeviceVerifyButtons from './components/views/elements/DeviceVerifyButtons';
views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons);
import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox';
views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox);
import views$elements$EditableText from './components/views/elements/EditableText';
views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText);
import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer';
views$elements$EditableTextContainer && (module.exports.components['views.elements.EditableTextContainer'] = views$elements$EditableTextContainer);
import views$elements$EmojiText from './components/views/elements/EmojiText';
views$elements$EmojiText && (module.exports.components['views.elements.EmojiText'] = views$elements$EmojiText);
import views$elements$MemberEventListSummary from './components/views/elements/MemberEventListSummary';
views$elements$MemberEventListSummary && (module.exports.components['views.elements.MemberEventListSummary'] = views$elements$MemberEventListSummary);
import views$elements$PowerSelector from './components/views/elements/PowerSelector';
views$elements$PowerSelector && (module.exports.components['views.elements.PowerSelector'] = views$elements$PowerSelector);
import views$elements$ProgressBar from './components/views/elements/ProgressBar';
views$elements$ProgressBar && (module.exports.components['views.elements.ProgressBar'] = views$elements$ProgressBar);
import views$elements$TintableSvg from './components/views/elements/TintableSvg';
views$elements$TintableSvg && (module.exports.components['views.elements.TintableSvg'] = views$elements$TintableSvg);
import views$elements$TruncatedList from './components/views/elements/TruncatedList';
views$elements$TruncatedList && (module.exports.components['views.elements.TruncatedList'] = views$elements$TruncatedList);
import views$elements$UserSelector from './components/views/elements/UserSelector';
views$elements$UserSelector && (module.exports.components['views.elements.UserSelector'] = views$elements$UserSelector);
import views$login$CaptchaForm from './components/views/login/CaptchaForm';
views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm);
import views$login$CasLogin from './components/views/login/CasLogin';
views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin);
import views$login$CustomServerDialog from './components/views/login/CustomServerDialog';
views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog);
import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents';
views$login$InteractiveAuthEntryComponents && (module.exports.components['views.login.InteractiveAuthEntryComponents'] = views$login$InteractiveAuthEntryComponents);
import views$login$LoginFooter from './components/views/login/LoginFooter';
views$login$LoginFooter && (module.exports.components['views.login.LoginFooter'] = views$login$LoginFooter);
import views$login$LoginHeader from './components/views/login/LoginHeader';
views$login$LoginHeader && (module.exports.components['views.login.LoginHeader'] = views$login$LoginHeader);
import views$login$PasswordLogin from './components/views/login/PasswordLogin';
views$login$PasswordLogin && (module.exports.components['views.login.PasswordLogin'] = views$login$PasswordLogin);
import views$login$RegistrationForm from './components/views/login/RegistrationForm';
views$login$RegistrationForm && (module.exports.components['views.login.RegistrationForm'] = views$login$RegistrationForm);
import views$login$ServerConfig from './components/views/login/ServerConfig';
views$login$ServerConfig && (module.exports.components['views.login.ServerConfig'] = views$login$ServerConfig);
import views$messages$MAudioBody from './components/views/messages/MAudioBody';
views$messages$MAudioBody && (module.exports.components['views.messages.MAudioBody'] = views$messages$MAudioBody);
import views$messages$MFileBody from './components/views/messages/MFileBody';
views$messages$MFileBody && (module.exports.components['views.messages.MFileBody'] = views$messages$MFileBody);
import views$messages$MImageBody from './components/views/messages/MImageBody';
views$messages$MImageBody && (module.exports.components['views.messages.MImageBody'] = views$messages$MImageBody);
import views$messages$MVideoBody from './components/views/messages/MVideoBody';
views$messages$MVideoBody && (module.exports.components['views.messages.MVideoBody'] = views$messages$MVideoBody);
import views$messages$MessageEvent from './components/views/messages/MessageEvent';
views$messages$MessageEvent && (module.exports.components['views.messages.MessageEvent'] = views$messages$MessageEvent);
import views$messages$SenderProfile from './components/views/messages/SenderProfile';
views$messages$SenderProfile && (module.exports.components['views.messages.SenderProfile'] = views$messages$SenderProfile);
import views$messages$TextualBody from './components/views/messages/TextualBody';
views$messages$TextualBody && (module.exports.components['views.messages.TextualBody'] = views$messages$TextualBody);
import views$messages$TextualEvent from './components/views/messages/TextualEvent';
views$messages$TextualEvent && (module.exports.components['views.messages.TextualEvent'] = views$messages$TextualEvent);
import views$messages$UnknownBody from './components/views/messages/UnknownBody';
views$messages$UnknownBody && (module.exports.components['views.messages.UnknownBody'] = views$messages$UnknownBody);
import views$room_settings$AliasSettings from './components/views/room_settings/AliasSettings';
views$room_settings$AliasSettings && (module.exports.components['views.room_settings.AliasSettings'] = views$room_settings$AliasSettings);
import views$room_settings$ColorSettings from './components/views/room_settings/ColorSettings';
views$room_settings$ColorSettings && (module.exports.components['views.room_settings.ColorSettings'] = views$room_settings$ColorSettings);
import views$room_settings$UrlPreviewSettings from './components/views/room_settings/UrlPreviewSettings';
views$room_settings$UrlPreviewSettings && (module.exports.components['views.room_settings.UrlPreviewSettings'] = views$room_settings$UrlPreviewSettings);
import views$rooms$Autocomplete from './components/views/rooms/Autocomplete';
views$rooms$Autocomplete && (module.exports.components['views.rooms.Autocomplete'] = views$rooms$Autocomplete);
import views$rooms$AuxPanel from './components/views/rooms/AuxPanel';
views$rooms$AuxPanel && (module.exports.components['views.rooms.AuxPanel'] = views$rooms$AuxPanel);
import views$rooms$EntityTile from './components/views/rooms/EntityTile';
views$rooms$EntityTile && (module.exports.components['views.rooms.EntityTile'] = views$rooms$EntityTile);
import views$rooms$EventTile from './components/views/rooms/EventTile';
views$rooms$EventTile && (module.exports.components['views.rooms.EventTile'] = views$rooms$EventTile);
import views$rooms$LinkPreviewWidget from './components/views/rooms/LinkPreviewWidget';
views$rooms$LinkPreviewWidget && (module.exports.components['views.rooms.LinkPreviewWidget'] = views$rooms$LinkPreviewWidget);
import views$rooms$MemberDeviceInfo from './components/views/rooms/MemberDeviceInfo';
views$rooms$MemberDeviceInfo && (module.exports.components['views.rooms.MemberDeviceInfo'] = views$rooms$MemberDeviceInfo);
import views$rooms$MemberInfo from './components/views/rooms/MemberInfo';
views$rooms$MemberInfo && (module.exports.components['views.rooms.MemberInfo'] = views$rooms$MemberInfo);
import views$rooms$MemberList from './components/views/rooms/MemberList';
views$rooms$MemberList && (module.exports.components['views.rooms.MemberList'] = views$rooms$MemberList);
import views$rooms$MemberTile from './components/views/rooms/MemberTile';
views$rooms$MemberTile && (module.exports.components['views.rooms.MemberTile'] = views$rooms$MemberTile);
import views$rooms$MessageComposer from './components/views/rooms/MessageComposer';
views$rooms$MessageComposer && (module.exports.components['views.rooms.MessageComposer'] = views$rooms$MessageComposer);
import views$rooms$MessageComposerInput from './components/views/rooms/MessageComposerInput';
views$rooms$MessageComposerInput && (module.exports.components['views.rooms.MessageComposerInput'] = views$rooms$MessageComposerInput);
import views$rooms$MessageComposerInputOld from './components/views/rooms/MessageComposerInputOld';
views$rooms$MessageComposerInputOld && (module.exports.components['views.rooms.MessageComposerInputOld'] = views$rooms$MessageComposerInputOld);
import views$rooms$PresenceLabel from './components/views/rooms/PresenceLabel';
views$rooms$PresenceLabel && (module.exports.components['views.rooms.PresenceLabel'] = views$rooms$PresenceLabel);
import views$rooms$ReadReceiptMarker from './components/views/rooms/ReadReceiptMarker';
views$rooms$ReadReceiptMarker && (module.exports.components['views.rooms.ReadReceiptMarker'] = views$rooms$ReadReceiptMarker);
import views$rooms$RoomHeader from './components/views/rooms/RoomHeader';
views$rooms$RoomHeader && (module.exports.components['views.rooms.RoomHeader'] = views$rooms$RoomHeader);
import views$rooms$RoomList from './components/views/rooms/RoomList';
views$rooms$RoomList && (module.exports.components['views.rooms.RoomList'] = views$rooms$RoomList);
import views$rooms$RoomNameEditor from './components/views/rooms/RoomNameEditor';
views$rooms$RoomNameEditor && (module.exports.components['views.rooms.RoomNameEditor'] = views$rooms$RoomNameEditor);
import views$rooms$RoomPreviewBar from './components/views/rooms/RoomPreviewBar';
views$rooms$RoomPreviewBar && (module.exports.components['views.rooms.RoomPreviewBar'] = views$rooms$RoomPreviewBar);
import views$rooms$RoomSettings from './components/views/rooms/RoomSettings';
views$rooms$RoomSettings && (module.exports.components['views.rooms.RoomSettings'] = views$rooms$RoomSettings);
import views$rooms$RoomTile from './components/views/rooms/RoomTile';
views$rooms$RoomTile && (module.exports.components['views.rooms.RoomTile'] = views$rooms$RoomTile);
import views$rooms$RoomTopicEditor from './components/views/rooms/RoomTopicEditor';
views$rooms$RoomTopicEditor && (module.exports.components['views.rooms.RoomTopicEditor'] = views$rooms$RoomTopicEditor);
import views$rooms$SearchResultTile from './components/views/rooms/SearchResultTile';
views$rooms$SearchResultTile && (module.exports.components['views.rooms.SearchResultTile'] = views$rooms$SearchResultTile);
import views$rooms$SearchableEntityList from './components/views/rooms/SearchableEntityList';
views$rooms$SearchableEntityList && (module.exports.components['views.rooms.SearchableEntityList'] = views$rooms$SearchableEntityList);
import views$rooms$SimpleRoomHeader from './components/views/rooms/SimpleRoomHeader';
views$rooms$SimpleRoomHeader && (module.exports.components['views.rooms.SimpleRoomHeader'] = views$rooms$SimpleRoomHeader);
import views$rooms$TabCompleteBar from './components/views/rooms/TabCompleteBar';
views$rooms$TabCompleteBar && (module.exports.components['views.rooms.TabCompleteBar'] = views$rooms$TabCompleteBar);
import views$rooms$TopUnreadMessagesBar from './components/views/rooms/TopUnreadMessagesBar';
views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar);
import views$rooms$UserTile from './components/views/rooms/UserTile';
views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile);
import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar';
views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar);
import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName';
views$settings$ChangeDisplayName && (module.exports.components['views.settings.ChangeDisplayName'] = views$settings$ChangeDisplayName);
import views$settings$ChangePassword from './components/views/settings/ChangePassword';
views$settings$ChangePassword && (module.exports.components['views.settings.ChangePassword'] = views$settings$ChangePassword);
import views$settings$DevicesPanel from './components/views/settings/DevicesPanel';
views$settings$DevicesPanel && (module.exports.components['views.settings.DevicesPanel'] = views$settings$DevicesPanel);
import views$settings$DevicesPanelEntry from './components/views/settings/DevicesPanelEntry';
views$settings$DevicesPanelEntry && (module.exports.components['views.settings.DevicesPanelEntry'] = views$settings$DevicesPanelEntry);
import views$settings$EnableNotificationsButton from './components/views/settings/EnableNotificationsButton';
views$settings$EnableNotificationsButton && (module.exports.components['views.settings.EnableNotificationsButton'] = views$settings$EnableNotificationsButton);
import views$voip$CallView from './components/views/voip/CallView';
views$voip$CallView && (module.exports.components['views.voip.CallView'] = views$voip$CallView);
import views$voip$IncomingCallBox from './components/views/voip/IncomingCallBox';
views$voip$IncomingCallBox && (module.exports.components['views.voip.IncomingCallBox'] = views$voip$IncomingCallBox);
import views$voip$VideoFeed from './components/views/voip/VideoFeed';
views$voip$VideoFeed && (module.exports.components['views.voip.VideoFeed'] = views$voip$VideoFeed);
import views$voip$VideoView from './components/views/voip/VideoView';
views$voip$VideoView && (module.exports.components['views.voip.VideoView'] = views$voip$VideoView);

View file

@ -17,9 +17,10 @@ limitations under the License.
'use strict';
var classNames = require('classnames');
var React = require('react');
var ReactDOM = require('react-dom');
const classNames = require('classnames');
const React = require('react');
const ReactDOM = require('react-dom');
import PropTypes from 'prop-types';
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -29,14 +30,15 @@ module.exports = {
ContextualMenuContainerId: "mx_ContextualMenu_Container",
propTypes: {
menuWidth: React.PropTypes.number,
menuHeight: React.PropTypes.number,
chevronOffset: React.PropTypes.number,
menuColour: React.PropTypes.string,
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
menuColour: PropTypes.string,
chevronFace: PropTypes.string, // top, bottom, left, right
},
getOrCreateContainer: function() {
var container = document.getElementById(this.ContextualMenuContainerId);
let container = document.getElementById(this.ContextualMenuContainerId);
if (!container) {
container = document.createElement("div");
@ -47,10 +49,10 @@ module.exports = {
return container;
},
createMenu: function (Element, props) {
var self = this;
createMenu: function(Element, props) {
const self = this;
var closeMenu = function() {
const closeMenu = function() {
ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
if (props && props.onFinished) {
@ -58,47 +60,64 @@ module.exports = {
}
};
var position = {
top: props.top,
};
const position = {};
let chevronFace = null;
var chevronOffset = {};
if (props.chevronOffset) {
if (props.top) {
position.top = props.top;
} else {
position.bottom = props.bottom;
}
if (props.left) {
position.left = props.left;
chevronFace = 'left';
} else {
position.right = props.right;
chevronFace = 'right';
}
const chevronOffset = {};
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else {
chevronOffset.top = props.chevronOffset;
}
// To overide the deafult chevron colour, if it's been set
var chevronCSS = "";
// 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};
}
`;
}
var chevron = null;
if (props.left) {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_left"></div>
position.left = props.left;
} else {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_right"></div>
position.right = props.right;
}
const chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace}></div>;
const className = 'mx_ContextualMenu_wrapper';
var className = 'mx_ContextualMenu_wrapper';
var menuClasses = classNames({
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_left': props.left,
'mx_ContextualMenu_right': !props.left,
'mx_ContextualMenu_left': chevronFace === 'left',
'mx_ContextualMenu_right': chevronFace === 'right',
'mx_ContextualMenu_top': chevronFace === 'top',
'mx_ContextualMenu_bottom': chevronFace === 'bottom',
});
var menuStyle = {};
const menuStyle = {};
if (props.menuWidth) {
menuStyle.width = props.menuWidth;
}
@ -113,14 +132,14 @@ module.exports = {
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click!
var menu = (
const menu = (
<div className={className} style={position}>
<div className={menuClasses} style={menuStyle}>
{chevron}
<Element {...props} onFinished={closeMenu}/>
{ chevron }
<Element {...props} onFinished={closeMenu} />
</div>
<div className="mx_ContextualMenu_background" onClick={closeMenu}></div>
<style>{chevronCSS}</style>
<style>{ chevronCSS }</style>
</div>
);

View file

@ -16,22 +16,23 @@ limitations under the License.
'use strict';
var React = require("react");
var MatrixClientPeg = require("../../MatrixClientPeg");
var PresetValues = {
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
const PresetValues = {
PrivateChat: "private_chat",
PublicChat: "public_chat",
Custom: "custom",
};
var q = require('q');
var sdk = require('../../index');
module.exports = React.createClass({
displayName: 'CreateRoom',
propTypes: {
onRoomCreated: React.PropTypes.func,
collapsedRhs: React.PropTypes.bool,
onRoomCreated: PropTypes.func,
collapsedRhs: PropTypes.bool,
},
phases: {
@ -61,7 +62,7 @@ module.exports = React.createClass({
},
onCreateRoom: function() {
var options = {};
const options = {};
if (this.state.room_name) {
options.name = this.state.room_name;
@ -79,14 +80,14 @@ module.exports = React.createClass({
{
type: "m.room.join_rules",
content: {
"join_rule": this.state.is_private ? "invite" : "public"
}
"join_rule": this.state.is_private ? "invite" : "public",
},
},
{
type: "m.room.history_visibility",
content: {
"history_visibility": this.state.share_history ? "shared" : "invited"
}
"history_visibility": this.state.share_history ? "shared" : "invited",
},
},
];
}
@ -94,19 +95,19 @@ module.exports = React.createClass({
options.invite = this.state.invited_users;
var alias = this.getAliasLocalpart();
const alias = this.getAliasLocalpart();
if (alias) {
options.room_alias_name = alias;
}
var cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.get();
if (!cli) {
// TODO: Error.
console.error("Cannot create room: No matrix client.");
return;
}
var deferred = cli.createRoom(options);
const deferred = cli.createRoom(options);
if (this.state.encrypt) {
// TODO
@ -116,9 +117,9 @@ module.exports = React.createClass({
phase: this.phases.CREATING,
});
var self = this;
const self = this;
deferred.then(function (resp) {
deferred.then(function(resp) {
self.setState({
phase: self.phases.CREATED,
});
@ -209,8 +210,8 @@ module.exports = React.createClass({
onAliasChanged: function(alias) {
this.setState({
alias: alias
})
alias: alias,
});
},
onEncryptChanged: function(ev) {
@ -220,64 +221,64 @@ module.exports = React.createClass({
},
render: function() {
var curr_phase = this.state.phase;
const curr_phase = this.state.phase;
if (curr_phase == this.phases.CREATING) {
var Loader = sdk.getComponent("elements.Spinner");
const Loader = sdk.getComponent("elements.Spinner");
return (
<Loader/>
<Loader />
);
} else {
var error_box = "";
let error_box = "";
if (curr_phase == this.phases.ERROR) {
error_box = (
<div className="mx_Error">
An error occured: {this.state.error_string}
{ _t('An error occurred: %(error_string)s', {error_string: this.state.error_string}) }
</div>
);
}
var CreateRoomButton = sdk.getComponent("create_room.CreateRoomButton");
var RoomAlias = sdk.getComponent("create_room.RoomAlias");
var Presets = sdk.getComponent("create_room.Presets");
var UserSelector = sdk.getComponent("elements.UserSelector");
var SimpleRoomHeader = sdk.getComponent("rooms.SimpleRoomHeader");
const CreateRoomButton = sdk.getComponent("create_room.CreateRoomButton");
const RoomAlias = sdk.getComponent("create_room.RoomAlias");
const Presets = sdk.getComponent("create_room.Presets");
const UserSelector = sdk.getComponent("elements.UserSelector");
const SimpleRoomHeader = sdk.getComponent("rooms.SimpleRoomHeader");
var domain = MatrixClientPeg.get().getDomain();
const domain = MatrixClientPeg.get().getDomain();
return (
<div className="mx_CreateRoom">
<SimpleRoomHeader title="CreateRoom" collapsedRhs={ this.props.collapsedRhs }/>
<SimpleRoomHeader title={_t("Create Room")} collapsedRhs={this.props.collapsedRhs} />
<div className="mx_CreateRoom_body">
<input type="text" ref="room_name" value={this.state.room_name} onChange={this.onNameChange} placeholder="Name"/> <br />
<textarea className="mx_CreateRoom_description" ref="topic" value={this.state.topic} onChange={this.onTopicChange} placeholder="Topic"/> <br />
<RoomAlias ref="alias" alias={this.state.alias} homeserver={ domain } onChange={this.onAliasChanged}/> <br />
<UserSelector ref="user_selector" selected_users={this.state.invited_users} onChange={this.onInviteChanged}/> <br />
<Presets ref="presets" onChange={this.onPresetChanged} preset={this.state.preset}/> <br />
<input type="text" ref="room_name" value={this.state.room_name} onChange={this.onNameChange} placeholder={_t('Name')} /> <br />
<textarea className="mx_CreateRoom_description" ref="topic" value={this.state.topic} onChange={this.onTopicChange} placeholder={_t('Topic')} /> <br />
<RoomAlias ref="alias" alias={this.state.alias} homeserver={domain} onChange={this.onAliasChanged} /> <br />
<UserSelector ref="user_selector" selected_users={this.state.invited_users} onChange={this.onInviteChanged} /> <br />
<Presets ref="presets" onChange={this.onPresetChanged} preset={this.state.preset} /> <br />
<div>
<label>
<input type="checkbox" ref="is_private" checked={this.state.is_private} onChange={this.onPrivateChanged}/>
Make this room private
<input type="checkbox" ref="is_private" checked={this.state.is_private} onChange={this.onPrivateChanged} />
{ _t('Make this room private') }
</label>
</div>
<div>
<label>
<input type="checkbox" ref="share_history" checked={this.state.share_history} onChange={this.onShareHistoryChanged}/>
Share message history with new users
<input type="checkbox" ref="share_history" checked={this.state.share_history} onChange={this.onShareHistoryChanged} />
{ _t('Share message history with new users') }
</label>
</div>
<div className="mx_CreateRoom_encrypt">
<label>
<input type="checkbox" ref="encrypt" checked={this.state.encrypt} onChange={this.onEncryptChanged}/>
Encrypt room
<input type="checkbox" ref="encrypt" checked={this.state.encrypt} onChange={this.onEncryptChanged} />
{ _t('Encrypt room') }
</label>
</div>
<div>
<CreateRoomButton onCreateRoom={this.onCreateRoom} /> <br />
</div>
{error_box}
{ error_box }
</div>
</div>
);
}
}
},
});

View file

@ -14,28 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ReactDOM = require("react-dom");
import React from 'react';
import PropTypes from 'prop-types';
var Matrix = require("matrix-js-sdk");
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher");
import Matrix from 'matrix-js-sdk';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
import { _t } from '../../languageHandler';
/*
* Component which shows the filtered file using a TimelinePanel
*/
var FilePanel = React.createClass({
const FilePanel = React.createClass({
displayName: 'FilePanel',
propTypes: {
roomId: React.PropTypes.string.isRequired,
roomId: PropTypes.string.isRequired,
},
getInitialState: function() {
return {
timelineSet: null,
}
};
},
componentWillMount: function() {
@ -56,42 +56,58 @@ var FilePanel = React.createClass({
},
updateTimelineSet: function(roomId) {
var client = MatrixClientPeg.get();
var room = client.getRoom(roomId);
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
this.noRoom = !room;
if (room) {
var filter = new Matrix.Filter(client.credentials.userId);
const filter = new Matrix.Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true
"contains_url": true,
},
}
}
},
},
);
// FIXME: we shouldn't be doing this every time we change room - see comment above.
client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then(
(filterId)=>{
filter.filterId = filterId;
var timelineSet = room.getOrCreateFilteredTimelineSet(filter);
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
this.setState({ timelineSet: timelineSet });
},
(error)=>{
console.error("Failed to get or create file panel filter", error);
}
},
);
}
else {
} else {
console.error("Failed to add filtered timelineSet for FilePanel as no room!");
}
},
render: function() {
if (MatrixClientPeg.get().isGuest()) {
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
<div className="mx_RoomView_empty">
{ _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
</div>
</div>;
} else if (this.noRoom) {
return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
<div className="mx_RoomView_empty">{ _t("You must join the room to see its files") }</div>
</div>;
}
// wrap a TimelinePanel with the jump-to-event bits turned off.
var TimelinePanel = sdk.getComponent("structures.TimelinePanel");
var Loader = sdk.getComponent("elements.Spinner");
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const Loader = sdk.getComponent("elements.Spinner");
if (this.state.timelineSet) {
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
@ -102,16 +118,15 @@ var FilePanel = React.createClass({
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = { false }
showUrlPreview = {false}
tileShape="file_grid"
opacity={ this.props.opacity }
empty={_t('There are no visible files in this room')}
/>
);
}
else {
} else {
return (
<div className="mx_FilePanel">
<Loader/>
<Loader />
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,225 @@
/*
Copyright 2017 Vector Creations 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 Matrix from 'matrix-js-sdk';
const InteractiveAuth = Matrix.InteractiveAuth;
import React from 'react';
import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/login/InteractiveAuthEntryComponents';
export default React.createClass({
displayName: 'InteractiveAuth',
propTypes: {
// matrix client to use for UI auth requests
matrixClient: PropTypes.object.isRequired,
// response from initial request. If not supplied, will do a request on
// mount.
authData: PropTypes.shape({
flows: PropTypes.array,
params: PropTypes.object,
session: PropTypes.string,
}),
// callback
makeRequest: PropTypes.func.isRequired,
// callback called when the auth process has finished,
// successfully or unsuccessfully.
// @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
// the auth session.
// * clientSecret {string} The client secret used in auth
// sessions with the ID server.
onAuthFinished: PropTypes.func.isRequired,
// Inputs provided by the user to the auth process
// and used by various stages. As passed to js-sdk
// interactive-auth
inputs: PropTypes.object,
// As js-sdk interactive-auth
makeRegistrationUrl: PropTypes.func,
sessionId: PropTypes.string,
clientSecret: PropTypes.string,
emailSid: PropTypes.string,
// If true, poll to see if the auth flow has been completed
// out-of-band
poll: PropTypes.bool,
},
getInitialState: function() {
return {
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
submitButtonEnabled: false,
};
},
componentWillMount: function() {
this._unmounted = false;
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this._requestCallback,
inputs: this.props.inputs,
stateUpdated: this._authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
});
this._authLogic.attemptAuth().then((result) => {
const extra = {
emailSid: this._authLogic.getEmailSid(),
clientSecret: this._authLogic.getClientSecret(),
};
this.props.onAuthFinished(true, result, extra);
}).catch((error) => {
this.props.onAuthFinished(false, error);
console.error("Error during user-interactive auth:", error);
if (this._unmounted) {
return;
}
const msg = error.message || error.toString();
this.setState({
errorText: msg,
});
}).done();
this._intervalId = null;
if (this.props.poll) {
this._intervalId = setInterval(() => {
this._authLogic.poll();
}, 2000);
}
},
componentWillUnmount: function() {
this._unmounted = true;
if (this._intervalId !== null) {
clearInterval(this._intervalId);
}
},
_authStateUpdated: function(stageType, stageState) {
const oldStage = this.state.authStage;
this.setState({
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage != stageType) this._setFocus();
});
},
_requestCallback: function(auth, background) {
const makeRequestPromise = 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;
}
this.setState({
busy: false,
});
});
},
_setFocus: function() {
if (this.refs.stageComponent && this.refs.stageComponent.focus) {
this.refs.stageComponent.focus();
}
},
_submitAuthDict: function(authData) {
this._authLogic.submitAuthDict(authData);
},
_renderCurrentStage: function() {
const stage = this.state.authStage;
if (!stage) return null;
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent ref="stageComponent"
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this._authLogic.getSessionId()}
clientSecret={this._authLogic.getClientSecret()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
makeRegistrationUrl={this.props.makeRegistrationUrl}
/>
);
},
_onAuthStageFailed: function(e) {
this.props.onAuthFinished(false, e);
},
_setEmailSid: function(sid) {
this._authLogic.setEmailSid(sid);
},
render: function() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this._renderCurrentStage() }
{ error }
</div>
</div>
);
},
});

View file

@ -1,5 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
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.
@ -16,11 +18,17 @@ limitations under the License.
import * as Matrix from 'matrix-js-sdk';
import React from 'react';
import PropTypes from 'prop-types';
import KeyCode from '../../KeyCode';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import sdk from '../../index';
import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
@ -31,26 +39,43 @@ import sdk from '../../index';
*
* Components mounted below us can access the matrix client via the react context.
*/
export default React.createClass({
const LoggedInView = React.createClass({
displayName: 'LoggedInView',
propTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
page_type: React.PropTypes.string.isRequired,
onRoomIdResolved: React.PropTypes.func,
onRoomCreated: React.PropTypes.func,
onUserSettingsClose: React.PropTypes.func,
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
onUserSettingsClose: PropTypes.func,
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: PropTypes.func,
teamToken: PropTypes.string,
// and lots and lots of other stuff.
},
childContextTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient),
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient),
authCache: PropTypes.object,
},
getChildContext: function() {
return {
matrixClient: this._matrixClient,
authCache: {
auth: {},
lastUpdate: 0,
},
};
},
getInitialState: function() {
return {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
};
},
@ -58,19 +83,59 @@ export default React.createClass({
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
// _scrollStateMap is a map from room id to the scroll state returned by
// RoomView.getScrollState()
this._scrollStateMap = {};
CallMediaHandler.loadDevices();
document.addEventListener('keydown', this._onKeyDown);
this._sessionStore = sessionStore;
this._sessionStoreToken = this._sessionStore.addListener(
this._setStateFromSessionStore,
);
this._setStateFromSessionStore();
this._matrixClient.on("accountData", this.onAccountData);
},
componentWillUnmount: function() {
document.removeEventListener('keydown', this._onKeyDown);
this._matrixClient.removeListener("accountData", this.onAccountData);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
},
getScrollStateForRoom: function(roomId) {
return this._scrollStateMap[roomId];
// Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy.
//
// This is required because `LoggedInView` maintains its own state and if this state
// updates after the client peg has been made null (during logout), then it will
// attempt to re-render and the children will throw errors.
shouldComponentUpdate: function() {
return Boolean(MatrixClientPeg.get());
},
canResetTimelineInRoom: function(roomId) {
if (!this.refs.roomView) {
return true;
}
return this.refs.roomView.canResetTimeline();
},
_setStateFromSessionStore() {
this.setState({
userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()),
});
},
onAccountData: function(event) {
if (event.getType() === "im.vector.web.settings") {
this.setState({
useCompactLayout: event.getContent().useCompactLayout,
});
}
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({action: "ignore_state_changed"});
}
},
_onKeyDown: function(ev) {
@ -88,13 +153,14 @@ export default React.createClass({
}
*/
var handled = false;
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.keyCode) {
case KeyCode.UP:
case KeyCode.DOWN:
if (ev.altKey) {
var action = ev.keyCode == KeyCode.UP ?
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;
@ -103,17 +169,27 @@ export default React.createClass({
case KeyCode.PAGE_UP:
case KeyCode.PAGE_DOWN:
this._onScrollKeyPressed(ev);
handled = true;
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this._onScrollKeyPressed(ev);
handled = true;
}
break;
case KeyCode.HOME:
case KeyCode.END:
if (ev.ctrlKey) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this._onScrollKeyPressed(ev);
handled = true;
}
break;
case KeyCode.KEY_K:
if (ctrlCmdOnly) {
dis.dispatch({
action: 'focus_room_filter',
});
handled = true;
}
break;
}
if (handled) {
@ -126,104 +202,147 @@ export default React.createClass({
_onScrollKeyPressed: function(ev) {
if (this.refs.roomView) {
this.refs.roomView.handleScrollKey(ev);
} else if (this.refs.roomDirectory) {
this.refs.roomDirectory.handleScrollKey(ev);
}
},
render: function() {
var LeftPanel = sdk.getComponent('structures.LeftPanel');
var RightPanel = sdk.getComponent('structures.RightPanel');
var RoomView = sdk.getComponent('structures.RoomView');
var UserSettings = sdk.getComponent('structures.UserSettings');
var CreateRoom = sdk.getComponent('structures.CreateRoom');
var RoomDirectory = sdk.getComponent('structures.RoomDirectory');
var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
var GuestWarningBar = sdk.getComponent('globals.GuestWarningBar');
var NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const TagPanel = sdk.getComponent('structures.TagPanel');
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserSettings = sdk.getComponent('structures.UserSettings');
const CreateRoom = sdk.getComponent('structures.CreateRoom');
const RoomDirectory = sdk.getComponent('structures.RoomDirectory');
const HomePage = sdk.getComponent('structures.HomePage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
var page_element;
var right_panel = '';
let page_element;
let right_panel = '';
switch (this.props.page_type) {
case PageTypes.RoomView:
page_element = <RoomView
ref='roomView'
roomAddress={this.props.currentRoomAlias || this.props.currentRoomId}
autoJoin={this.props.autoJoin}
onRoomIdResolved={this.props.onRoomIdResolved}
eventId={this.props.initialEventId}
onRegistered={this.props.onRegistered}
thirdPartyInvite={this.props.thirdPartyInvite}
oobData={this.props.roomOobData}
highlightedEventId={this.props.highlightedEventId}
eventPixelOffset={this.props.initialEventPixelOffset}
key={this.props.currentRoomAlias || this.props.currentRoomId}
opacity={this.props.middleOpacity}
collapsedRhs={this.props.collapse_rhs}
key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapseRhs}
ConferenceHandler={this.props.ConferenceHandler}
scrollStateMap={this._scrollStateMap}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel roomId={this.props.currentRoomId} opacity={this.props.sideOpacity} />
/>;
if (!this.props.collapseRhs) {
right_panel = <RightPanel roomId={this.props.currentRoomId} disabled={this.props.rightDisabled} />;
}
break;
case PageTypes.UserSettings:
page_element = <UserSettings
onClose={this.props.onUserSettingsClose}
brand={this.props.config.brand}
collapsedRhs={this.props.collapse_rhs}
enableLabs={this.props.config.enableLabs}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>
referralBaseUrl={this.props.config.referralBaseUrl}
teamToken={this.props.teamToken}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.MyGroups:
page_element = <MyGroups />;
break;
case PageTypes.CreateRoom:
page_element = <CreateRoom
onRoomCreated={this.props.onRoomCreated}
collapsedRhs={this.props.collapse_rhs}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>
collapsedRhs={this.props.collapseRhs}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.RoomDirectory:
page_element = <RoomDirectory
collapsedRhs={this.props.collapse_rhs}
ref="roomDirectory"
config={this.props.config.roomDirectory}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>
/>;
break;
case PageTypes.HomePage:
{
// If team server config is present, pass the teamServerURL. props.teamToken
// must also be set for the team page to be displayed, otherwise the
// welcomePageUrl is used (which might be undefined).
const teamServerUrl = this.props.config.teamServerConfig ?
this.props.config.teamServerConfig.teamServerURL : null;
page_element = <HomePage
teamServerUrl={teamServerUrl}
teamToken={this.props.teamToken}
homePageUrl={this.props.config.welcomePageUrl}
/>;
}
break;
case PageTypes.UserView:
page_element = null; // deliberately null for now
right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.sideOpacity} />
right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.GroupView:
page_element = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapseRhs}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} disabled={this.props.rightDisabled} />;
break;
}
var topBar;
let topBar;
const isGuest = this.props.matrixClient.isGuest();
if (this.props.hasNewVersion) {
topBar = <NewVersionBar version={this.props.version} newVersion={this.props.newVersion}
releaseNotes={this.props.newVersionReleaseNotes}
releaseNotes={this.props.newVersionReleaseNotes}
/>;
}
else if (this.props.matrixClient.isGuest()) {
topBar = <GuestWarningBar />;
}
else if (Notifier.supportsDesktopNotifications() && !Notifier.isEnabled() && !Notifier.isToolbarHidden()) {
} else if (this.props.checkingForUpdate) {
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
} else if (this.state.userHasGeneratedPassword) {
topBar = <PasswordNagBar />;
} else if (!isGuest && Notifier.supportsDesktopNotifications() && !Notifier.isEnabled() && !Notifier.isToolbarHidden()) {
topBar = <MatrixToolbar />;
}
var bodyClasses = 'mx_MatrixChat';
let bodyClasses = 'mx_MatrixChat';
if (topBar) {
bodyClasses += ' mx_MatrixChat_toolbarShowing';
}
if (this.state.useCompactLayout) {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
return (
<div className='mx_MatrixChat_wrapper'>
{topBar}
{ topBar }
<div className={bodyClasses}>
<LeftPanel selectedRoom={this.props.currentRoomId} collapsed={this.props.collapse_lhs || false} opacity={this.props.sideOpacity}/>
{ SettingsStore.isFeatureEnabled("feature_tag_panel") ? <TagPanel /> : <div /> }
<LeftPanel
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<main className='mx_MatrixChat_middlePanel'>
{page_element}
{ page_element }
</main>
{right_panel}
{ right_panel }
</div>
</div>
);
},
});
export default LoggedInView;

File diff suppressed because it is too large Load diff

View file

@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ReactDOM = require("react-dom");
var dis = require("../../dispatcher");
var sdk = require('../../index');
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import dis from "../../dispatcher";
import sdk from '../../index';
var MatrixClientPeg = require('../../MatrixClientPeg')
const MILLIS_IN_DAY = 86400000;
import MatrixClientPeg from '../../MatrixClientPeg';
/* (almost) stateless UI component which builds the event tiles in the room timeline.
*/
@ -30,60 +32,63 @@ module.exports = React.createClass({
propTypes: {
// true to give the component a 'display: none' style.
hidden: React.PropTypes.bool,
hidden: PropTypes.bool,
// true to show a spinner at the top of the timeline to indicate
// back-pagination in progress
backPaginating: React.PropTypes.bool,
backPaginating: PropTypes.bool,
// true to show a spinner at the end of the timeline to indicate
// forward-pagination in progress
forwardPaginating: React.PropTypes.bool,
forwardPaginating: PropTypes.bool,
// the list of MatrixEvents to display
events: React.PropTypes.array.isRequired,
events: PropTypes.array.isRequired,
// ID of an event to highlight. If undefined, no event will be highlighted.
highlightedEventId: React.PropTypes.string,
highlightedEventId: PropTypes.string,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
showUrlPreview: PropTypes.bool,
// event after which we should show a read marker
readMarkerEventId: React.PropTypes.string,
readMarkerEventId: PropTypes.string,
// whether the read marker should be visible
readMarkerVisible: React.PropTypes.bool,
readMarkerVisible: PropTypes.bool,
// the userid of our user. This is used to suppress the read marker
// for pending messages.
ourUserId: React.PropTypes.string,
ourUserId: PropTypes.string,
// true to suppress the date at the start of the timeline
suppressFirstDateSeparator: React.PropTypes.bool,
suppressFirstDateSeparator: PropTypes.bool,
// whether to show read receipts
manageReadReceipts: React.PropTypes.bool,
showReadReceipts: PropTypes.bool,
// true if updates to the event list should cause the scroll panel to
// scroll down when we are at the bottom of the window. See ScrollPanel
// for more details.
stickyBottom: React.PropTypes.bool,
stickyBottom: PropTypes.bool,
// callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func,
onScroll: PropTypes.func,
// callback which is called when more content is needed.
onFillRequest: React.PropTypes.func,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
onFillRequest: PropTypes.func,
// className for the panel
className: React.PropTypes.string.isRequired,
className: PropTypes.string.isRequired,
// shape parameter to be passed to EventTiles
tileShape: React.PropTypes.string,
tileShape: PropTypes.string,
// show twelve hour timestamps
isTwelveHour: PropTypes.bool,
// show timestamps always
alwaysShowTimestamps: PropTypes.bool,
},
componentWillMount: function() {
@ -144,15 +149,15 @@ module.exports = React.createClass({
// 0: read marker is within the window
// +1: read marker is below the window
getReadMarkerPosition: function() {
var readMarker = this.refs.readMarkerNode;
var messageWrapper = this.refs.scrollPanel;
const readMarker = this.refs.readMarkerNode;
const messageWrapper = this.refs.scrollPanel;
if (!readMarker || !messageWrapper) {
return null;
}
var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
var readMarkerRect = readMarker.getBoundingClientRect();
const wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
const readMarkerRect = readMarker.getBoundingClientRect();
// the read-marker pretends to have zero height when it is actually
// two pixels high; +2 here to account for that.
@ -229,31 +234,48 @@ module.exports = React.createClass({
return !this._isMounted;
},
// TODO: Implement granular (per-room) hide options
_shouldShowEvent: function(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)
}
const EventTile = sdk.getComponent('rooms.EventTile');
if (!EventTile.haveTileForEvent(mxEv)) {
return false; // no tile = no show
}
// Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true;
return !shouldHideEvent(mxEv);
},
_getEventTiles: function() {
var EventTile = sdk.getComponent('rooms.EventTile');
var DateSeparator = sdk.getComponent('messages.DateSeparator');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
this.eventNodes = {};
var i;
let i;
// first figure out which is the last event in the list which we're
// actually going to show; this allows us to behave slightly
// differently for the last event in the list.
// differently for the last event in the list. (eg show timestamp)
//
// we also need to figure out which is the last event we show which isn't
// a local echo, to manage the read-marker.
var lastShownEventIndex = -1;
var lastShownNonLocalEchoIndex = -1;
let lastShownEvent;
let lastShownNonLocalEchoIndex = -1;
for (i = this.props.events.length-1; i >= 0; i--) {
var mxEv = this.props.events[i];
if (!EventTile.haveTileForEvent(mxEv)) {
const mxEv = this.props.events[i];
if (!this._shouldShowEvent(mxEv)) {
continue;
}
if (lastShownEventIndex < 0) {
lastShownEventIndex = i;
if (lastShownEvent === undefined) {
lastShownEvent = mxEv;
}
if (mxEv.status) {
@ -265,12 +287,12 @@ module.exports = React.createClass({
break;
}
var ret = [];
const ret = [];
var prevEvent = null; // the last event we showed
let prevEvent = null; // the last event we showed
// assume there is no read marker until proven otherwise
var readMarkerVisible = false;
let readMarkerVisible = false;
// if the readmarker has moved, cancel any active ghost.
if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
@ -279,25 +301,19 @@ module.exports = React.createClass({
this.currentGhostEventId = null;
}
var isMembershipChange = (e) =>
e.getType() === 'm.room.member'
&& ['join', 'leave'].indexOf(e.getContent().membership) !== -1
&& (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership);
const isMembershipChange = (e) => e.getType() === 'm.room.member';
for (i = 0; i < this.props.events.length; i++) {
var mxEv = this.props.events[i];
var wantTile = true;
var eventId = mxEv.getId();
const mxEv = this.props.events[i];
const eventId = mxEv.getId();
const last = (mxEv === lastShownEvent);
if (!EventTile.haveTileForEvent(mxEv)) {
wantTile = false;
}
const wantTile = this._shouldShowEvent(mxEv);
var last = (i == lastShownEventIndex);
// Wrap consecutive member events in a ListSummary
if (isMembershipChange(mxEv)) {
let ts1 = mxEv.getTs();
// Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) && wantTile) {
let readMarkerInMels = false;
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
// instead will allow new props to be provided. In turn, the shouldComponentUpdate
@ -308,47 +324,73 @@ module.exports = React.createClass({
// membership event, which will not change during forward pagination.
const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial");
if (this._wantsDateSeparator(prevEvent, ts1)) {
let dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1}/></li>;
if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
const dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1} /></li>;
ret.push(dateSeparator);
}
let summarisedEvents = [mxEv];
// If RM event is the first in the MELS, append the RM after MELS
if (mxEv.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
const summarisedEvents = [mxEv];
for (;i + 1 < this.props.events.length; i++) {
let collapsedMxEv = this.props.events[i + 1];
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 MELS put RM after MELS.
if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
continue;
}
if (!isMembershipChange(collapsedMxEv) ||
this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getTs())) {
this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) {
break;
}
// 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;
}
summarisedEvents.push(collapsedMxEv);
}
// At this point, i = the index of the last event in the summary sequence
let eventTiles = summarisedEvents.map(
(e) => {
// 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.
let ret = this._getTilesForEvent(e, e);
prevEvent = e;
return ret;
let highlightInMels = false;
// At this point, i = the index of the last event in the summary sequence
let eventTiles = summarisedEvents.map((e) => {
if (e.getId() === this.props.highlightedEventId) {
highlightInMels = true;
}
).reduce((a,b) => a.concat(b));
// 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.
return this._getTilesForEvent(e, e, e === lastShownEvent);
}).reduce((a, b) => a.concat(b));
if (eventTiles.length === 0) {
eventTiles = null;
}
ret.push(
<MemberEventListSummary
key={key}
events={summarisedEvents}
data-scroll-token={eventId}>
{eventTiles}
</MemberEventListSummary>
);
ret.push(<MemberEventListSummary key={key}
events={summarisedEvents}
onToggle={this._onWidgetLoad} // Update scroll state
startExpanded={highlightInMels}
>
{ eventTiles }
</MemberEventListSummary>);
if (readMarkerInMels) {
ret.push(this._getReadMarkerTile(visible));
}
prevEvent = mxEv;
continue;
}
@ -358,13 +400,9 @@ module.exports = React.createClass({
// replacing all of the DOM elements every time we paginate.
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last));
prevEvent = mxEv;
} else if (!mxEv.status) {
// if we aren't showing the event, put in a dummy scroll token anyway, so
// that we can scroll to the right place.
ret.push(<li key={eventId} data-scroll-token={eventId}/>);
}
var isVisibleReadMarker = false;
let isVisibleReadMarker = false;
if (eventId == this.props.readMarkerEventId) {
var visible = this.props.readMarkerVisible;
@ -383,6 +421,8 @@ module.exports = React.createClass({
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());
@ -400,13 +440,15 @@ module.exports = React.createClass({
},
_getTilesForEvent: function(prevEvent, mxEv, last) {
var EventTile = sdk.getComponent('rooms.EventTile');
var DateSeparator = sdk.getComponent('messages.DateSeparator');
var ret = [];
const EventTile = sdk.getComponent('rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = [];
// is this a continuation of the previous message?
var continuation = false;
if (prevEvent !== null && prevEvent.sender && mxEv.sender
let continuation = false;
if (prevEvent !== null
&& prevEvent.sender && mxEv.sender
&& mxEv.sender.userId === prevEvent.sender.userId
&& mxEv.getType() == prevEvent.getType()) {
continuation = true;
@ -428,35 +470,37 @@ module.exports = React.createClass({
// local echoes have a fake date, which could even be yesterday. Treat them
// as 'today' for the date separators.
var ts1 = mxEv.getTs();
let ts1 = mxEv.getTs();
let eventDate = mxEv.getDate();
if (mxEv.status) {
ts1 = new Date();
eventDate = new Date();
ts1 = eventDate.getTime();
}
// do we need a date separator since the last event?
if (this._wantsDateSeparator(prevEvent, ts1)) {
var dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>;
if (this._wantsDateSeparator(prevEvent, eventDate)) {
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
ret.push(dateSeparator);
continuation = false;
}
var eventId = mxEv.getId();
var highlight = (eventId == this.props.highlightedEventId);
const eventId = mxEv.getId();
const highlight = (eventId == this.props.highlightedEventId);
// we can't use local echoes as scroll tokens, because their event IDs change.
// Local echos have a send "status".
var scrollToken = mxEv.status ? undefined : eventId;
const scrollToken = mxEv.status ? undefined : eventId;
var readReceipts;
if (this.props.manageReadReceipts) {
let readReceipts;
if (this.props.showReadReceipts) {
readReceipts = this._getReadReceiptsForEvent(mxEv);
}
ret.push(
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-token={scrollToken}>
data-scroll-tokens={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
@ -464,20 +508,21 @@ module.exports = React.createClass({
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status}
tileShape={this.props.tileShape}
last={last} isSelectedEvent={highlight}/>
</li>
isTwelveHour={this.props.isTwelveHour}
last={last} isSelectedEvent={highlight} />
</li>,
);
return ret;
},
_wantsDateSeparator: function(prevEvent, nextEventTs) {
_wantsDateSeparator: function(prevEvent, nextEventDate) {
if (prevEvent == null) {
// first event in the panel: depends if we could back-paginate from
// here.
return !this.props.suppressFirstDateSeparator;
}
return Math.floor(prevEvent.getTs() / MILLIS_IN_DAY) !== Math.floor(nextEventTs / MILLIS_IN_DAY);
return wantsDateSeparator(prevEvent.getDate(), nextEventDate);
},
// get a list of read receipts that should be shown next to this event
@ -490,12 +535,15 @@ module.exports = React.createClass({
if (!room) {
return null;
}
let receipts = [];
const receipts = [];
room.getReceiptsForEvent(event).forEach((r) => {
if (!r.userId || r.type !== "m.read" || r.userId === myUserId) {
return; // ignore non-read receipts and receipts from self.
}
let member = room.getMember(r.userId);
if (MatrixClientPeg.get().isUserIgnored(r.userId)) {
return; // ignore ignored users
}
const member = room.getMember(r.userId);
if (!member) {
return; // ignore unknown user IDs
}
@ -511,7 +559,7 @@ module.exports = React.createClass({
},
_getReadMarkerTile: function(visible) {
var hr;
let hr;
if (visible) {
hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
@ -521,7 +569,7 @@ module.exports = React.createClass({
return (
<li key="_readupto" ref="readMarkerNode"
className="mx_RoomView_myReadMarker_container">
{hr}
{ hr }
</li>
);
},
@ -540,7 +588,7 @@ module.exports = React.createClass({
},
_getReadMarkerGhostTile: function() {
var hr = <hr className="mx_RoomView_myReadMarker"
const hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
ref={this._startAnimation}
/>;
@ -551,7 +599,7 @@ module.exports = React.createClass({
return (
<li key={"_readuptoghost_"+this.currentGhostEventId}
className="mx_RoomView_myReadMarker_container">
{hr}
{ hr }
</li>
);
},
@ -563,7 +611,7 @@ module.exports = React.createClass({
// once dynamic content in the events load, make the scrollPanel check the
// scroll offsets.
_onWidgetLoad: function() {
var scrollPanel = this.refs.scrollPanel;
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.forceUpdate();
}
@ -574,9 +622,9 @@ module.exports = React.createClass({
},
render: function() {
var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
var Spinner = sdk.getComponent("elements.Spinner");
var topSpinner, bottomSpinner;
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const Spinner = sdk.getComponent("elements.Spinner");
let topSpinner, bottomSpinner;
if (this.props.backPaginating) {
topSpinner = <li key="_topSpinner"><Spinner /></li>;
}
@ -584,20 +632,26 @@ module.exports = React.createClass({
bottomSpinner = <li key="_bottomSpinner"><Spinner /></li>;
}
var style = this.props.hidden ? { display: 'none' } : {};
style.opacity = this.props.opacity;
const style = this.props.hidden ? { display: 'none' } : {};
const className = classNames(
this.props.className,
{
"mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps,
},
);
return (
<ScrollPanel ref="scrollPanel" className={ this.props.className + " mx_fadable" }
onScroll={ this.props.onScroll }
onResize={ this.onResize }
onFillRequest={ this.props.onFillRequest }
onUnfillRequest={ this.props.onUnfillRequest }
style={ style }
stickyBottom={ this.props.stickyBottom }>
{topSpinner}
{this._getEventTiles()}
{bottomSpinner}
<ScrollPanel ref="scrollPanel" className={className}
onScroll={this.props.onScroll}
onResize={this.onResize}
onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest}
style={style}
stickyBottom={this.props.stickyBottom}>
{ topSpinner }
{ this._getEventTiles() }
{ bottomSpinner }
</ScrollPanel>
);
},

View file

@ -0,0 +1,133 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import GeminiScrollbar from 'react-gemini-scrollbar';
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({
displayName: 'MyGroups',
propTypes: {
matrixClient: PropTypes.object.isRequired,
},
getInitialState: function() {
return {
groups: null,
error: null,
};
},
componentWillMount: function() {
this._fetch();
},
_onCreateGroupClick: function() {
dis.dispatch({action: 'view_create_group'});
},
_fetch: function() {
this.props.matrixClient.getJoinedGroups().done((result) => {
this.setState({groups: result.groups, error: null});
}, (err) => {
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') {
// Indicate that the guest isn't in any groups (which should be true)
this.setState({groups: [], error: null});
return;
}
this.setState({groups: null, error: err});
});
},
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const GroupTile = sdk.getComponent("groups.GroupTile");
let content;
let contentHeader;
if (this.state.groups) {
const groupNodes = [];
this.state.groups.forEach((g) => {
groupNodes.push(<GroupTile groupId={g} />);
});
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
content = groupNodes.length > 0 ?
<GeminiScrollbar className="mx_MyGroups_joinedGroups">
{ groupNodes }
</GeminiScrollbar> :
<div className="mx_MyGroups_placeholder">
{ _t(
"You're not currently a member of any communities.",
) }
</div>;
} else if (this.state.error) {
content = <div className="mx_MyGroups_error">
{ _t('Error whilst fetching joined communities') }
</div>;
} else {
content = <Loader />;
}
return <div className="mx_MyGroups">
<SimpleRoomHeader title={_t("Communities")} icon="img/icons-groups.svg" />
<div className='mx_MyGroups_header'>
<div className="mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton>
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">
{ _t('Create a new community') }
</div>
{ _t(
'Create a community to group together users and rooms! ' +
'Build a custom homepage to mark out your space in the Matrix universe.',
) }
</div>
</div>
<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton>
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">
{ _t('Join an existing community') }
</div>
{ _t(
'To join an existing community you\'ll have to '+
'know its community identifier; this will look '+
'something like <i>+example:matrix.org</i>.',
{},
{ 'i': (sub) => <i>{ sub }</i> })
}
</div>
</div>
</div>
<div className="mx_MyGroups_content">
{ contentHeader }
{ content }
</div>
</div>;
},
}));

View file

@ -14,18 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ReactDOM = require("react-dom");
var Matrix = require("matrix-js-sdk");
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher");
const React = require('react');
const ReactDOM = require("react-dom");
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
*/
var NotificationPanel = React.createClass({
const NotificationPanel = React.createClass({
displayName: 'NotificationPanel',
propTypes: {
@ -33,11 +33,10 @@ var NotificationPanel = React.createClass({
render: function() {
// wrap a TimelinePanel with the jump-to-event bits turned off.
var TimelinePanel = sdk.getComponent("structures.TimelinePanel");
var Loader = sdk.getComponent("elements.Spinner");
var timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const Loader = sdk.getComponent("elements.Spinner");
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
return (
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
@ -45,17 +44,16 @@ var NotificationPanel = React.createClass({
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview = { false }
opacity={ this.props.opacity }
showUrlPreview = {false}
tileShape="notif"
empty={_t('You have no visible notifications')}
/>
);
}
else {
} else {
console.error("No notifTimelineSet available!");
return (
<div className="mx_NotificationPanel">
<Loader/>
<Loader />
</div>
);
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -14,78 +15,121 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var sdk = require('../../index');
var dis = require("../../dispatcher");
var WhoIsTyping = require("../../WhoIsTyping");
var MatrixClientPeg = require("../../MatrixClientPeg");
import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t } 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';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
function getUnsentMessages(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === Matrix.EventStatus.NOT_SENT;
});
};
module.exports = React.createClass({
displayName: 'RoomStatusBar',
propTypes: {
// the room this statusbar is representing.
room: React.PropTypes.object.isRequired,
// a TabComplete object
tabComplete: React.PropTypes.object.isRequired,
room: PropTypes.object.isRequired,
// the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number,
// true if there are messages in the room which had errors on send
hasUnsentMessages: React.PropTypes.bool,
numUnreadMessages: PropTypes.number,
// this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline.
atEndOfLiveTimeline: React.PropTypes.bool,
atEndOfLiveTimeline: PropTypes.bool,
// This is true when the user is alone in the room, but has also sent a message.
// Used to suggest to the user to invite someone
sentMessageAndIsAlone: PropTypes.bool,
// true if there is an active call in this room (means we show
// the 'Active Call' text in the status bar if there is nothing
// more interesting)
hasActiveCall: React.PropTypes.bool,
hasActiveCall: PropTypes.bool,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number,
// callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar
onResendAllClick: React.PropTypes.func,
onResendAllClick: PropTypes.func,
// callback for when the user clicks on the 'cancel all' button in the
// 'unsent messages' bar
onCancelAllClick: React.PropTypes.func,
onCancelAllClick: PropTypes.func,
// callback for when the user clicks on the 'invite others' button in the
// 'you are alone' bar
onInviteClick: PropTypes.func,
// callback for when the user clicks on the 'stop warning me' button in the
// 'you are alone' bar
onStopWarningClick: PropTypes.func,
// callback for when the user clicks on the 'scroll to bottom' button
onScrollToBottomClick: React.PropTypes.func,
onScrollToBottomClick: PropTypes.func,
// callback for when we do something that changes the size of the
// status bar. This is used to trigger a re-layout in the parent
// component.
onResize: React.PropTypes.func,
onResize: PropTypes.func,
// callback for when the status bar can be hidden from view, as it is
// not displaying anything
onHidden: PropTypes.func,
// callback for when the status bar is displaying something and should
// be visible
onVisible: PropTypes.func,
},
getDefaultProps: function() {
return {
whoIsTypingLimit: 3,
};
},
getInitialState: function() {
return {
syncState: MatrixClientPeg.get().getSyncState(),
whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(this.props.room),
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize();
},
componentDidUpdate: function(prevProps, prevState) {
if(this.props.onResize && this._checkForResize(prevProps, prevState)) {
this.props.onResize();
}
componentDidUpdate: function() {
this._checkSize();
},
componentWillUnmount: function() {
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
var client = MatrixClientPeg.get();
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("sync", this.onSyncStateChange);
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
}
},
@ -94,45 +138,68 @@ module.exports = React.createClass({
return;
}
this.setState({
syncState: state
syncState: state,
});
},
onRoomMemberTyping: function(ev, member) {
this.setState({
whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room),
usersTyping: WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room),
});
},
// determine if we need to call onResize
_checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar. We
// don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
var oldSize, newSize;
_onSendWithoutVerifyingClick: function() {
cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => {
cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices);
Resend.resendUnsentEvents(this.props.room);
});
},
if (prevState.syncState === "ERROR") {
oldSize = 1;
} else if (prevProps.tabCompleteEntries) {
oldSize = 0;
} else if (prevProps.hasUnsentMessages) {
oldSize = 2;
_onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room);
},
_onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room);
},
_onShowDevicesClick: function() {
cryptodevices.showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
},
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
if (room.roomId !== this.props.room.roomId) return;
this.setState({
unsentMessages: getUnsentMessages(this.props.room),
});
},
// Check whether current size is greater than 0, if yes call props.onVisible
_checkSize: function() {
if (this._getSize()) {
if (this.props.onVisible) this.props.onVisible();
} else {
oldSize = 0;
if (this.props.onHidden) this.props.onHidden();
}
},
if (this.state.syncState === "ERROR") {
newSize = 1;
} else if (this.props.tabCompleteEntries) {
newSize = 0;
} else if (this.props.hasUnsentMessages) {
newSize = 2;
} else {
newSize = 0;
// We don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize: function() {
if (this.state.syncState === "ERROR" ||
(this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall ||
this.props.sentMessageAndIsAlone
) {
return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE;
}
return newSize != oldSize;
return STATUS_BAR_HIDDEN;
},
// return suitable content for the image on the left of the status bar.
@ -143,9 +210,9 @@ module.exports = React.createClass({
if (this.props.numUnreadMessages) {
return (
<div className="mx_RoomStatusBar_scrollDownIndicator"
onClick={ this.props.onScrollToBottomClick }>
onClick={this.props.onScrollToBottomClick}>
<img src="img/newmessages.svg" width="24" height="24"
alt=""/>
alt="" />
</div>
);
}
@ -153,17 +220,18 @@ module.exports = React.createClass({
if (!this.props.atEndOfLiveTimeline) {
return (
<div className="mx_RoomStatusBar_scrollDownIndicator"
onClick={ this.props.onScrollToBottomClick }>
onClick={this.props.onScrollToBottomClick}>
<img src="img/scrolldown.svg" width="24" height="24"
alt="Scroll to bottom of page"
title="Scroll to bottom of page"/>
alt={_t("Scroll to bottom of page")}
title={_t("Scroll to bottom of page")} />
</div>
);
}
if (this.props.hasActiveCall) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<img src="img/sound-indicator.svg" width="23" height="20"/>
<TintableSvg src="img/sound-indicator.svg" width="23" height="20" />
);
}
@ -173,10 +241,8 @@ module.exports = React.createClass({
if (wantPlaceholder) {
return (
<div className="mx_RoomStatusBar_placeholderIndicator">
<span>.</span>
<span>.</span>
<span>.</span>
<div className="mx_RoomStatusBar_typingIndicatorAvatars">
{ this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit) }
</div>
);
}
@ -184,11 +250,96 @@ module.exports = React.createClass({
return null;
},
_renderTypingIndicatorAvatars: function(limit) {
let users = this.state.usersTyping;
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
users = users.slice(0, limit - 1);
}
const avatars = users.map((u) => {
return (
<MemberAvatar
key={u.userId}
member={u}
width={24}
height={24}
resizeMethod="crop"
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining" key="others">
+{ othersCount }
</span>,
);
}
return avatars;
},
_getUnsentMessageContent: function() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
let title;
let content;
const hasUDE = unsentMessages.some((m) => {
return m.error && m.error.name === "UnknownDeviceError";
});
if (hasUDE) {
title = _t("Message not sent due to unknown devices being present");
content = _t(
"<showDevicesText>Show devices</showDevicesText>, <sendAnywayText>send anyway</sendAnywayText> or <cancelText>cancel</cancelText>.",
{},
{
'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
'sendAnywayText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="sendAnyway" onClick={this._onSendWithoutVerifyingClick}>{ sub }</a>,
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
} else {
if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error
) {
title = unsentMessages[0].error.data.error;
} else {
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
}
content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
"You can also select individual messages to resend or cancel.",
{ count: unsentMessages.length },
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div>;
},
// return suitable content for the main (text) part of the status bar.
_getContent: function() {
var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
const EmojiText = sdk.getComponent('elements.EmojiText');
// no conn bar trumps unread count since you can't get unread messages
@ -198,72 +349,43 @@ module.exports = React.createClass({
if (this.state.syncState === "ERROR") {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ "/>
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title">
Connectivity to the server has been lost.
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
Sent messages will be stored until your connection has returned.
{ _t('Sent messages will be stored until your connection has returned.') }
</div>
</div>
);
}
if (this.props.tabComplete.isTabCompleting()) {
return (
<div className="mx_RoomStatusBar_tabCompleteBar">
<div className="mx_RoomStatusBar_tabCompleteWrapper">
<TabCompleteBar tabComplete={this.props.tabComplete} />
<div className="mx_RoomStatusBar_tabCompleteEol" title="->|">
<TintableSvg src="img/eol.svg" width="22" height="16"/>
Auto-complete
</div>
</div>
</div>
);
}
if (this.props.hasUnsentMessages) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ "/>
<div className="mx_RoomStatusBar_connectionLostBar_title">
Some of your messages have not been sent.
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
<a className="mx_RoomStatusBar_resend_link"
onClick={ this.props.onResendAllClick }>
Resend all
</a> or <a
className="mx_RoomStatusBar_resend_link"
onClick={ this.props.onCancelAllClick }>
cancel all
</a> now. You can also select individual messages to
resend or cancel.
</div>
</div>
);
if (this.state.unsentMessages.length > 0) {
return this._getUnsentMessageContent();
}
// unread count trumps who is typing since the unread count is only
// set when you've scrolled up
if (this.props.numUnreadMessages) {
var unreadMsgs = this.props.numUnreadMessages + " new message" +
(this.props.numUnreadMessages > 1 ? "s" : "");
// MUST use var name "count" for pluralization to kick in
const unreadMsgs = _t("%(count)s new messages", {count: this.props.numUnreadMessages});
return (
<div className="mx_RoomStatusBar_unreadMessagesBar"
onClick={ this.props.onScrollToBottomClick }>
{unreadMsgs}
onClick={this.props.onScrollToBottomClick}>
{ unreadMsgs }
</div>
);
}
var typingString = this.state.whoisTypingString;
const typingString = WhoIsTyping.whoIsTypingString(
this.state.usersTyping,
this.props.whoIsTypingLimit,
);
if (typingString) {
return (
<div className="mx_RoomStatusBar_typingBar">
<EmojiText>{typingString}</EmojiText>
<EmojiText>{ typingString }</EmojiText>
</div>
);
}
@ -271,7 +393,25 @@ module.exports = React.createClass({
if (this.props.hasActiveCall) {
return (
<div className="mx_RoomStatusBar_callBar">
<b>Active call</b>
<b>{ _t('Active call') }</b>
</div>
);
}
// If you're alone in the room, and have sent a message, suggest to invite someone
if (this.props.sentMessageAndIsAlone) {
return (
<div className="mx_RoomStatusBar_isAlone">
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
"or <nowarnText>stop warning about the empty room</nowarnText>?",
{},
{
'inviteText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
'nowarnText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
},
) }
</div>
);
}
@ -279,17 +419,16 @@ module.exports = React.createClass({
return null;
},
render: function() {
var content = this._getContent();
var indicator = this._getIndicator(this.state.whoisTypingString !== null);
const content = this._getContent();
const indicator = this._getIndicator(this.state.usersTyping.length > 0);
return (
<div className="mx_RoomStatusBar">
<div className="mx_RoomStatusBar_indicator">
{indicator}
{ indicator }
</div>
{content}
{ content }
</div>
);
},

File diff suppressed because it is too large Load diff

View file

@ -14,18 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar');
var q = require("q");
var KeyCode = require('../../KeyCode');
const React = require("react");
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
const GeminiScrollbar = require('react-gemini-scrollbar');
import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard';
var DEBUG_SCROLL = false;
const DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true;
// The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight.
const UNPAGINATION_PADDING = 1500;
const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
@ -34,7 +35,7 @@ if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
var debuglog = function() {};
}
/* This component implements an intelligent scrolling list.
@ -46,9 +47,13 @@ if (DEBUG_SCROLL) {
* It also provides a hook which allows parents to provide more list elements
* when we get close to the start or end of the list.
*
* Each child element should have a 'data-scroll-token'. This token is used to
* serialise the scroll state, and returned as the 'trackedScrollToken'
* attribute by getScrollState().
* Each child element should have a 'data-scroll-tokens'. This string of
* comma-separated tokens may contain a single token or many, where many indicates
* that the element contains elements that have scroll tokens themselves. The first
* token in 'data-scroll-tokens' is used to serialise the scroll state, and returned
* as the 'trackedScrollToken' attribute by getScrollState().
*
* IMPORTANT: INDIVIDUAL TOKENS WITHIN 'data-scroll-tokens' MUST NOT CONTAIN COMMAS.
*
* Some notes about the implementation:
*
@ -82,7 +87,7 @@ module.exports = React.createClass({
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom: React.PropTypes.bool,
stickyBottom: PropTypes.bool,
/* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom.
@ -91,7 +96,7 @@ module.exports = React.createClass({
* behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line.
*/
startAtBottom: React.PropTypes.bool,
startAtBottom: PropTypes.bool,
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
@ -106,7 +111,7 @@ module.exports = React.createClass({
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest: React.PropTypes.func,
onFillRequest: PropTypes.func,
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
@ -117,33 +122,34 @@ module.exports = React.createClass({
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest: React.PropTypes.func,
onUnfillRequest: PropTypes.func,
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll: React.PropTypes.func,
onScroll: PropTypes.func,
/* onResize: a callback which is called whenever the Gemini scroll
* panel is resized
*/
onResize: React.PropTypes.func,
onResize: PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: React.PropTypes.string,
className: PropTypes.string,
/* style: styles to add to the top-level div
*/
style: React.PropTypes.object,
style: PropTypes.object,
},
getDefaultProps: function() {
return {
stickyBottom: true,
startAtBottom: true,
onFillRequest: function(backwards) { return q(false); },
onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {},
onResize: function() {},
};
},
@ -153,7 +159,7 @@ module.exports = React.createClass({
},
componentDidMount: function() {
this.checkFillState();
this.checkScroll();
},
componentDidUpdate: function() {
@ -174,7 +180,7 @@ module.exports = React.createClass({
},
onScroll: function(ev) {
var sn = this._getScrollNode();
const sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop,
"_lastSetScroll:", this._lastSetScroll);
@ -234,7 +240,7 @@ module.exports = React.createClass({
// about whether the the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update.
isAtBottom: function() {
var sn = this._getScrollNode();
const sn = this._getScrollNode();
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
// understanding of the box model, wherein the scrollNode ends up 2
@ -277,7 +283,7 @@ module.exports = React.createClass({
// |#########| |
// `---------' -
_getExcessHeight: function(backwards) {
var sn = this._getScrollNode();
const sn = this._getScrollNode();
if (backwards) {
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else {
@ -291,7 +297,7 @@ module.exports = React.createClass({
return;
}
var sn = this._getScrollNode();
const sn = this._getScrollNode();
// if there is less than a screenful of messages above or below the
// viewport, try to get some more messages.
@ -333,33 +339,28 @@ module.exports = React.createClass({
if (excessHeight <= 0) {
return;
}
var itemlist = this.refs.itemlist;
var tiles = itemlist.children;
const tiles = this.refs.itemlist.children;
// The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null;
// Subtract clientHeights to simulate the events being unpaginated whilst counting
// the events to be unpaginated.
if (backwards) {
// Iterate forwards from start of tiles, subtracting event tile height
let i = 0;
while (i < tiles.length && excessHeight > tiles[i].clientHeight) {
excessHeight -= tiles[i].clientHeight;
if (tiles[i].dataset.scrollToken) {
markerScrollToken = tiles[i].dataset.scrollToken;
}
i++;
// Subtract heights of tiles to simulate the tiles being unpaginated until the
// excess height is less than the height of the next tile to subtract. This
// prevents excessHeight becoming negative, which could lead to future
// pagination.
//
// If backwards is true, we unpaginate (remove) tiles from the back (top).
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[backwards ? i : tiles.length - 1 - i];
// Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight;
//If removing the tile would lead to future pagination, break before setting scroll token
if (tile.clientHeight > excessHeight) {
break;
}
} else {
// Iterate backwards from end of tiles, subtracting event tile height
let i = tiles.length - 1;
while (i > 0 && excessHeight > tiles[i].clientHeight) {
excessHeight -= tiles[i].clientHeight;
if (tiles[i].dataset.scrollToken) {
markerScrollToken = tiles[i].dataset.scrollToken;
}
i--;
// The tile may not have a scroll token, so guard it
if (tile.dataset.scrollTokens) {
markerScrollToken = tile.dataset.scrollTokens.split(',')[0];
}
}
@ -378,7 +379,7 @@ module.exports = React.createClass({
// check if there is already a pending fill request. If not, set one off.
_maybeFill: function(backwards) {
var dir = backwards ? 'b' : 'f';
const dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) {
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
return;
@ -387,19 +388,12 @@ module.exports = React.createClass({
debuglog("ScrollPanel: starting "+dir+" fill");
// onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call. That
// does present the risk that we might not ever actually fire off the
// fill request, so wrap it in a try/catch.
// events) so make sure we set this before firing off the call.
this._pendingFillRequests[dir] = true;
var fillPromise;
try {
fillPromise = this.props.onFillRequest(backwards);
} catch (e) {
this._pendingFillRequests[dir] = false;
throw e;
}
q.finally(fillPromise, () => {
Promise.try(() => {
return this.props.onFillRequest(backwards);
}).finally(() => {
this._pendingFillRequests[dir] = false;
}).then((hasMoreResults) => {
if (this.unmounted) {
@ -425,7 +419,8 @@ module.exports = React.createClass({
* scroll. false if we are tracking a particular child.
*
* string trackedScrollToken: undefined if stuckAtBottom is true; if it is
* false, the data-scroll-token of the child which we are tracking.
* false, the first token in data-scroll-tokens of the child which we are
* tracking.
*
* number pixelOffset: undefined if stuckAtBottom is true; if it is false,
* the number of pixels the bottom of the tracked child is above the
@ -477,8 +472,8 @@ module.exports = React.createClass({
* mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
var scrollNode = this._getScrollNode();
var delta = mult * scrollNode.clientHeight * 0.5;
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
this._setScrollTop(scrollNode.scrollTop + delta);
this._saveScrollState();
},
@ -489,21 +484,25 @@ module.exports = React.createClass({
handleScrollKey: function(ev) {
switch (ev.keyCode) {
case KeyCode.PAGE_UP:
this.scrollRelative(-1);
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(-1);
}
break;
case KeyCode.PAGE_DOWN:
this.scrollRelative(1);
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(1);
}
break;
case KeyCode.HOME:
if (ev.ctrlKey) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToTop();
}
break;
case KeyCode.END:
if (ev.ctrlKey) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToBottom();
}
break;
@ -538,7 +537,7 @@ module.exports = React.createClass({
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: scrollToken,
pixelOffset: pixelOffset
pixelOffset: pixelOffset,
};
// ... then make it so.
@ -549,12 +548,14 @@ module.exports = React.createClass({
// given offset in the window. A helper for _restoreSavedScrollState.
_scrollToToken: function(scrollToken, pixelOffset) {
/* find the dom node with the right scrolltoken */
var node;
var messages = this.refs.itemlist.children;
for (var i = messages.length-1; i >= 0; --i) {
var m = messages[i];
if (!m.dataset.scrollToken) continue;
if (m.dataset.scrollToken == scrollToken) {
let node;
const messages = this.refs.itemlist.children;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
@ -565,53 +566,62 @@ module.exports = React.createClass({
return;
}
var scrollNode = this._getScrollNode();
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
var boundingRect = node.getBoundingClientRect();
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
const scrollNode = this._getScrollNode();
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const boundingRect = node.getBoundingClientRect();
const scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
debuglog("Scrolling to token '" + node.dataset.scrollToken + "'+" +
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
if(scrollDelta != 0) {
if (scrollDelta != 0) {
this._setScrollTop(scrollNode.scrollTop + scrollDelta);
}
},
_saveScrollState: function() {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
debuglog("Saved scroll state", this.scrollState);
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
return;
}
var itemlist = this.refs.itemlist;
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
var messages = itemlist.children;
const itemlist = this.refs.itemlist;
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const messages = itemlist.children;
let newScrollState = null;
for (var i = messages.length-1; i >= 0; --i) {
var node = messages[i];
if (!node.dataset.scrollToken) continue;
for (let i = messages.length-1; i >= 0; --i) {
const node = messages[i];
if (!node.dataset.scrollTokens) continue;
var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
}
debuglog("Saved scroll state", this.scrollState);
return;
const boundingRect = node.getBoundingClientRect();
newScrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
// If the bottom of the panel intersects the ClientRect of node, use this node
// as the scrollToken.
// If this is false for the entire for-loop, we default to the last node
// (which is why newScrollState is set on every iteration).
if (boundingRect.top < wrapperRect.bottom) {
// Use this node as the scrollToken
break;
}
}
debuglog("Unable to save scroll state: found no children in the viewport");
// This is only false if there were no nodes with `node.dataset.scrollTokens` set.
if (newScrollState) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
} else {
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
}
},
_restoreSavedScrollState: function() {
var scrollState = this.scrollState;
var scrollNode = this._getScrollNode();
const scrollState = this.scrollState;
const scrollNode = this._getScrollNode();
if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE);
@ -622,9 +632,9 @@ module.exports = React.createClass({
},
_setScrollTop: function(scrollTop) {
var scrollNode = this._getScrollNode();
const scrollNode = this._getScrollNode();
var prevScroll = scrollNode.scrollTop;
const prevScroll = scrollNode.scrollTop;
// FF ignores attempts to set scrollTop to very large numbers
scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight);
@ -640,7 +650,7 @@ module.exports = React.createClass({
this._lastSetScroll = scrollNode.scrollTop;
}
debuglog("Set scrollTop:", scrollNode.scrollTop,
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll);
},
@ -667,7 +677,7 @@ module.exports = React.createClass({
className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{this.props.children}
{ this.props.children }
</ol>
</div>
</GeminiScrollbar>

View file

@ -0,0 +1,139 @@
/*
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 React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions';
import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index';
import dis from '../../dispatcher';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
const TagPanel = React.createClass({
displayName: 'TagPanel',
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getInitialState() {
return {
orderedTags: [],
selectedTags: [],
};
},
componentWillMount: function() {
this.unmounted = false;
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) {
return;
}
this.setState({
orderedTags: TagOrderStore.getOrderedTags() || [],
selectedTags: TagOrderStore.getSelectedTags(),
});
});
// This could be done by anything with a matrix client
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
},
componentWillUnmount() {
this.unmounted = true;
this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
},
_onGroupMyMembership() {
if (this.unmounted) return;
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
},
onClick(e) {
// Ignore clicks on children
if (e.target !== e.currentTarget) return;
dis.dispatch({action: 'deselect_tags'});
},
onCreateGroupClick(ev) {
ev.stopPropagation();
dis.dispatch({action: 'view_create_group'});
},
onTagTileEndDrag(result) {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
}
// Dispatch synchronously so that the TagPanel receives an
// optimistic update from TagOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this.context.matrixClient,
result.draggableId,
result.destination.index,
), true);
},
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
key={tag}
tag={tag}
index={index}
selected={this.state.selectedTags.includes(tag)}
/>;
});
return <div className="mx_TagPanel">
<DragDropContext onDragEnd={this.onTagTileEndDrag}>
<Droppable droppableId="tag-panel-droppable">
{ (provided, snapshot) => (
<div
className="mx_TagPanel_tagTileContainer"
ref={provided.innerRef}
// react-beautiful-dnd has a bug that emits a click to the parent
// of draggables upon dropping
// https://github.com/atlassian/react-beautiful-dnd/issues/273
// so we use onMouseDown here as a workaround.
onMouseDown={this.onClick}
>
{ tags }
{ provided.placeholder }
</div>
) }
</Droppable>
</DragDropContext>
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" />
</AccessibleButton>
</div>;
},
});
export default TagPanel;

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,31 +15,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ReactDOM = require("react-dom");
var q = require("q");
import SettingsStore from "../../settings/SettingsStore";
var Matrix = require("matrix-js-sdk");
var EventTimeline = Matrix.EventTimeline;
const React = require('react');
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
import Promise from 'bluebird';
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher");
var ObjectUtils = require('../../ObjectUtils');
var Modal = require("../../Modal");
var UserActivity = require("../../UserActivity");
var KeyCode = require('../../KeyCode');
const Matrix = require("matrix-js-sdk");
const EventTimeline = Matrix.EventTimeline;
var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20;
const sdk = require('../../index');
import { _t } from '../../languageHandler';
const MatrixClientPeg = require("../../MatrixClientPeg");
const dis = require("../../dispatcher");
const ObjectUtils = require('../../ObjectUtils');
const Modal = require("../../Modal");
const UserActivity = require("../../UserActivity");
import { KeyCode } from '../../Keyboard';
var DEBUG = false;
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
const DEBUG = false;
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
var debuglog = function() {};
}
/*
@ -54,54 +59,52 @@ var TimelinePanel = React.createClass({
// representing. This may or may not have a room, depending on what it's
// a timeline representing. If it has a room, we maintain RRs etc for
// that room.
timelineSet: React.PropTypes.object.isRequired,
timelineSet: PropTypes.object.isRequired,
showReadReceipts: PropTypes.bool,
// Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts: React.PropTypes.bool,
manageReadMarkers: React.PropTypes.bool,
manageReadReceipts: PropTypes.bool,
manageReadMarkers: PropTypes.bool,
// true to give the component a 'display: none' style.
hidden: React.PropTypes.bool,
hidden: PropTypes.bool,
// ID of an event to highlight. If undefined, no event will be highlighted.
// typically this will be either 'eventId' or undefined.
highlightedEventId: React.PropTypes.string,
highlightedEventId: PropTypes.string,
// id of an event to jump to. If not given, will go to the end of the
// live timeline.
eventId: React.PropTypes.string,
eventId: PropTypes.string,
// where to position the event given by eventId, in pixels from the
// bottom of the viewport. If not given, will try to put the event
// half way down the viewport.
eventPixelOffset: React.PropTypes.number,
eventPixelOffset: PropTypes.number,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
showUrlPreview: PropTypes.bool,
// callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func,
onScroll: PropTypes.func,
// callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: React.PropTypes.func,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
onReadMarkerUpdated: PropTypes.func,
// maximum number of events to show in a timeline
timelineCap: React.PropTypes.number,
timelineCap: PropTypes.number,
// classname to use for the messagepanel
className: React.PropTypes.string,
className: PropTypes.string,
// shape property to be passed to EventTiles
tileShape: React.PropTypes.string,
tileShape: PropTypes.string,
// placeholder text to use if the timeline is empty
empty: PropTypes.string,
},
statics: {
// a map from room id to read marker event ID
roomReadMarkerMap: {},
// a map from room id to read marker event timestamp
roomReadMarkerTsMap: {},
},
@ -118,10 +121,14 @@ var TimelinePanel = React.createClass({
getInitialState: function() {
// XXX: we could track RM per TimelineSet rather than per Room.
// but for now we just do it per room for simplicity.
let initialReadMarker = null;
if (this.props.manageReadMarkers) {
var initialReadMarker =
TimelinePanel.roomReadMarkerMap[this.props.timelineSet.room.roomId]
|| this._getCurrentReadReceipt();
const readmarker = this.props.timelineSet.room.getAccountData('m.fully_read');
if (readmarker) {
initialReadMarker = readmarker.getContent().event_id;
} else {
initialReadMarker = this._getCurrentReadReceipt();
}
}
return {
@ -163,13 +170,23 @@ var TimelinePanel = React.createClass({
backPaginating: false,
forwardPaginating: false,
// cache of matrixClient.getSyncState() (but from the 'sync' event)
clientSyncState: MatrixClientPeg.get().getSyncState(),
// should the event tiles have twelve hour times
isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"),
// always show timestamps on event tiles?
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
};
},
componentWillMount: function() {
debuglog("TimelinePanel: mounting");
this.last_rr_sent_event_id = undefined;
this.lastRRSentEventId = undefined;
this.lastRMSentEventId = undefined;
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
@ -177,6 +194,9 @@ var TimelinePanel = React.createClass({
MatrixClientPeg.get().on("Room.redaction", 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("sync", this.onSync);
this._initTimeline(this.props);
},
@ -237,30 +257,35 @@ var TimelinePanel = React.createClass({
dis.unregister(this.dispatcherRef);
var client = MatrixClientPeg.get();
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("Room.timeline", this.onRoomTimeline);
client.removeListener("Room.timelineReset", this.onRoomTimelineReset);
client.removeListener("Room.redaction", 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("sync", this.onSync);
}
},
onMessageListUnfillRequest: function(backwards, scrollToken) {
let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
// If backwards, unpaginate from the back (i.e. the start of the timeline)
const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
debuglog("TimelinePanel: unpaginating events in direction", dir);
// All tiles are inserted by MessagePanel to have a scrollToken === eventId
let eventId = scrollToken;
// All tiles are inserted by MessagePanel to have a scrollToken === eventId, and
// this particular event should be the first or last to be unpaginated.
const eventId = scrollToken;
let marker = this.state.events.findIndex(
const marker = this.state.events.findIndex(
(ev) => {
return ev.getId() === eventId;
}
},
);
let count = backwards ? marker + 1 : this.state.events.length - marker;
const count = backwards ? marker + 1 : this.state.events.length - marker;
if (count > 0) {
debuglog("TimelinePanel: Unpaginating", count, "in direction", dir);
@ -277,19 +302,21 @@ var TimelinePanel = React.createClass({
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
var canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate';
var paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating';
if (!this._shouldPaginate()) return Promise.resolve(false);
const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate';
const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating';
if (!this.state[canPaginateKey]) {
debuglog("TimelinePanel: have given up", dir, "paginating this timeline");
return q(false);
return Promise.resolve(false);
}
if(!this._timelineWindow.canPaginate(dir)) {
if (!this._timelineWindow.canPaginate(dir)) {
debuglog("TimelinePanel: can't", dir, "paginate any further");
this.setState({[canPaginateKey]: false});
return q(false);
return Promise.resolve(false);
}
debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
@ -300,7 +327,7 @@ var TimelinePanel = React.createClass({
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
var newState = {
const newState = {
[paginatingKey]: false,
[canPaginateKey]: r,
events: this._getEvents(),
@ -308,23 +335,30 @@ var TimelinePanel = React.createClass({
// moving the window in this direction may mean that we can now
// paginate in the other where we previously could not.
var otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
var canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate';
const otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
const canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate';
if (!this.state[canPaginateOtherWayKey] &&
this._timelineWindow.canPaginate(otherDirection)) {
debuglog('TimelinePanel: can now', otherDirection, 'paginate again');
newState[canPaginateOtherWayKey] = true;
}
this.setState(newState);
return r;
// Don't resolve until the setState has completed: we need to let
// the component update before we consider the pagination completed,
// otherwise we'll end up paginating in all the history the js-sdk
// has in memory because we never gave the component a chance to scroll
// itself into the right place
return new Promise((resolve) => {
this.setState(newState, () => {
resolve(r);
});
});
});
},
onMessageListScroll: function () {
onMessageListScroll: function(e) {
if (this.props.onScroll) {
this.props.onScroll();
this.props.onScroll(e);
}
if (this.props.manageReadMarkers) {
@ -349,6 +383,9 @@ var TimelinePanel = React.createClass({
this.sendReadReceipt();
this.updateReadMarker();
break;
case 'ignore_state_changed':
this.forceUpdate();
break;
}
},
@ -382,15 +419,15 @@ var TimelinePanel = React.createClass({
this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).done(() => {
if (this.unmounted) { return; }
var events = this._timelineWindow.getEvents();
var lastEv = events[events.length-1];
const events = this._timelineWindow.getEvents();
const lastEv = events[events.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());
events.push(...this.props.timelineSet.room.getPendingEvents());
}
var updatedState = {events: events};
const updatedState = {events: events};
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
@ -401,14 +438,15 @@ var TimelinePanel = React.createClass({
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userCurrentlyActive.
//
var myUserId = MatrixClientPeg.get().credentials.userId;
var sender = ev.sender ? ev.sender.userId : null;
const myUserId = MatrixClientPeg.get().credentials.userId;
const sender = ev.sender ? ev.sender.userId : null;
var callback = null;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
updatedState.readMarkerVisible = true;
} else if(lastEv && this.getReadMarkerPosition() === 0) {
} else if (lastEv && 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);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastEv.getId();
@ -428,6 +466,10 @@ var TimelinePanel = React.createClass({
}
},
canResetTimeline: function() {
return this.refs.messagePanel && this.refs.messagePanel.isAtBottom();
},
onRoomRedaction: function(ev, room) {
if (this.unmounted) return;
@ -457,6 +499,37 @@ var TimelinePanel = React.createClass({
this._reloadEvents();
},
onAccountData: function(ev, room) {
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.timelineSet.room) return;
if (ev.getType() !== "m.fully_read") return;
// XXX: roomReadMarkerTsMap not updated here so it is now inconsistent. Replace
// this mechanism of determining where the RM is relative to the view-port with
// one supported by the server (the client needs more than an event ID).
this.setState({
readMarkerEventId: ev.getContent().event_id,
}, this.props.onReadMarkerUpdated);
},
onEventDecrypted: function(ev) {
// 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.
// TODO: We should restrict this to only events in our timeline,
// but possibly the event tile itself should just update when this
// happens to save us re-rendering the whole timeline.
if (ev.getRoomId() === this.props.timelineSet.room.roomId) {
this.forceUpdate();
}
},
onSync: function(state, prevState, data) {
this.setState({clientSyncState: state});
},
sendReadReceipt: function() {
if (!this.refs.messagePanel) return;
@ -464,19 +537,14 @@ var TimelinePanel = React.createClass({
// This happens on user_activity_end which is delayed, and it's
// very possible have logged out within that timeframe, so check
// we still have a client.
if (!MatrixClientPeg.get()) return;
const cli = MatrixClientPeg.get();
// if no client or client is guest don't send RR or RM
if (!cli || cli.isGuest()) return;
// if we are scrolled to the bottom, do a quick-reset of our unreadNotificationCount
// to avoid having to wait from the remote echo from the homeserver.
if (this.isAtEndOfLiveTimeline()) {
this.props.timelineSet.room.setUnreadNotificationCount('total', 0);
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
// XXX: i'm a bit surprised we don't have to emit an event or dispatch to get this picked up
}
var currentReadUpToEventId = this._getCurrentReadReceipt(true);
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
let shouldSendRR = true;
const currentRREventId = this._getCurrentReadReceipt(true);
const currentRREventIndex = this._indexForEventId(currentRREventId);
// We want to avoid sending out read receipts when we are looking at
// events in the past which are before the latest RR.
//
@ -490,27 +558,74 @@ var TimelinePanel = React.createClass({
// RRs) - but that is a bit of a niche case. It will sort itself out when
// the user eventually hits the live timeline.
//
if (currentReadUpToEventId && currentReadUpToEventIndex === null &&
if (currentRREventId && currentRREventIndex === null &&
this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
return;
shouldSendRR = false;
}
var lastReadEventIndex = this._getLastDisplayedEventIndex({
ignoreOwn: true
const lastReadEventIndex = this._getLastDisplayedEventIndex({
ignoreOwn: true,
});
if (lastReadEventIndex === null) return;
if (lastReadEventIndex === null) {
shouldSendRR = false;
}
let lastReadEvent = this.state.events[lastReadEventIndex];
shouldSendRR = shouldSendRR &&
// Only send a RR if the last read event is ahead in the timeline relative to
// the current RR event.
lastReadEventIndex > currentRREventIndex &&
// Only send a RR if the last RR set != the one we would send
this.lastRRSentEventId != lastReadEvent.getId();
var lastReadEvent = this.state.events[lastReadEventIndex];
// Only send a RM if the last RM sent != the one we would send
const shouldSendRM =
this.lastRMSentEventId != this.state.readMarkerEventId;
// we also remember the last read receipt we sent to avoid spamming the
// same one at the server repeatedly
if (lastReadEventIndex > currentReadUpToEventIndex
&& this.last_rr_sent_event_id != lastReadEvent.getId()) {
this.last_rr_sent_event_id = lastReadEvent.getId();
MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => {
if (shouldSendRR || shouldSendRM) {
if (shouldSendRR) {
this.lastRRSentEventId = lastReadEvent.getId();
} else {
lastReadEvent = null;
}
this.lastRMSentEventId = this.state.readMarkerEventId;
debuglog('TimelinePanel: Sending Read Markers for ',
this.props.timelineSet.room.roomId,
'rm', this.state.readMarkerEventId,
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
);
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
this.state.readMarkerEventId,
lastReadEvent, // Could be null, in which case no RR is sent
).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(() => {
this.lastRRSentEventId = undefined;
});
}
// it failed, so allow retries next time the user is active
this.last_rr_sent_event_id = undefined;
this.lastRRSentEventId = undefined;
this.lastRMSentEventId = undefined;
});
// do a quick-reset of our unreadNotificationCount to avoid having
// to wait from the remote echo from the homeserver.
// we only do this if we're right at the end, because we're just assuming
// that sending an RR for the latest message will set our notif counter
// to zero: it may not do this if we send an RR for somewhere before the end.
if (this.isAtEndOfLiveTimeline()) {
this.props.timelineSet.room.setUnreadNotificationCount('total', 0);
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
dis.dispatch({
action: 'on_room_read',
});
}
}
},
@ -530,7 +645,7 @@ var TimelinePanel = React.createClass({
// 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.
var lastDisplayedIndex = this._getLastDisplayedEventIndex({
const lastDisplayedIndex = this._getLastDisplayedEventIndex({
allowPartial: true,
ignoreEchoes: true,
});
@ -539,13 +654,13 @@ var TimelinePanel = React.createClass({
return;
}
var lastDisplayedEvent = this.state.events[lastDisplayedIndex];
const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
this._setReadMarker(lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs());
// the read-marker should become invisible, so that if the user scrolls
// down, they don't see it.
if(this.state.readMarkerVisible) {
if (this.state.readMarkerVisible) {
this.setState({
readMarkerVisible: false,
});
@ -560,19 +675,20 @@ var TimelinePanel = React.createClass({
// 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.
var events = this._timelineWindow.getEvents();
const events = this._timelineWindow.getEvents();
// first find where the current RM is
for (var i = 0; i < events.length; i++) {
if (events[i].getId() == this.state.readMarkerEventId)
if (events[i].getId() == this.state.readMarkerEventId) {
break;
}
}
if (i >= events.length) {
return;
}
// now think about advancing it
var myUserId = MatrixClientPeg.get().credentials.userId;
const myUserId = MatrixClientPeg.get().credentials.userId;
for (i++; i < events.length; i++) {
var ev = events[i];
if (!ev.sender || ev.sender.userId != myUserId) {
@ -617,7 +733,7 @@ var TimelinePanel = React.createClass({
//
// a quick way to figure out if we've loaded the relevant event is
// simply to check if the messagepanel knows where the read-marker is.
var ret = this.refs.messagePanel.getReadMarkerPosition();
const ret = this.refs.messagePanel.getReadMarkerPosition();
if (ret !== null) {
// The messagepanel knows where the RM is, so we must have loaded
// the relevant event.
@ -638,13 +754,13 @@ var TimelinePanel = React.createClass({
forgetReadMarker: function() {
if (!this.props.manageReadMarkers) return;
var rmId = this._getCurrentReadReceipt();
const rmId = this._getCurrentReadReceipt();
// see if we know the timestamp for the rr event
var tl = this.props.timelineSet.getTimelineForEvent(rmId);
var rmTs;
const tl = this.props.timelineSet.getTimelineForEvent(rmId);
let rmTs;
if (tl) {
var event = tl.getEvents().find((e) => { return e.getId() == rmId });
const event = tl.getEvents().find((e) => { return e.getId() == rmId; });
if (event) {
rmTs = event.getTs();
}
@ -684,14 +800,14 @@ var TimelinePanel = React.createClass({
if (!this.props.manageReadMarkers) return null;
if (!this.refs.messagePanel) return null;
var ret = this.refs.messagePanel.getReadMarkerPosition();
const ret = this.refs.messagePanel.getReadMarkerPosition();
if (ret !== null) {
return ret;
}
// the messagePanel doesn't know where the read marker is.
// if we know the timestamp of the read marker, make a guess based on that.
var rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.timelineSet.roomId];
const rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.timelineSet.room.roomId];
if (rmTs && this.state.events.length > 0) {
if (rmTs < this.state.events[0].getTs()) {
return -1;
@ -703,6 +819,19 @@ var TimelinePanel = React.createClass({
return null;
},
canJumpToReadMarker: function() {
// 1. Do not show jump bar if neither the RM nor the RR are set.
// 2. Only show jump bar if RR !== RM. If they are the same, there are only fully
// read messages and unread messages. We already have a badge count and the bottom
// bar to jump to "live" when we have unread messages.
// 3. We want to show the bar if the read-marker is off the top of the screen.
// 4. Also, if pos === null, the event might not be paginated - show the unread bar
const pos = this.getReadMarkerPosition();
return this.state.readMarkerEventId !== null && // 1.
this.state.readMarkerEventId !== this._getCurrentReadReceipt() && // 2.
(pos < 0 || pos === null); // 3., 4.
},
/**
* called by the parent component when PageUp/Down/etc is pressed.
*
@ -713,7 +842,8 @@ var TimelinePanel = React.createClass({
// jump to the live timeline on ctrl-end, rather than the end of the
// timeline window.
if (ev.ctrlKey && ev.keyCode == KeyCode.END) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey &&
ev.keyCode == KeyCode.END) {
this.jumpToLiveTimeline();
} else {
this.refs.messagePanel.handleScrollKey(ev);
@ -721,12 +851,12 @@ var TimelinePanel = React.createClass({
},
_initTimeline: function(props) {
var initialEvent = props.eventId;
var pixelOffset = props.eventPixelOffset;
const initialEvent = props.eventId;
const pixelOffset = props.eventPixelOffset;
// if a pixelOffset is given, it is relative to the bottom of the
// container. If not, put the event in the middle of the container.
var offsetBase = 1;
let offsetBase = 1;
if (pixelOffset == null) {
offsetBase = 0.5;
}
@ -755,7 +885,7 @@ var TimelinePanel = React.createClass({
MatrixClientPeg.get(), this.props.timelineSet,
{windowLimit: this.props.timelineCap});
var onLoaded = () => {
const onLoaded = () => {
this._reloadEvents();
// If we switched away from the room while there were pending
@ -790,12 +920,15 @@ var TimelinePanel = React.createClass({
});
};
var onError = (error) => {
const onError = (error) => {
this.setState({timelineLoading: false});
var msg = error.message ? error.message : JSON.stringify(error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error(
`Error loading timeline panel at ${eventId}: ${error}`,
);
const msg = error.message ? error.message : JSON.stringify(error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var onFinished;
let onFinished;
// if we were given an event ID, then when the user closes the
// dialog, let's jump to the end of the timeline. If we weren't,
@ -806,24 +939,21 @@ var TimelinePanel = React.createClass({
// go via the dispatcher so that the URL is updated
dis.dispatch({
action: 'view_room',
room_id: this.props.timelineSet.roomId,
room_id: this.props.timelineSet.room.roomId,
});
};
}
var message = "Riot was trying to load a specific point in this room's timeline but ";
if (error.errcode == 'M_FORBIDDEN') {
message += "you do not have permission to view the message in question.";
} else {
message += "was unable to find it.";
}
Modal.createDialog(ErrorDialog, {
title: "Failed to load timeline position",
const message = (error.errcode == 'M_FORBIDDEN')
? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.")
: _t("Tried to load a specific point in this room's timeline, but was unable to find it.");
Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, {
title: _t("Failed to load timeline position"),
description: message,
onFinished: onFinished,
});
}
};
var prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
let prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
// if we already have the event in question, TimelineWindow.load
// returns a resolved promise.
@ -843,7 +973,7 @@ var TimelinePanel = React.createClass({
timelineLoading: true,
});
prom = prom.then(onLoaded, onError)
prom = prom.then(onLoaded, onError);
}
prom.done();
@ -864,18 +994,18 @@ var TimelinePanel = React.createClass({
// get the list of events from the timeline window and the pending event list
_getEvents: function() {
var events = this._timelineWindow.getEvents();
const events = this._timelineWindow.getEvents();
// 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());
events.push(...this.props.timelineSet.getPendingEvents());
}
return events;
},
_indexForEventId: function(evId) {
for (var i = 0; i < this.state.events.length; ++i) {
for (let i = 0; i < this.state.events.length; ++i) {
if (evId == this.state.events[i].getId()) {
return i;
}
@ -885,18 +1015,18 @@ var TimelinePanel = React.createClass({
_getLastDisplayedEventIndex: function(opts) {
opts = opts || {};
var ignoreOwn = opts.ignoreOwn || false;
var ignoreEchoes = opts.ignoreEchoes || false;
var allowPartial = opts.allowPartial || false;
const ignoreOwn = opts.ignoreOwn || false;
const ignoreEchoes = opts.ignoreEchoes || false;
const allowPartial = opts.allowPartial || false;
var messagePanel = this.refs.messagePanel;
const messagePanel = this.refs.messagePanel;
if (messagePanel === undefined) return null;
var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
var myUserId = MatrixClientPeg.get().credentials.userId;
const wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
const myUserId = MatrixClientPeg.get().credentials.userId;
for (var i = this.state.events.length-1; i >= 0; --i) {
var ev = this.state.events[i];
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;
@ -907,10 +1037,10 @@ var TimelinePanel = React.createClass({
continue;
}
var node = messagePanel.getNodeForEventId(ev.getId());
const node = messagePanel.getNodeForEventId(ev.getId());
if (!node) continue;
var boundingRect = node.getBoundingClientRect();
const boundingRect = node.getBoundingClientRect();
if ((allowPartial && boundingRect.top < wrapperRect.bottom) ||
(!allowPartial && boundingRect.bottom < wrapperRect.bottom)) {
return i;
@ -928,28 +1058,25 @@ var TimelinePanel = React.createClass({
* SDK.
*/
_getCurrentReadReceipt: function(ignoreSynthesized) {
var client = MatrixClientPeg.get();
const client = MatrixClientPeg.get();
// the client can be null on logout
if (client == null)
if (client == null) {
return null;
}
var myUserId = client.credentials.userId;
const myUserId = client.credentials.userId;
return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized);
},
_setReadMarker: function(eventId, eventTs, inhibitSetState) {
var roomId = this.props.timelineSet.room.roomId;
const roomId = this.props.timelineSet.room.roomId;
if (TimelinePanel.roomReadMarkerMap[roomId] == eventId) {
// don't update the state (and cause a re-render) if there is
// no change to the RM.
// don't update the state (and cause a re-render) if there is
// no change to the RM.
if (eventId === this.state.readMarkerEventId) {
return;
}
// ideally we'd sync these via the server, but for now just stash them
// in a map.
TimelinePanel.roomReadMarkerMap[roomId] = eventId;
// in order to later figure out if the read marker is
// above or below the visible timeline, we stash the timestamp.
TimelinePanel.roomReadMarkerTsMap[roomId] = eventTs;
@ -958,6 +1085,7 @@ var TimelinePanel = React.createClass({
return;
}
// Do the local echo of the RM
// run the render cycle before calling the callback, so that
// getReadMarkerPosition() returns the right thing.
this.setState({
@ -965,9 +1093,20 @@ var TimelinePanel = React.createClass({
}, this.props.onReadMarkerUpdated);
},
_shouldPaginate: function() {
// don't try to paginate while events in the timeline are
// still being decrypted. We don't render events while they're
// being decrypted, so they don't take up space in the timeline.
// This means we can pull quite a lot of events into the timeline
// and end up trying to render a lot of events.
return !this.state.events.some((e) => {
return e.isBeingDecrypted();
});
},
render: function() {
var MessagePanel = sdk.getComponent("structures.MessagePanel");
var Loader = sdk.getComponent("elements.Spinner");
const MessagePanel = sdk.getComponent("structures.MessagePanel");
const Loader = sdk.getComponent("elements.Spinner");
// just show a spinner while the timeline loads.
//
@ -982,12 +1121,20 @@ var TimelinePanel = React.createClass({
// exist.
if (this.state.timelineLoading) {
return (
<div className={ this.props.className + " mx_RoomView_messageListWrapper" }>
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
<Loader />
</div>
);
}
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>
);
}
// give the messagepanel a stickybottom if we're at the end of the
// live timeline, so that the arrival of new events triggers a
// scroll.
@ -996,28 +1143,34 @@ var TimelinePanel = React.createClass({
// forwards, otherwise if somebody hits the bottom of the loaded
// events when viewing historical messages, we get stuck in a loop
// of paginating our way through the entire history of the room.
var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
const stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
// If the state is PREPARED, we're still waiting for the js-sdk to sync with
// the HS and fetch the latest events, so we are effectively forward paginating.
const forwardPaginating = (
this.state.forwardPaginating || this.state.clientSyncState == 'PREPARED'
);
return (
<MessagePanel ref="messagePanel"
hidden={ this.props.hidden }
backPaginating={ this.state.backPaginating }
forwardPaginating={ this.state.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 }
manageReadReceipts = { this.props.manageReadReceipts }
ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
onUnfillRequest={ this.onMessageListUnfillRequest }
opacity={ this.props.opacity }
className={ this.props.className }
tileShape={ this.props.tileShape }
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}
/>
);
},

View file

@ -14,23 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ContentMessages = require('../../ContentMessages');
var dis = require('../../dispatcher');
var filesize = require('filesize');
const React = require('react');
import PropTypes from 'prop-types';
const ContentMessages = require('../../ContentMessages');
const dis = require('../../dispatcher');
const filesize = require('filesize');
import { _t } from '../../languageHandler';
module.exports = React.createClass({displayName: 'UploadBar',
propTypes: {
room: React.PropTypes.object
room: PropTypes.object,
},
componentDidMount: function() {
dis.register(this.onAction);
this.dispatcherRef = dis.register(this.onAction);
this.mounted = true;
},
componentWillUnmount: function() {
this.mounted = false;
dis.unregister(this.dispatcherRef);
},
onAction: function(payload) {
@ -44,7 +47,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
},
render: function() {
var uploads = ContentMessages.getCurrentUploads();
const uploads = ContentMessages.getCurrentUploads();
// for testing UI... - also fix up the ContentMessages.getCurrentUploads().length
// check in RoomView
@ -57,48 +60,46 @@ module.exports = React.createClass({displayName: 'UploadBar',
// }];
if (uploads.length == 0) {
return <div />
return <div />;
}
var upload;
for (var i = 0; i < uploads.length; ++i) {
let upload;
for (let i = 0; i < uploads.length; ++i) {
if (uploads[i].roomId == this.props.room.roomId) {
upload = uploads[i];
break;
}
}
if (!upload) {
return <div />
return <div />;
}
var innerProgressStyle = {
width: ((upload.loaded / (upload.total || 1)) * 100) + '%'
const innerProgressStyle = {
width: ((upload.loaded / (upload.total || 1)) * 100) + '%',
};
var uploadedSize = filesize(upload.loaded);
var totalSize = filesize(upload.total);
if (uploadedSize.replace(/^.* /,'') === totalSize.replace(/^.* /,'')) {
let uploadedSize = filesize(upload.loaded);
const totalSize = filesize(upload.total);
if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) {
uploadedSize = uploadedSize.replace(/ .*/, '');
}
var others;
if (uploads.length > 1) {
others = ' and ' + (uploads.length - 1) + ' other' + (uploads.length > 2 ? 's' : '');
}
// MUST use var name 'count' for pluralization to kick in
const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)});
return (
<div className="mx_UploadBar">
<div className="mx_UploadBar_uploadProgressOuter">
<div className="mx_UploadBar_uploadProgressInner" style={innerProgressStyle}></div>
</div>
<img className="mx_UploadBar_uploadIcon" src="img/fileicon.png" width="17" height="22"/>
<img className="mx_UploadBar_uploadCancel" src="img/cancel.svg" width="18" height="18"
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src="img/fileicon.png" width="17" height="22" />
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src="img/cancel.svg" width="18" height="18"
onClick={function() { ContentMessages.cancelUpload(upload.promise); }}
/>
<div className="mx_UploadBar_uploadBytes">
{ uploadedSize } / { totalSize }
</div>
<div className="mx_UploadBar_uploadFilename">Uploading {upload.fileName}{others}</div>
<div className="mx_UploadBar_uploadFilename">{ uploadText }</div>
</div>
);
}
},
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -16,49 +17,51 @@ limitations under the License.
'use strict';
var React = require('react');
var sdk = require('../../../index');
var Modal = require("../../../Modal");
var MatrixClientPeg = require('../../../MatrixClientPeg');
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
var PasswordReset = require("../../../PasswordReset");
import PasswordReset from "../../../PasswordReset";
module.exports = React.createClass({
displayName: 'ForgotPassword',
propTypes: {
defaultHsUrl: React.PropTypes.string,
defaultIsUrl: React.PropTypes.string,
customHsUrl: React.PropTypes.string,
customIsUrl: React.PropTypes.string,
onLoginClick: React.PropTypes.func,
onRegisterClick: React.PropTypes.func,
onComplete: React.PropTypes.func.isRequired
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
onLoginClick: PropTypes.func,
onRegisterClick: PropTypes.func,
onComplete: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
progress: null
progress: null,
};
},
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
this.setState({
progress: "sending_email"
progress: "sending_email",
});
this.reset = new PasswordReset(hsUrl, identityUrl);
this.reset.resetPassword(email, password).done(() => {
this.setState({
progress: "sent_email"
progress: "sent_email",
});
}, (err) => {
this.showErrorDialog("Failed to send email: " + err.message);
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
progress: null
progress: null,
});
})
});
},
onVerify: function(ev) {
@ -71,93 +74,134 @@ module.exports = React.createClass({
this.setState({ progress: "complete" });
}, (err) => {
this.showErrorDialog(err.message);
})
});
},
onSubmitForm: function(ev) {
ev.preventDefault();
if (!this.state.email) {
this.showErrorDialog("The email address linked to your account must be entered.");
}
else if (!this.state.password || !this.state.password2) {
this.showErrorDialog("A new password must be entered.");
}
else if (this.state.password !== this.state.password2) {
this.showErrorDialog("New passwords must match each other.");
}
else {
this.submitPasswordReset(
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
this.state.email, this.state.password
);
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.password || !this.state.password2) {
this.showErrorDialog(_t('A new password must be entered.'));
} else if (this.state.password !== this.state.password2) {
this.showErrorDialog(_t('New passwords must match each other.'));
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
'Resetting password will currently reset any ' +
'end-to-end encryption keys on all devices, ' +
'making encrypted chat history unreadable, ' +
'unless you first export your room keys and re-import ' +
'them afterwards. In future this will be improved.',
) }
</div>,
button: _t('Continue'),
extraButtons: [
<button className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t('Export E2E room keys') }
</button>,
],
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
this.state.email, this.state.password,
);
}
},
});
}
},
_onExportE2eKeysClicked: function() {
Modal.createTrackedDialogAsync('Export E2E Keys', 'Forgot Password', (cb) => {
require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => {
cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog'));
}, "e2e-export");
}, {
matrixClient: MatrixClientPeg.get(),
});
},
onInputChanged: function(stateKey, ev) {
this.setState({
[stateKey]: ev.target.value
[stateKey]: ev.target.value,
});
},
onHsUrlChanged: function(newHsUrl) {
this.setState({
enteredHomeserverUrl: newHsUrl
});
},
onIsUrlChanged: function(newIsUrl) {
this.setState({
enteredIdentityServerUrl: newIsUrl
});
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.enteredHomeserverUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl;
}
this.setState(newState);
},
showErrorDialog: function(body, title) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
title: title,
description: body
description: body,
});
},
render: function() {
var LoginHeader = sdk.getComponent("login.LoginHeader");
var LoginFooter = sdk.getComponent("login.LoginFooter");
var ServerConfig = sdk.getComponent("login.ServerConfig");
var Spinner = sdk.getComponent("elements.Spinner");
const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig");
const Spinner = sdk.getComponent("elements.Spinner");
var resetPasswordJsx;
let resetPasswordJsx;
if (this.state.progress === "sending_email") {
resetPasswordJsx = <Spinner />
}
else if (this.state.progress === "sent_email") {
resetPasswordJsx = <Spinner />;
} else if (this.state.progress === "sent_email") {
resetPasswordJsx = (
<div>
An email has been sent to {this.state.email}. Once you&#39;ve followed
the link it contains, click below.
<div className="mx_Login_prompt">
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", { emailAddress: this.state.email }) }
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
value="I have verified my email address" />
value={_t('I have verified my email address')} />
</div>
);
}
else if (this.state.progress === "complete") {
} else if (this.state.progress === "complete") {
resetPasswordJsx = (
<div>
<p>Your password has been reset.</p>
<p>You have been logged out of all devices and will no longer receive push notifications.
To re-enable notifications, sign in again on each device.</p>
<div className="mx_Login_prompt">
<p>{ _t('Your password has been reset') }.</p>
<p>{ _t('You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device') }.</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value="Return to login screen" />
value={_t('Return to login screen')} />
</div>
);
}
else {
} else {
let serverConfigSection;
if (!config.disable_custom_urls) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={0} />
);
}
resetPasswordJsx = (
<div>
<div className="mx_Login_prompt">
To reset your password, enter the email address linked to your account:
{ _t('To reset your password, enter the email address linked to your account') }:
</div>
<div>
<form onSubmit={this.onSubmitForm}>
@ -165,38 +209,28 @@ module.exports = React.createClass({
name="reset_email" // define a name so browser's password autofill gets less confused
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
placeholder="Email address" autoFocus />
placeholder={_t('Email address')} autoFocus />
<br />
<input className="mx_Login_field" ref="pass" type="password"
name="reset_password"
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
placeholder="New password" />
placeholder={_t('New password')} />
<br />
<input className="mx_Login_field" ref="pass" type="password"
name="reset_password_confirm"
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
placeholder="Confirm your new password" />
placeholder={_t('Confirm your new password')} />
<br />
<input className="mx_Login_submit" type="submit" value="Send Reset Email" />
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
</form>
<ServerConfig ref="serverConfig"
withToggleButton={true}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
delayTimeMs={0}/>
<div className="mx_Login_error">
</div>
{ serverConfigSection }
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
Return to login
{ _t('Return to login screen') }
</a>
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
Create a new account
{ _t('Create an account') }
</a>
<LoginFooter />
</div>
@ -206,12 +240,12 @@ module.exports = React.createClass({
return (
<div className="mx_Login">
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
{resetPasswordJsx}
{ resetPasswordJsx }
</div>
</div>
</LoginPage>
);
}
},
});

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,78 +17,139 @@ limitations under the License.
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
var sdk = require('../../../index');
var Signup = require("../../../Signup");
var PasswordLogin = require("../../views/login/PasswordLogin");
var CasLogin = require("../../views/login/CasLogin");
var ServerConfig = require("../../views/login/ServerConfig");
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
import PlatformPeg from '../../../PlatformPeg';
import SdkConfig from '../../../SdkConfig';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/;
/**
* A wire component which glues together login UI components and Signup logic
* A wire component which glues together login UI components and Login logic
*/
module.exports = React.createClass({
displayName: 'Login',
propTypes: {
onLoggedIn: React.PropTypes.func.isRequired,
onLoggedIn: PropTypes.func.isRequired,
enableGuest: React.PropTypes.bool,
enableGuest: PropTypes.bool,
customHsUrl: React.PropTypes.string,
customIsUrl: React.PropTypes.string,
defaultHsUrl: React.PropTypes.string,
defaultIsUrl: React.PropTypes.string,
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 home server without confusing users.
fallbackHsUrl: React.PropTypes.string,
fallbackHsUrl: PropTypes.string,
defaultDeviceDisplayName: React.PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration is done.
onRegisterClick: React.PropTypes.func.isRequired,
onRegisterClick: PropTypes.func.isRequired,
// login shouldn't care how password recovery is done.
onForgotPasswordClick: React.PropTypes.func,
onCancelClick: React.PropTypes.func,
initialErrorText: React.PropTypes.string,
onForgotPasswordClick: PropTypes.func,
onCancelClick: PropTypes.func,
},
getInitialState: function() {
return {
busy: false,
errorText: this.props.initialErrorText,
errorText: null,
loginIncorrect: false,
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
// used for preserving username when changing homeserver
// used for preserving form values when changing homeserver
username: "",
phoneCountry: null,
phoneNumber: "",
currentFlow: "m.login.password",
};
},
componentWillMount: function() {
this._unmounted = false;
// map from login step type to a function which will render a control
// letting you do that login type
this._stepRendererMap = {
'm.login.password': this._renderPasswordStep,
'm.login.cas': this._renderCasStep,
};
this._initLoginLogic();
},
onPasswordLogin: function(username, password) {
var self = this;
self.setState({
componentWillUnmount: function() {
this._unmounted = true;
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
this.setState({
busy: true,
errorText: null,
loginIncorrect: false,
});
this._loginLogic.loginViaPassword(username, password).then(function(data) {
self.props.onLoggedIn(data);
}, function(error) {
self._setStateFromError(error, true);
}).finally(function() {
self.setState({
busy: false
this._loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.props.onLoggedIn(data);
}, (error) => {
if (this._unmounted) {
return;
}
let errorText;
// Some error strings only apply for logging in
const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus == 400 && usingEmail) {
errorText = _t('This Home Server does not support login using email address.');
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
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?:\/\//, ''),
})
}
</div>
</div>
);
} else {
errorText = _t('Incorrect username and/or password.');
}
} else {
// other errors, not specific to doing a password login
errorText = this._errorTextFromError(error);
}
this.setState({
errorText: errorText,
// 401 would be the sensible status code for 'incorrect password'
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
// mentions this (although the bug is for UI auth which is not this)
// We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403,
});
}).finally(() => {
if (this._unmounted) {
return;
}
this.setState({
busy: false,
});
}).done();
},
@ -97,7 +159,7 @@ module.exports = React.createClass({
},
_onLoginAsGuestClick: function() {
var self = this;
const self = this;
self.setState({
busy: true,
errorText: null,
@ -107,10 +169,19 @@ module.exports = React.createClass({
this._loginLogic.loginAsGuest().then(function(data) {
self.props.onLoggedIn(data);
}, function(error) {
self._setStateFromError(error, true);
let errorText;
if (error.httpStatus === 403) {
errorText = _t("Guest access is disabled on this Home Server.");
} else {
errorText = self._errorTextFromError(error);
}
self.setState({
errorText: errorText,
loginIncorrect: false,
});
}).finally(function() {
self.setState({
busy: false
busy: false,
});
}).done();
},
@ -119,98 +190,137 @@ module.exports = React.createClass({
this.setState({ username: username });
},
onHsUrlChanged: function(newHsUrl) {
var self = this;
onPhoneCountryChanged: function(phoneCountry) {
this.setState({ phoneCountry: phoneCountry });
},
onPhoneNumberChanged: function(phoneNumber) {
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({ errorText: _t('The phone number entered looks invalid') });
return;
}
this.setState({
enteredHomeserverUrl: newHsUrl,
errorText: null, // reset err messages
}, function() {
self._initLoginLogic(newHsUrl);
phoneNumber: phoneNumber,
errorText: null,
});
},
onIsUrlChanged: function(newIsUrl) {
var self = this;
this.setState({
enteredIdentityServerUrl: newIsUrl,
onServerConfigChange: function(config) {
const self = this;
const newState = {
errorText: null, // reset err messages
}, function() {
self._initLoginLogic(null, newIsUrl);
};
if (config.hsUrl !== undefined) {
newState.enteredHomeserverUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl;
}
this.setState(newState, function() {
self._initLoginLogic(config.hsUrl || null, config.isUrl);
});
},
_initLoginLogic: function(hsUrl, isUrl) {
var self = this;
const self = this;
hsUrl = hsUrl || this.state.enteredHomeserverUrl;
isUrl = isUrl || this.state.enteredIdentityServerUrl;
var fallbackHsUrl = hsUrl == this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
const fallbackHsUrl = hsUrl == this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
var loginLogic = new Signup.Login(hsUrl, isUrl, fallbackHsUrl, {
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
this._loginLogic = loginLogic;
loginLogic.getFlows().then(function(flows) {
// old behaviour was to always use the first flow without presenting
// options. This works in most cases (we don't have a UI for multiple
// logins so let's skip that for now).
loginLogic.chooseFlow(0);
}, function(err) {
self._setStateFromError(err, false);
}).finally(function() {
self.setState({
busy: false
});
});
this.setState({
enteredHomeserverUrl: hsUrl,
enteredIdentityServerUrl: isUrl,
busy: true,
loginIncorrect: false,
});
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) {
if (!this._isSupportedFlow(flows[i])) {
continue;
}
// we just pick the first flow where we support all the
// steps. (we don't have a UI for multiple logins so let's skip
// that for now).
loginLogic.chooseFlow(i);
this.setState({
currentFlow: this._getCurrentFlowStep(),
});
return;
}
// we got to the end of the list without finding a suitable
// flow.
this.setState({
errorText: _t(
"This homeserver doesn't offer any login flows which are " +
"supported by this client.",
),
});
}, function(err) {
self.setState({
errorText: self._errorTextFromError(err),
loginIncorrect: false,
});
}).finally(function() {
self.setState({
busy: false,
});
}).done();
},
_isSupportedFlow: function(flow) {
// technically the flow can have multiple steps, but no one does this
// for login and loginLogic doesn't support it so we can ignore it.
if (!this._stepRendererMap[flow.type]) {
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false;
}
return true;
},
_getCurrentFlowStep: function() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null
},
_setStateFromError: function(err, isLoginAttempt) {
this.setState({
errorText: this._errorTextFromError(err),
// https://matrix.org/jira/browse/SYN-744
loginIncorrect: isLoginAttempt && (err.httpStatus == 401 || err.httpStatus == 403)
});
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
},
_errorTextFromError(err) {
if (err.friendlyText) {
return err.friendlyText;
}
let errCode = err.errcode;
if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus;
}
let errorText = "Error: Problem communicating with the given homeserver " +
(errCode ? "(" + errCode + ")" : "")
let errorText = _t("Error: Problem communicating with the given homeserver.") +
(errCode ? " (" + errCode + ")" : "");
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
(this.state.enteredHomeserverUrl.startsWith("http:") ||
!this.state.enteredHomeserverUrl.startsWith("http")))
{
!this.state.enteredHomeserverUrl.startsWith("http"))
) {
errorText = <span>
Can't connect to homeserver via HTTP when using Riot served by HTTPS.
Either use HTTPS or <a href='https://www.google.com/search?&q=enable%20unsafe%20scripts'>enable unsafe scripts</a>
{
_t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.",
{},
{ 'a': (sub) => { return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">{ sub }</a>; } },
) }
</span>;
}
else {
} else {
errorText = <span>
Can't connect to homeserver - please check your connectivity and ensure
your <a href={ this.state.enteredHomeserverUrl }>homeserver's SSL certificate</a> is trusted.
{
_t("Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.",
{},
{ 'a': (sub) => { return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>; } },
) }
</span>;
}
}
@ -219,86 +329,140 @@ module.exports = React.createClass({
},
componentForStep: function(step) {
switch (step) {
case 'm.login.password':
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
initialUsername={this.state.username}
onUsernameChanged={this.onUsernameChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
/>
);
case 'm.login.cas':
return (
<CasLogin onSubmit={this.onCasLogin} />
);
default:
if (!step) {
return;
}
return (
<div>
Sorry, this homeserver is using a login which is not
recognised ({step})
</div>
);
if (!step) {
return null;
}
const stepRenderer = this._stepRendererMap[step];
if (stepRenderer) {
return stepRenderer();
}
return null;
},
_renderPasswordStep: function() {
const PasswordLogin = sdk.getComponent('login.PasswordLogin');
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsUrl={this.state.enteredHomeserverUrl}
/>
);
},
_renderCasStep: function() {
const CasLogin = sdk.getComponent('login.CasLogin');
return (
<CasLogin onSubmit={this.onCasLogin} />
);
},
_onLanguageChange: function(newLang) {
if (languageHandler.getCurrentLanguage() !== newLang) {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
}
},
render: function() {
var Loader = sdk.getComponent("elements.Spinner");
var LoginHeader = sdk.getComponent("login.LoginHeader");
var LoginFooter = sdk.getComponent("login.LoginFooter");
var loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
_renderLanguageSetting: function() {
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
return <div className="mx_Login_language_div">
<LanguageDropdown onOptionChange={this._onLanguageChange}
className="mx_Login_language"
value={languageHandler.getCurrentLanguage()}
/>
</div>;
},
var loginAsGuestJsx;
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig");
const loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
let loginAsGuestJsx;
if (this.props.enableGuest) {
loginAsGuestJsx =
<a className="mx_Login_create" onClick={this._onLoginAsGuestClick} href="#">
Login as guest
</a>
{ _t('Login as guest') }
</a>;
}
var returnToAppJsx;
let returnToAppJsx;
/*
// with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) {
returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app
</a>
{ _t('Return to app') }
</a>;
}
*/
let serverConfig;
let header;
if (!SdkConfig.get().disable_custom_urls) {
serverConfig = <ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000} />;
}
// FIXME: remove status.im theme tweaks
const theme = SettingsStore.getValue("theme");
if (theme !== "status") {
header = <h2>{ _t('Sign in') }</h2>;
} else {
if (!this.state.errorText) {
header = <h2>{ _t('Sign in to get started') }</h2>;
}
}
let errorTextSection;
if (this.state.errorText) {
errorTextSection = (
<div className="mx_Login_error">
{ this.state.errorText }
</div>
);
}
return (
<div className="mx_Login">
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
<div>
<h2>Sign in
{ loader }
</h2>
{ this.componentForStep(this._getCurrentFlowStep()) }
<ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
delayTimeMs={1000}/>
<div className="mx_Login_error">
{ this.state.errorText }
</div>
{ header }
{ errorTextSection }
{ this.componentForStep(this.state.currentFlow) }
{ serverConfig }
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
Create a new account
{ _t('Create an account') }
</a>
{ loginAsGuestJsx }
{ returnToAppJsx }
{ !SdkConfig.get().disable_login_language_selector ? this._renderLanguageSetting() : '' }
<LoginFooter />
</div>
</div>
</div>
</LoginPage>
);
}
},
});

View file

@ -16,22 +16,24 @@ limitations under the License.
'use strict';
var React = require('react');
var sdk = require('../../../index');
var MatrixClientPeg = require('../../../MatrixClientPeg');
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'PostRegistration',
propTypes: {
onComplete: React.PropTypes.func.isRequired
onComplete: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
avatarUrl: null,
errorString: null,
busy: false
busy: false,
};
},
@ -39,41 +41,42 @@ module.exports = React.createClass({
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
// the URL to be passed to you (because it's also used for room avatars).
var cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.get();
this.setState({busy: true});
var self = this;
const self = this;
cli.getProfileInfo(cli.credentials.userId).done(function(result) {
self.setState({
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
busy: false
busy: false,
});
}, function(error) {
self.setState({
errorString: "Failed to fetch avatar URL",
busy: false
errorString: _t("Failed to fetch avatar URL"),
busy: false,
});
});
},
render: function() {
var ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
var LoginHeader = sdk.getComponent('login.LoginHeader');
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const LoginPage = sdk.getComponent('login.LoginPage');
const LoginHeader = sdk.getComponent('login.LoginHeader');
return (
<div className="mx_Login">
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
<div className="mx_Login_profile">
Set a display name:
{ _t('Set a display name:') }
<ChangeDisplayName />
Upload an avatar:
{ _t('Upload an avatar:') }
<ChangeAvatar
initialAvatarUrl={this.state.avatarUrl} />
<button onClick={this.props.onComplete}>Continue</button>
{this.state.errorString}
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
{ this.state.errorString }
</div>
</div>
</div>
</LoginPage>
);
}
},
});

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,52 +15,58 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import Matrix from 'matrix-js-sdk';
var React = require('react');
import Promise from 'bluebird';
import React from 'react';
import PropTypes from 'prop-types';
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
var Signup = require("../../../Signup");
var ServerConfig = require("../../views/login/ServerConfig");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var RegistrationForm = require("../../views/login/RegistrationForm");
var CaptchaForm = require("../../views/login/CaptchaForm");
import sdk from '../../../index';
import ServerConfig from '../../views/login/ServerConfig';
import MatrixClientPeg from '../../../MatrixClientPeg';
import RegistrationForm from '../../views/login/RegistrationForm';
import RtsClient from '../../../RtsClient';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
var MIN_PASSWORD_LENGTH = 6;
const MIN_PASSWORD_LENGTH = 6;
/**
* TODO: It would be nice to make use of the InteractiveAuthEntryComponents
* here, rather than inventing our own.
*/
module.exports = React.createClass({
displayName: 'Registration',
propTypes: {
onLoggedIn: React.PropTypes.func.isRequired,
clientSecret: React.PropTypes.string,
sessionId: React.PropTypes.string,
registrationUrl: React.PropTypes.string,
idSid: React.PropTypes.string,
customHsUrl: React.PropTypes.string,
customIsUrl: React.PropTypes.string,
defaultHsUrl: React.PropTypes.string,
defaultIsUrl: React.PropTypes.string,
brand: React.PropTypes.string,
email: React.PropTypes.string,
username: React.PropTypes.string,
guestAccessToken: React.PropTypes.string,
onLoggedIn: PropTypes.func.isRequired,
clientSecret: PropTypes.string,
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
brand: PropTypes.string,
email: PropTypes.string,
referrer: PropTypes.string,
teamServerConfig: PropTypes.shape({
// Email address to request new teams
supportEmail: PropTypes.string.isRequired,
// URL of the riot-team-server to get team configurations and track referrals
teamServerURL: PropTypes.string.isRequired,
}),
teamSelected: PropTypes.object,
defaultDeviceDisplayName: React.PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: React.PropTypes.func.isRequired,
onCancelClick: React.PropTypes.func
onLoginClick: PropTypes.func.isRequired,
onCancelClick: PropTypes.func,
},
getInitialState: function() {
return {
busy: false,
teamServerBusy: false,
errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
@ -71,270 +78,361 @@ module.exports = React.createClass({
formVals: {
email: this.props.email,
},
// true if we're waiting for the user to complete
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
};
},
componentWillMount: function() {
this.dispatcherRef = dis.register(this.onAction);
// attach this to the instance rather than this.state since it isn't UI
this.registerLogic = new Signup.Register(
this.props.customHsUrl, this.props.customIsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
}
);
this.registerLogic.setClientSecret(this.props.clientSecret);
this.registerLogic.setSessionId(this.props.sessionId);
this.registerLogic.setRegistrationUrl(this.props.registrationUrl);
this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
this.registerLogic.recheckState();
},
this._unmounted = false;
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
},
this._replaceClient();
componentDidMount: function() {
// may have already done an HTTP hit (e.g. redirect from an email) so
// check for any pending response
var promise = this.registerLogic.getPromise();
if (promise) {
this.onProcessingRegistration(promise);
if (
this.props.teamServerConfig &&
this.props.teamServerConfig.teamServerURL &&
!this._rtsClient
) {
this._rtsClient = this.props.rtsClient || new RtsClient(this.props.teamServerConfig.teamServerURL);
this.setState({
teamServerBusy: true,
});
// GET team configurations including domains, names and icons
this._rtsClient.getTeamsConfig().then((data) => {
const teamsConfig = {
teams: data,
supportEmail: this.props.teamServerConfig.supportEmail,
};
console.log('Setting teams config to ', teamsConfig);
this.setState({
teamsConfig: teamsConfig,
teamServerBusy: false,
});
}, (err) => {
console.error('Error retrieving config for teams', err);
this.setState({
teamServerBusy: false,
});
});
}
},
onHsUrlChanged: function(newHsUrl) {
this.registerLogic.setHomeserverUrl(newHsUrl);
},
onIsUrlChanged: function(newIsUrl) {
this.registerLogic.setIdentityServerUrl(newIsUrl);
},
onAction: function(payload) {
if (payload.action !== "registration_step_update") {
return;
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.hsUrl = config.hsUrl;
}
// If the registration state has changed, this means the
// user now needs to do something. It would be better
// to expose the explicitly in the register logic.
this.setState({
busy: false
if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl;
}
this.setState(newState, function() {
this._replaceClient();
});
},
_replaceClient: function() {
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
});
},
onFormSubmit: function(formVals) {
var self = this;
this.setState({
errorText: "",
busy: true,
formVals: formVals,
doingUIAuth: true,
});
if (formVals.username !== this.props.username) {
// don't try to upgrade if we changed our username
this.registerLogic.setGuestAccessToken(null);
}
this.onProcessingRegistration(this.registerLogic.register(formVals));
},
// Promise is resolved when the registration process is FULLY COMPLETE
onProcessingRegistration: function(promise) {
var self = this;
promise.done(function(response) {
self.setState({
busy: false
_onUIAuthFinished: function(success, response, extra) {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdn_available = false;
for (const flow of response.available_flows) {
msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1;
}
if (!msisdn_available) {
msg = _t('This server does not support authentication with a phone number.');
}
}
this.setState({
busy: false,
doingUIAuth: false,
errorText: msg,
});
if (!response || !response.access_token) {
console.warn(
"FIXME: Register fulfilled without a final response, " +
"did you break the promise chain?"
);
// no matter, we'll grab it direct
response = self.registerLogic.getCredentials();
}
if (!response || !response.user_id || !response.access_token) {
console.error("Final response is missing keys.");
self.setState({
errorText: "Registration failed on server"
return;
}
this.setState({
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
doingUIAuth: false,
});
// Done regardless of `teamSelected`. People registering with non-team emails
// will just nop. The point of this being we might not have the email address
// that the user registered with at this stage (depending on whether this
// is the client they initiated registration).
let trackPromise = Promise.resolve(null);
if (this._rtsClient && extra.emailSid) {
// Track referral if this.props.referrer set, get team_token in order to
// retrieve team config and see welcome page etc.
trackPromise = this._rtsClient.trackReferral(
this.props.referrer || '', // Default to empty string = not referred
extra.emailSid,
extra.clientSecret,
).then((data) => {
const teamToken = data.team_token;
// Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken);
this._rtsClient.getTeam(teamToken).then((team) => {
console.log(
`User successfully registered with team ${team.name}`,
);
if (!team.rooms) {
return;
}
// Auto-join rooms
team.rooms.forEach((room) => {
if (room.auto_join && room.room_id) {
console.log(`Auto-joining ${room.room_id}`);
MatrixClientPeg.get().joinRoom(room.room_id);
}
});
}, (err) => {
console.error('Error getting team config', err);
});
return;
}
self.props.onLoggedIn({
return teamToken;
}, (err) => {
console.error('Error tracking referral', err);
});
}
trackPromise.then((teamToken) => {
return this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: self.registerLogic.getHomeserverUrl(),
identityServerUrl: self.registerLogic.getIdentityServerUrl(),
accessToken: response.access_token
});
homeserverUrl: this._matrixClient.getHomeserverUrl(),
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
}, teamToken);
}).then((cli) => {
return this._setupPushers(cli);
});
},
if (self.props.brand) {
MatrixClientPeg.get().getPushers().done((resp)=>{
var pushers = resp.pushers;
for (var i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email') {
var emailPusher = pushers[i];
emailPusher.data = { brand: self.props.brand };
MatrixClientPeg.get().setPusher(emailPusher).done(() => {
console.log("Set email branding to " + self.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);
});
}
}
}, (error) => {
console.error("Couldn't get pushers: " + error);
});
_setupPushers: function(matrixClient) {
if (!this.props.brand) {
return Promise.resolve();
}
return matrixClient.getPushers().then((resp)=>{
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).done(() => {
console.log("Set email branding to " + this.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);
});
}
}
}, function(err) {
if (err.message) {
self.setState({
errorText: err.message
});
}
self.setState({
busy: false
});
console.log(err);
}, (error) => {
console.error("Couldn't get pushers: " + error);
});
},
onFormValidationFailed: function(errCode) {
var errMsg;
let errMsg;
switch (errCode) {
case "RegistrationForm.ERR_PASSWORD_MISSING":
errMsg = "Missing password.";
errMsg = _t('Missing password.');
break;
case "RegistrationForm.ERR_PASSWORD_MISMATCH":
errMsg = "Passwords don't match.";
errMsg = _t('Passwords don\'t match.');
break;
case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`;
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH: MIN_PASSWORD_LENGTH});
break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = "This doesn't look like a valid email address";
errMsg = _t('This doesn\'t look like a valid email address.');
break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = _t('This doesn\'t look like a valid phone number.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores.";
errMsg = _t('User names may only contain letters, numbers, dots, hyphens and underscores.');
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = "You need to enter a user name";
errMsg = _t('You need to enter a user name.');
break;
default:
console.error("Unknown error code: %s", errCode);
errMsg = "An unknown error occurred.";
errMsg = _t('An unknown error occurred.');
break;
}
this.setState({
errorText: errMsg
errorText: errMsg,
});
},
onCaptchaResponse: function(response) {
this.registerLogic.tellStage("m.login.recaptcha", {
response: response
});
onTeamSelected: function(teamSelected) {
if (!this._unmounted) {
this.setState({ teamSelected });
}
},
_getRegisterContentJsx: function() {
var currStep = this.registerLogic.getStep();
var registerStep;
switch (currStep) {
case "Register.COMPLETE":
break; // NOP
case "Register.START":
case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading
// a guest account
registerStep = (
<RegistrationForm
showEmail={true}
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password}
guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} />
);
break;
case "Register.STEP_m.login.email.identity":
registerStep = (
<div>
Please check your email to continue registration.
</div>
);
break;
case "Register.STEP_m.login.recaptcha":
var publicKey;
var serverParams = this.registerLogic.getServerData().params;
if (serverParams && serverParams["m.login.recaptcha"]) {
publicKey = serverParams["m.login.recaptcha"].public_key;
}
registerStep = (
<CaptchaForm sitePublicKey={publicKey}
onCaptchaResponse={this.onCaptchaResponse}
/>
);
break;
default:
console.error("Unknown register state: %s", currStep);
break;
}
var busySpinner;
if (this.state.busy) {
var Spinner = sdk.getComponent("elements.Spinner");
busySpinner = (
<Spinner />
);
}
_makeRegisterRequest: function(auth) {
// Only send the bind params 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,
} : {};
var returnToAppJsx;
if (this.props.onCancelClick) {
returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app
</a>
}
return (
<div>
<h2>Create an account</h2>
{registerStep}
<div className="mx_Login_error">{this.state.errorText}</div>
{busySpinner}
<ServerConfig ref="serverConfig"
withToggleButton={ this.registerLogic.getStep() === "Register.START" }
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
delayTimeMs={1000} />
<div className="mx_Login_error">
</div>
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
I already have an account
</a>
{ returnToAppJsx }
</div>
return this._matrixClient.register(
this.state.formVals.username,
this.state.formVals.password,
undefined, // session id: included in the auth dict already
auth,
bindThreepids,
null,
);
},
_getUIAuthInputs: function() {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
phoneNumber: this.state.formVals.phoneNumber,
};
},
render: function() {
var LoginHeader = sdk.getComponent('login.LoginHeader');
var LoginFooter = sdk.getComponent('login.LoginFooter');
const LoginHeader = sdk.getComponent('login.LoginHeader');
const LoginFooter = sdk.getComponent('login.LoginFooter');
const LoginPage = sdk.getComponent('login.LoginPage');
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent("elements.Spinner");
const ServerConfig = sdk.getComponent('views.login.ServerConfig');
const theme = SettingsStore.getValue("theme");
let registerBody;
if (this.state.doingUIAuth) {
registerBody = (
<InteractiveAuth
matrixClient={this._matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
makeRegistrationUrl={this.props.makeRegistrationUrl}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>
);
} else if (this.state.busy || this.state.teamServerBusy) {
registerBody = <Spinner />;
} else {
let serverConfigSection;
if (!SdkConfig.get().disable_custom_urls) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000}
/>
);
}
registerBody = (
<div>
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
teamsConfig={this.state.teamsConfig}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
/>
{ serverConfigSection }
</div>
);
}
let returnToAppJsx;
/*
// with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) {
returnToAppJsx = (
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
{ _t('Return to app') }
</a>
);
}
*/
let header;
let errorText;
// FIXME: remove hardcoded Status team tweaks at some point
if (theme === 'status' && this.state.errorText) {
header = <div className="mx_Login_error">{ this.state.errorText }</div>;
} else {
header = <h2>{ _t('Create an account') }</h2>;
if (this.state.errorText) {
errorText = <div className="mx_Login_error">{ this.state.errorText }</div>;
}
}
let signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
{ theme === 'status' ? _t('Sign in') : _t('I already have an account') }
</a>
);
}
return (
<div className="mx_Login">
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
{this._getRegisterContentJsx()}
<LoginHeader
icon={this.state.teamSelected ?
this.props.teamServerConfig.teamServerURL + "/static/common/" +
this.state.teamSelected.domain + "/icon.png" :
null}
/>
{ header }
{ registerBody }
{ signIn }
{ errorText }
{ returnToAppJsx }
<LoginFooter />
</div>
</div>
</LoginPage>
);
}
},
});

View file

@ -14,25 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var AvatarLogic = require("../../../Avatar");
import React from 'react';
import PropTypes from 'prop-types';
import AvatarLogic from '../../../Avatar';
import sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'BaseAvatar',
propTypes: {
name: React.PropTypes.string.isRequired, // The name (first initial used as default)
idName: React.PropTypes.string, // ID for generating hash colours
title: React.PropTypes.string, // onHover title text
url: React.PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
urls: React.PropTypes.array, // [highest_priority, ... , lowest_priority]
width: React.PropTypes.number,
height: React.PropTypes.number,
resizeMethod: React.PropTypes.string,
defaultToInitialLetter: React.PropTypes.bool // true to add default url
name: PropTypes.string.isRequired, // The name (first initial used as default)
idName: PropTypes.string, // ID for generating hash colours
title: PropTypes.string, // onHover title text
url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
urls: PropTypes.array, // [highest_priority, ... , lowest_priority]
width: PropTypes.number,
height: PropTypes.number,
// XXX resizeMethod not actually used.
resizeMethod: PropTypes.string,
defaultToInitialLetter: PropTypes.bool, // true to add default url
},
getDefaultProps: function() {
@ -40,8 +41,8 @@ module.exports = React.createClass({
width: 40,
height: 40,
resizeMethod: 'crop',
defaultToInitialLetter: true
}
defaultToInitialLetter: true,
};
},
getInitialState: function() {
@ -50,15 +51,14 @@ module.exports = React.createClass({
componentWillReceiveProps: function(nextProps) {
// work out if we need to call setState (if the image URLs array has changed)
var newState = this._getState(nextProps);
var newImageUrls = newState.imageUrls;
var oldImageUrls = this.state.imageUrls;
const newState = this._getState(nextProps);
const newImageUrls = newState.imageUrls;
const oldImageUrls = this.state.imageUrls;
if (newImageUrls.length !== oldImageUrls.length) {
this.setState(newState); // detected a new entry
}
else {
} else {
// check each one to see if they are the same
for (var i = 0; i < newImageUrls.length; i++) {
for (let i = 0; i < newImageUrls.length; i++) {
if (oldImageUrls[i] !== newImageUrls[i]) {
this.setState(newState); // detected a diff
break;
@ -71,31 +71,31 @@ module.exports = React.createClass({
// work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, props.urls, default image ]
var urls = props.urls || [];
const urls = props.urls || [];
if (props.url) {
urls.unshift(props.url); // put in urls[0]
}
var defaultImageUrl = null;
let defaultImageUrl = null;
if (props.defaultToInitialLetter) {
defaultImageUrl = AvatarLogic.defaultAvatarUrlForString(
props.idName || props.name
props.idName || props.name,
);
urls.push(defaultImageUrl); // lowest priority
}
return {
imageUrls: urls,
defaultImageUrl: defaultImageUrl,
urlsIndex: 0
urlsIndex: 0,
};
},
onError: function(ev) {
var nextIndex = this.state.urlsIndex + 1;
const nextIndex = this.state.urlsIndex + 1;
if (nextIndex < this.state.imageUrls.length) {
// try the next one
this.setState({
urlsIndex: nextIndex
urlsIndex: nextIndex,
});
}
},
@ -109,59 +109,92 @@ module.exports = React.createClass({
return undefined;
}
var idx = 0;
var initial = name[0];
if ((initial === '@' || initial === '#') && name[1]) {
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).
var chars = 1;
var first = name.charCodeAt(idx);
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
var second = name.charCodeAt(idx+1);
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
var firstChar = name.substring(idx, idx+chars);
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
},
render: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
var imageUrl = this.state.imageUrls[this.state.urlsIndex];
const imageUrl = this.state.imageUrls[this.state.urlsIndex];
const {
name, idName, title, url, urls, width, height, resizeMethod,
defaultToInitialLetter,
defaultToInitialLetter, onClick,
...otherProps
} = this.props;
if (imageUrl === this.state.defaultImageUrl) {
const initialLetter = this._getInitialLetter(name);
const textNode = (
<EmojiText className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (width * 0.65) + "px",
width: width + "px",
lineHeight: height + "px" }}
>
{ initialLetter }
</EmojiText>
);
const imgNode = (
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
width={width} height={height} />
);
if (onClick != null) {
return (
<AccessibleButton element='span' className="mx_BaseAvatar"
onClick={onClick} {...otherProps}
>
{ textNode }
{ imgNode }
</AccessibleButton>
);
} else {
return (
<span className="mx_BaseAvatar" {...otherProps}>
{ textNode }
{ imgNode }
</span>
);
}
}
if (onClick != null) {
return (
<span className="mx_BaseAvatar" {...otherProps}>
<EmojiText className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (width * 0.65) + "px",
width: width + "px",
lineHeight: height + "px" }}>{initialLetter}</EmojiText>
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
width={width} height={height} />
</span>
<AccessibleButton className="mx_BaseAvatar mx_BaseAvatar_image"
element='img'
src={imageUrl}
onClick={onClick}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
);
} else {
return (
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
);
}
return (
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
);
}
},
});

View file

@ -0,0 +1,68 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default React.createClass({
displayName: 'GroupAvatar',
propTypes: {
groupId: PropTypes.string,
groupName: PropTypes.string,
groupAvatarUrl: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
onClick: PropTypes.func,
},
getDefaultProps: function() {
return {
width: 36,
height: 36,
resizeMethod: 'crop',
};
},
getGroupAvatarUrl: function() {
return MatrixClientPeg.get().mxcUrlToHttp(
this.props.groupAvatarUrl,
this.props.width,
this.props.height,
this.props.resizeMethod,
);
},
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// extract the props we use from props so we can pass any others through
// should consider adding this as a global rule in js-sdk?
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props;
return (
<BaseAvatar
name={groupName || this.props.groupId[1]}
idName={this.props.groupId}
url={this.getGroupAvatarUrl()}
{...otherProps}
/>
);
},
});

View file

@ -16,24 +16,25 @@ limitations under the License.
'use strict';
var React = require('react');
var Avatar = require('../../../Avatar');
var sdk = require("../../../index");
const React = require('react');
import PropTypes from 'prop-types';
const Avatar = require('../../../Avatar');
const sdk = require("../../../index");
const dispatcher = require("../../../dispatcher");
module.exports = React.createClass({
displayName: 'MemberAvatar',
propTypes: {
member: React.PropTypes.object.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number,
resizeMethod: React.PropTypes.string,
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
// The onClick to give the avatar
onClick: React.PropTypes.func,
onClick: PropTypes.func,
// Whether the onClick of the avatar should be overriden to dispatch 'view_user'
viewUserOnClick: React.PropTypes.bool,
title: React.PropTypes.string,
viewUserOnClick: PropTypes.bool,
title: PropTypes.string,
},
getDefaultProps: function() {
@ -42,7 +43,7 @@ module.exports = React.createClass({
height: 40,
resizeMethod: 'crop',
viewUserOnClick: false,
}
};
},
getInitialState: function() {
@ -63,14 +64,14 @@ module.exports = React.createClass({
imageUrl: Avatar.avatarUrlForMember(props.member,
props.width,
props.height,
props.resizeMethod)
}
props.resizeMethod),
};
},
render: function() {
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
var {member, onClick, viewUserOnClick, ...otherProps} = this.props;
let {member, onClick, viewUserOnClick, ...otherProps} = this.props;
if (viewUserOnClick) {
onClick = () => {
@ -78,12 +79,12 @@ module.exports = React.createClass({
action: 'view_user',
member: this.props.member,
});
}
};
}
return (
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
idName={member.userId} url={this.state.imageUrl} onClick={onClick}/>
idName={member.userId} url={this.state.imageUrl} onClick={onClick} />
);
}
},
});

View file

@ -0,0 +1,169 @@
/*
Copyright 2017 Travis Ralston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from "react";
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import MatrixClientPeg from "../../../MatrixClientPeg";
import AccessibleButton from '../elements/AccessibleButton';
import Presence from "../../../Presence";
import dispatcher from "../../../dispatcher";
import * as ContextualMenu from "../../structures/ContextualMenu";
import SettingsStore from "../../../settings/SettingsStore";
// This is an avatar with presence information and controls on it.
module.exports = React.createClass({
displayName: 'MemberPresenceAvatar',
propTypes: {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
},
getDefaultProps: function() {
return {
width: 40,
height: 40,
resizeMethod: 'crop',
};
},
getInitialState: function() {
let presenceState = null;
let presenceMessage = null;
// RoomMembers do not necessarily have a user.
if (this.props.member.user) {
presenceState = this.props.member.user.presence;
presenceMessage = this.props.member.user.presenceStatusMsg;
}
return {
status: presenceState,
message: presenceMessage,
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("User.presence", this.onUserPresence);
this.dispatcherRef = dispatcher.register(this.onAction);
},
componentWillUnmount: function() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("User.presence", this.onUserPresence);
}
dispatcher.unregister(this.dispatcherRef);
},
onAction: function(payload) {
if (payload.action !== "self_presence_updated") return;
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) return;
this.setState({
status: payload.statusInfo.presence,
message: payload.statusInfo.status_msg,
});
},
onUserPresence: function(event, user) {
if (user.userId !== MatrixClientPeg.get().getUserId()) return;
this.setState({
status: user.presence,
message: user.presenceStatusMsg,
});
},
onStatusChange: function(newStatus) {
Presence.stopMaintainingStatus();
if (newStatus === "online") {
Presence.setState(newStatus);
} else Presence.setState(newStatus, null, true);
},
onClick: function(e) {
const PresenceContextMenu = sdk.getComponent('context_menus.PresenceContextMenu');
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3;
const chevronOffset = 12;
let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron
ContextualMenu.createMenu(PresenceContextMenu, {
chevronOffset: chevronOffset,
chevronFace: 'bottom',
left: x,
top: y,
menuWidth: 125,
currentStatus: this.state.status,
onChange: this.onStatusChange,
});
e.stopPropagation();
// XXX NB the following assumes that user is non-null, which is not valid
// const presenceState = this.props.member.user.presence;
// const presenceLastActiveAgo = this.props.member.user.lastActiveAgo;
// const presenceLastTs = this.props.member.user.lastPresenceTs;
// const presenceCurrentlyActive = this.props.member.user.currentlyActive;
// const presenceMessage = this.props.member.user.presenceStatusMsg;
},
render: function() {
const MemberAvatar = sdk.getComponent("avatars.MemberAvatar");
let onClickFn = null;
if (this.props.member.userId === MatrixClientPeg.get().getUserId()) {
onClickFn = this.onClick;
}
const avatarNode = (
<MemberAvatar member={this.props.member} width={this.props.width} height={this.props.height}
resizeMethod={this.props.resizeMethod} />
);
let statusNode = (
<span className={"mx_MemberPresenceAvatar_status mx_MemberPresenceAvatar_status_" + this.state.status} />
);
// LABS: Disable presence management functions for now
// Also disable the presence information if there's no status information
if (!SettingsStore.isFeatureEnabled("feature_presence_management") || !this.state.status) {
statusNode = null;
onClickFn = null;
}
let avatar = (
<div className="mx_MemberPresenceAvatar">
{ avatarNode }
{ statusNode }
</div>
);
if (onClickFn) {
avatar = (
<AccessibleButton onClick={onClickFn} className="mx_MemberPresenceAvatar" element="div">
{ avatarNode }
{ statusNode }
</AccessibleButton>
);
}
return avatar;
},
});

View file

@ -13,11 +13,11 @@ 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.
*/
var React = require('react');
var ContentRepo = require("matrix-js-sdk").ContentRepo;
var MatrixClientPeg = require('../../../MatrixClientPeg');
var Avatar = require('../../../Avatar');
var sdk = require("../../../index");
import React from "react";
import PropTypes from 'prop-types';
import {ContentRepo} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg";
import sdk from "../../../index";
module.exports = React.createClass({
displayName: 'RoomAvatar',
@ -26,11 +26,11 @@ module.exports = React.createClass({
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
propTypes: {
room: React.PropTypes.object,
oobData: React.PropTypes.object,
width: React.PropTypes.number,
height: React.PropTypes.number,
resizeMethod: React.PropTypes.string
room: PropTypes.object,
oobData: PropTypes.object,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
},
getDefaultProps: function() {
@ -39,19 +39,19 @@ module.exports = React.createClass({
height: 36,
resizeMethod: 'crop',
oobData: {},
}
};
},
getInitialState: function() {
return {
urls: this.getImageUrls(this.props)
urls: this.getImageUrls(this.props),
};
},
componentWillReceiveProps: function(newProps) {
this.setState({
urls: this.getImageUrls(newProps)
})
urls: this.getImageUrls(newProps),
});
},
getImageUrls: function(props) {
@ -59,34 +59,37 @@ module.exports = React.createClass({
ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
props.oobData.avatarUrl,
props.width, props.height, props.resizeMethod
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
), // highest priority
this.getRoomAvatarUrl(props),
this.getOneToOneAvatar(props),
this.getFallbackAvatar(props) // lowest priority
this.getOneToOneAvatar(props), // lowest priority
].filter(function(url) {
return (url != null && url != "");
});
},
getRoomAvatarUrl: function(props) {
if (!this.props.room) return null;
if (!props.room) return null;
return props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
false
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
},
getOneToOneAvatar: function(props) {
if (!this.props.room) return null;
if (!props.room) return null;
var mlist = props.room.currentState.members;
var userIds = [];
var leftUserIds = [];
const mlist = props.room.currentState.members;
const userIds = [];
const leftUserIds = [];
// for .. in optimisation to return early if there are >2 keys
for (var uid in mlist) {
for (const uid in mlist) {
if (mlist.hasOwnProperty(uid)) {
if (["join", "invite"].includes(mlist[uid].membership)) {
userIds.push(uid);
@ -100,7 +103,7 @@ module.exports = React.createClass({
}
if (userIds.length == 2) {
var theOtherGuy = null;
let theOtherGuy = null;
if (mlist[userIds[0]].userId == MatrixClientPeg.get().credentials.userId) {
theOtherGuy = mlist[userIds[1]];
} else {
@ -108,8 +111,10 @@ module.exports = React.createClass({
}
return theOtherGuy.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
false
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
} else if (userIds.length == 1) {
// The other 1-1 user left, leaving just the current user, so show the left user's avatar
@ -122,31 +127,27 @@ module.exports = React.createClass({
}
return mlist[userIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
false
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
} else {
return null;
}
},
getFallbackAvatar: function(props) {
if (!this.props.room) return null;
return Avatar.defaultAvatarUrlForString(props.room.roomId);
},
render: function() {
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
var {room, oobData, ...otherProps} = this.props;
const {room, oobData, ...otherProps} = this.props;
var roomName = room ? room.name : oobData.name;
const roomName = room ? room.name : oobData.name;
return (
<BaseAvatar {...otherProps} name={roomName}
idName={room ? room.roomId : null}
urls={this.state.urls} />
);
}
},
});

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