Merge branch 'develop' into luke/fix-render-1-1-avatars-when-others-leave
This commit is contained in:
commit
121b776e8a
378 changed files with 67241 additions and 11932 deletions
77
src/ActiveRoomObserver.js
Normal file
77
src/ActiveRoomObserver.js
Normal 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;
|
|
@ -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
231
src/Analytics.js
Normal 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;
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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!");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
62
src/CallMediaHandler.js
Normal 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);
|
||||
},
|
||||
};
|
84
src/ComposerHistoryManager.js
Normal file
84
src/ComposerHistoryManager.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
141
src/DateUtils.js
141
src/DateUtils.js
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
156
src/GroupAddressPicker.js
Normal 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(", "),
|
||||
});
|
||||
});
|
||||
}
|
264
src/HtmlUtils.js
264
src/HtmlUtils.js
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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
138
src/KeyRequestHandler.js
Normal 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
79
src/Keyboard.js
Normal 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;
|
||||
}
|
||||
}
|
410
src/Lifecycle.js
410
src/Lifecycle.js
|
@ -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
243
src/Login.js
Normal 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;
|
||||
}
|
||||
}
|
218
src/Markdown.js
218
src/Markdown.js
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
190
src/Modal.js
190
src/Modal.js
|
@ -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;
|
||||
|
|
197
src/Notifier.js
197
src/Notifier.js
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
163
src/RichText.js
163
src/RichText.js
|
@ -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
35
src/Roles.js
Normal 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
205
src/RoomInvite.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
59
src/Rooms.js
59
src/Rooms.js
|
@ -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
104
src/RtsClient.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
451
src/Signup.js
451
src/Signup.js
|
@ -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;
|
|
@ -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
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 '';
|
||||
},
|
||||
};
|
||||
|
|
521
src/Tinter.js
521
src/Tinter.js
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
58
src/UserAddress.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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
326
src/WidgetMessaging.js
Normal 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
58
src/WidgetUtils.js
Normal 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);
|
||||
}
|
||||
}
|
34
src/actions/GroupActions.js
Normal file
34
src/actions/GroupActions.js
Normal 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;
|
108
src/actions/MatrixActionCreators.js
Normal file
108
src/actions/MatrixActionCreators.js
Normal 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());
|
||||
},
|
||||
};
|
59
src/actions/TagOrderActions.js
Normal file
59
src/actions/TagOrderActions.js
Normal 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;
|
48
src/actions/actionCreators.js
Normal file
48
src/actions/actionCreators.js
Normal 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});
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
183
src/async-components/views/dialogs/ExportE2eKeysDialog.js
Normal file
183
src/async-components/views/dialogs/ExportE2eKeysDialog.js
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
181
src/async-components/views/dialogs/ImportE2eKeysDialog.js
Normal file
181
src/async-components/views/dialogs/ImportE2eKeysDialog.js
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
107
src/autocomplete/FuzzyMatcher.js
Normal file
107
src/autocomplete/FuzzyMatcher.js
Normal 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;
|
||||
// }
|
||||
//}
|
62
src/autocomplete/NotifProvider.js
Normal file
62
src/autocomplete/NotifProvider.js
Normal 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>;
|
||||
}
|
||||
}
|
112
src/autocomplete/QueryMatcher.js
Normal file
112
src/autocomplete/QueryMatcher.js
Normal 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];
|
||||
})));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
1129
src/components/structures/GroupView.js
Normal file
1129
src/components/structures/GroupView.js
Normal file
File diff suppressed because it is too large
Load diff
225
src/components/structures/InteractiveAuth.js
Normal file
225
src/components/structures/InteractiveAuth.js
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
133
src/components/structures/MyGroups.js
Normal file
133
src/components/structures/MyGroups.js
Normal 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>;
|
||||
},
|
||||
}));
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
|
|
139
src/components/structures/TagPanel.js
Normal file
139
src/components/structures/TagPanel.js
Normal 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;
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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 it’s 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} />
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
68
src/components/views/avatars/GroupAvatar.js
Normal file
68
src/components/views/avatars/GroupAvatar.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
169
src/components/views/avatars/MemberPresenceAvatar.js
Normal file
169
src/components/views/avatars/MemberPresenceAvatar.js
Normal 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;
|
||||
},
|
||||
});
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue