This commit is contained in:
Matthew Hodgson 2017-02-02 22:05:44 +00:00
commit be41462f3a
35 changed files with 621 additions and 275 deletions

View file

@ -53,7 +53,13 @@ module.exports = {
* things that are errors in the js-sdk config that the current * things that are errors in the js-sdk config that the current
* code does not adhere to, turned down to warn * code does not adhere to, turned down to warn
*/ */
"max-len": ["warn"], "max-len": ["warn", {
// apparently people believe the length limit shouldn't apply
// to JSX.
ignorePattern: '^\\s*<',
ignoreComments: true,
code: 90,
}],
"valid-jsdoc": ["warn"], "valid-jsdoc": ["warn"],
"new-cap": ["warn"], "new-cap": ["warn"],
"key-spacing": ["warn"], "key-spacing": ["warn"],

View file

@ -165,6 +165,14 @@ module.exports = function (config) {
}, },
devtool: 'inline-source-map', devtool: 'inline-source-map',
}, },
webpackMiddleware: {
stats: {
// don't fill the console up with a mahoosive list of modules
chunks: false,
},
},
browserNoActivityTimeout: 15000, browserNoActivityTimeout: 15000,
}); });
}; };

View file

@ -15,110 +15,143 @@ limitations under the License.
*/ */
import commonmark from 'commonmark'; import commonmark from 'commonmark';
import escape from 'lodash/escape';
const ALLOWED_HTML_TAGS = ['del'];
// 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) {
var 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 * a given message actually uses any markdown syntax or whether
* it's plain text. * it's plain text.
*/ */
export default class Markdown { export default class Markdown {
constructor(input) { constructor(input) {
this.input = input; this.input = input;
this.parser = new commonmark.Parser();
this.renderer = new commonmark.HtmlRenderer({safe: false}); const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input);
} }
isPlainText() { isPlainText() {
// we determine if the message requires markdown by const walker = this.parsed.walker();
// 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;
function setNotPlain() { let ev;
is_plain = false; 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;
}
} }
return true;
const dummy_renderer = new commonmark.HtmlRenderer();
for (const k of Object.keys(commonmark.HtmlRenderer.prototype)) {
dummy_renderer[k] = setNotPlain;
}
// text and paragraph are just text
dummy_renderer.text = function(t) { return t; };
dummy_renderer.softbreak = function(t) { return t; };
dummy_renderer.paragraph = function(t) { return t; };
const dummy_parser = new commonmark.Parser();
dummy_renderer.render(dummy_parser.parse(this.input));
return is_plain;
} }
toHTML() { toHTML() {
const real_paragraph = this.renderer.paragraph; const renderer = new commonmark.HtmlRenderer({safe: false});
const real_paragraph = renderer.paragraph;
this.renderer.paragraph = function(node, entering) { renderer.paragraph = function(node, entering) {
// If there is only one top level node, just return the // If there is only one top level node, just return the
// bare text: it's a single line of text and so should be // bare text: it's a single line of text and so should be
// 'inline', rather than unnecessarily wrapped in its own // 'inline', rather than unnecessarily wrapped in its own
// p tag. If, however, we have multiple nodes, each gets // p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs. // its own p tag to keep them as separate paragraphs.
var par = node; if (is_multi_line(node)) {
while (par.parent) {
par = par.parent;
}
if (par.firstChild != par.lastChild) {
real_paragraph.call(this, node, entering); real_paragraph.call(this, node, entering);
} }
}; };
var parsed = this.parser.parse(this.input); renderer.html_inline = html_if_tag_allowed;
var rendered = this.renderer.render(parsed); 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);
this.renderer.paragraph = real_paragraph; if (isMultiLine) this.cr();
html_if_tag_allowed.call(this, node);
if (isMultiLine) this.cr();
}
return rendered; 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() { toPlaintext() {
const real_paragraph = this.renderer.paragraph; const renderer = new commonmark.HtmlRenderer({safe: false});
const real_paragraph = renderer.paragraph;
// The default `out` function only sends the input through an XML // The default `out` function only sends the input through an XML
// escaping function, which causes messages to be entity encoded, // escaping function, which causes messages to be entity encoded,
// which we don't want in this case. // which we don't want in this case.
this.renderer.out = function(s) { renderer.out = function(s) {
// The `lit` function adds a string literal to the output buffer. // The `lit` function adds a string literal to the output buffer.
this.lit(s); this.lit(s);
}; };
this.renderer.paragraph = function(node, entering) { renderer.paragraph = function(node, entering) {
// If there is only one top level node, just return the // as with toHTML, only append lines to paragraphs if there are
// bare text: it's a single line of text and so should be // multiple paragraphs
// 'inline', rather than unnecessarily wrapped in its own if (is_multi_line(node)) {
// p tag. If, however, we have multiple nodes, each gets if (!entering && node.next) {
// its own p tag to keep them as separate paragraphs.
var par = node;
while (par.parent) {
node = par;
par = par.parent;
}
if (node != par.lastChild) {
if (!entering) {
this.lit('\n\n'); this.lit('\n\n');
} }
} }
}; };
renderer.html_block = function(node) {
this.lit(node.literal);
if (is_multi_line(node) && node.next) this.lit('\n\n');
}
var parsed = this.parser.parse(this.input); return renderer.render(this.parsed);
var rendered = this.renderer.render(parsed);
this.renderer.paragraph = real_paragraph;
return rendered;
} }
} }

View file

@ -177,7 +177,7 @@ class ModalManager {
var modal = this._modals[0]; var modal = this._modals[0];
var dialog = ( var dialog = (
<div className={"mx_Dialog_wrapper " + modal.className}> <div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '') }>
<div className="mx_Dialog"> <div className="mx_Dialog">
{modal.elem} {modal.elem}
</div> </div>

View file

@ -34,7 +34,7 @@ module.exports = {
Modal.createDialog(UnknownDeviceDialog, { Modal.createDialog(UnknownDeviceDialog, {
devices: err.devices, devices: err.devices,
room: MatrixClientPeg.get().getRoom(event.getRoomId()), room: MatrixClientPeg.get().getRoom(event.getRoomId()),
}); }, "mx_Dialog_unknownDevice");
} }
dis.dispatch({ dis.dispatch({

80
src/RtsClient.js Normal file
View file

@ -0,0 +1,80 @@
import 'whatwg-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 fetch(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} userId the user ID of the user being referred.
* @param {string} userEmail the email address linked to `userId`.
* @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon
* success.
*/
trackReferral(referrer, userId, userEmail) {
return request(this._url + '/register',
{
body: {
referrer: referrer,
user_id: userId,
user_email: userEmail,
},
method: 'POST',
}
);
}
getTeam(teamToken) {
return request(this._url + '/teamConfiguration',
{
qs: {
team_token: teamToken,
},
}
);
}
}

View file

@ -62,11 +62,11 @@ module.exports = React.createClass({
oldNode.style.visibility = c.props.style.visibility; 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); //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
} }
if (oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') {
oldNode.style.visibility = c.props.style.visibility;
}
self.children[c.key] = old; self.children[c.key] = old;
} else { } else {
// new element. If we have a startStyle, use that as the style and go through // new element. If we have a startStyle, use that as the style and go through

View file

@ -1,3 +1,19 @@
/*
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.
*/
var MatrixClientPeg = require("./MatrixClientPeg"); var MatrixClientPeg = require("./MatrixClientPeg");
module.exports = { module.exports = {

View file

@ -71,7 +71,7 @@ export default React.createClass({
return this.props.matrixClient.exportRoomKeys(); return this.props.matrixClient.exportRoomKeys();
}).then((k) => { }).then((k) => {
return MegolmExportEncryption.encryptMegolmKeyFile( return MegolmExportEncryption.encryptMegolmKeyFile(
JSON.stringify(k), passphrase JSON.stringify(k), passphrase,
); );
}).then((f) => { }).then((f) => {
const blob = new Blob([f], { const blob = new Blob([f], {
@ -95,9 +95,14 @@ export default React.createClass({
}); });
}, },
_onCancelClick: function(ev) {
ev.preventDefault();
this.props.onFinished(false);
return false;
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
const disableForm = (this.state.phase === PHASE_EXPORTING); const disableForm = (this.state.phase === PHASE_EXPORTING);
@ -159,10 +164,9 @@ export default React.createClass({
<input className='mx_Dialog_primary' type='submit' value='Export' <input className='mx_Dialog_primary' type='submit' value='Export'
disabled={disableForm} disabled={disableForm}
/> />
<AccessibleButton element='button' onClick={this.props.onFinished} <button onClick={this._onCancelClick} disabled={disableForm}>
disabled={disableForm}>
Cancel Cancel
</AccessibleButton> </button>
</div> </div>
</form> </form>
</BaseDialog> </BaseDialog>

View file

@ -80,7 +80,7 @@ export default React.createClass({
return readFileAsArrayBuffer(file).then((arrayBuffer) => { return readFileAsArrayBuffer(file).then((arrayBuffer) => {
return MegolmExportEncryption.decryptMegolmKeyFile( return MegolmExportEncryption.decryptMegolmKeyFile(
arrayBuffer, passphrase arrayBuffer, passphrase,
); );
}).then((keys) => { }).then((keys) => {
return this.props.matrixClient.importRoomKeys(JSON.parse(keys)); return this.props.matrixClient.importRoomKeys(JSON.parse(keys));
@ -98,9 +98,14 @@ export default React.createClass({
}); });
}, },
_onCancelClick: function(ev) {
ev.preventDefault();
this.props.onFinished(false);
return false;
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
const disableForm = (this.state.phase !== PHASE_EDIT); const disableForm = (this.state.phase !== PHASE_EDIT);
@ -158,10 +163,9 @@ export default React.createClass({
<input className='mx_Dialog_primary' type='submit' value='Import' <input className='mx_Dialog_primary' type='submit' value='Import'
disabled={!this.state.enableSubmit || disableForm} disabled={!this.state.enableSubmit || disableForm}
/> />
<AccessibleButton element='button' onClick={this.props.onFinished} <button onClick={this._onCancelClick} disabled={disableForm}>
disabled={disableForm}>
Cancel Cancel
</AccessibleButton> </button>
</div> </div>
</form> </form>
</BaseDialog> </BaseDialog>

View file

@ -105,6 +105,7 @@ var FilePanel = React.createClass({
showUrlPreview = { false } showUrlPreview = { false }
tileShape="file_grid" tileShape="file_grid"
opacity={ this.props.opacity } opacity={ this.props.opacity }
empty="There are no visible files in this room"
/> />
); );
} }

View file

@ -171,6 +171,7 @@ export default React.createClass({
brand={this.props.config.brand} brand={this.props.config.brand}
collapsedRhs={this.props.collapse_rhs} collapsedRhs={this.props.collapse_rhs}
enableLabs={this.props.config.enableLabs} enableLabs={this.props.config.enableLabs}
referralBaseUrl={this.props.config.referralBaseUrl}
/>; />;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>; if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break; break;

View file

@ -1055,12 +1055,13 @@ module.exports = React.createClass({
sessionId={this.state.register_session_id} sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid} idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email} email={this.props.startingFragmentQueryParams.email}
referrer={this.props.startingFragmentQueryParams.referrer}
username={this.state.upgradeUsername} username={this.state.upgradeUsername}
guestAccessToken={this.state.guestAccessToken} guestAccessToken={this.state.guestAccessToken}
defaultHsUrl={this.getDefaultHsUrl()} defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()} defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand} brand={this.props.config.brand}
teamsConfig={this.props.config.teamsConfig} teamServerConfig={this.props.config.teamServerConfig}
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
registrationUrl={this.props.registrationUrl} registrationUrl={this.props.registrationUrl}

View file

@ -48,6 +48,7 @@ var NotificationPanel = React.createClass({
showUrlPreview = { false } showUrlPreview = { false }
opacity={ this.props.opacity } opacity={ this.props.opacity }
tileShape="notif" tileShape="notif"
empty="You have no visible notifications"
/> />
); );
} }

View file

@ -74,6 +74,7 @@ module.exports = React.createClass({
// callback for when the status bar can be hidden from view, as it is // callback for when the status bar can be hidden from view, as it is
// not displaying anything // not displaying anything
onHidden: React.PropTypes.func, onHidden: React.PropTypes.func,
// callback for when the status bar is displaying something and should // callback for when the status bar is displaying something and should
// be visible // be visible
onVisible: React.PropTypes.func, onVisible: React.PropTypes.func,
@ -113,7 +114,9 @@ module.exports = React.createClass({
clearTimeout(this.hideDebouncer); clearTimeout(this.hideDebouncer);
} }
this.hideDebouncer = setTimeout(() => { this.hideDebouncer = setTimeout(() => {
this.props.onHidden(); // temporarily stop hiding the statusbar as per
// https://github.com/vector-im/riot-web/issues/1991#issuecomment-276953915
// this.props.onHidden();
}, HIDE_DEBOUNCE_MS); }, HIDE_DEBOUNCE_MS);
} }
}, },
@ -238,7 +241,7 @@ module.exports = React.createClass({
if (othersCount > 0) { if (othersCount > 0) {
avatars.push( avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining"> <span className="mx_RoomStatusBar_typingIndicatorRemaining" key="others">
+{othersCount} +{othersCount}
</span> </span>
); );

View file

@ -1332,12 +1332,14 @@ module.exports = React.createClass({
}, },
onStatusBarVisible: function() { onStatusBarVisible: function() {
if (this.unmounted) return;
this.setState({ this.setState({
statusBarVisible: true, statusBarVisible: true,
}); });
}, },
onStatusBarHidden: function() { onStatusBarHidden: function() {
if (this.unmounted) return;
this.setState({ this.setState({
statusBarVisible: false, statusBarVisible: false,
}); });
@ -1507,13 +1509,14 @@ module.exports = React.createClass({
}); });
var statusBar; var statusBar;
let isStatusAreaExpanded = true;
if (ContentMessages.getCurrentUploads().length > 0) { if (ContentMessages.getCurrentUploads().length > 0) {
var UploadBar = sdk.getComponent('structures.UploadBar'); var UploadBar = sdk.getComponent('structures.UploadBar');
statusBar = <UploadBar room={this.state.room} />; statusBar = <UploadBar room={this.state.room} />;
} else if (!this.state.searchResults) { } else if (!this.state.searchResults) {
var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar');
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
tabComplete={this.tabComplete} tabComplete={this.tabComplete}
@ -1683,7 +1686,7 @@ module.exports = React.createClass({
); );
} }
let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable"; let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable";
if (this.state.statusBarVisible) { if (isStatusAreaExpanded) {
statusBarAreaClass += " mx_RoomView_statusArea_expanded"; statusBarAreaClass += " mx_RoomView_statusArea_expanded";
} }

View file

@ -25,7 +25,7 @@ var DEBUG_SCROLL = false;
// The amount of extra scroll distance to allow prior to unfilling. // The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight. // See _getExcessHeight.
const UNPAGINATION_PADDING = 1500; const UNPAGINATION_PADDING = 3000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent // The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests. // many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200; const UNFILL_REQUEST_DEBOUNCE_MS = 200;
@ -570,7 +570,7 @@ module.exports = React.createClass({
var boundingRect = node.getBoundingClientRect(); var boundingRect = node.getBoundingClientRect();
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
debuglog("Scrolling to token '" + node.dataset.scrollToken + "'+" + debuglog("ScrollPanel: scrolling to token '" + node.dataset.scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")"); pixelOffset + " (delta: "+scrollDelta+")");
if(scrollDelta != 0) { if(scrollDelta != 0) {
@ -582,7 +582,7 @@ module.exports = React.createClass({
_saveScrollState: function() { _saveScrollState: function() {
if (this.props.stickyBottom && this.isAtBottom()) { if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true }; this.scrollState = { stuckAtBottom: true };
debuglog("Saved scroll state", this.scrollState); debuglog("ScrollPanel: Saved scroll state", this.scrollState);
return; return;
} }
@ -601,12 +601,12 @@ module.exports = React.createClass({
trackedScrollToken: node.dataset.scrollToken, trackedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom, pixelOffset: wrapperRect.bottom - boundingRect.bottom,
}; };
debuglog("Saved scroll state", this.scrollState); debuglog("ScrollPanel: saved scroll state", this.scrollState);
return; return;
} }
} }
debuglog("Unable to save scroll state: found no children in the viewport"); debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
}, },
_restoreSavedScrollState: function() { _restoreSavedScrollState: function() {
@ -640,7 +640,7 @@ module.exports = React.createClass({
this._lastSetScroll = scrollNode.scrollTop; this._lastSetScroll = scrollNode.scrollTop;
} }
debuglog("Set scrollTop:", scrollNode.scrollTop, debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop, "requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll); "_lastSetScroll:", this._lastSetScroll);
}, },

View file

@ -96,6 +96,9 @@ var TimelinePanel = React.createClass({
// shape property to be passed to EventTiles // shape property to be passed to EventTiles
tileShape: React.PropTypes.string, tileShape: React.PropTypes.string,
// placeholder text to use if the timeline is empty
empty: React.PropTypes.string,
}, },
statics: { statics: {
@ -990,6 +993,14 @@ var TimelinePanel = React.createClass({
); );
} }
if (this.state.events.length == 0) {
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 // give the messagepanel a stickybottom if we're at the end of the
// live timeline, so that the arrival of new events triggers a // live timeline, so that the arrival of new events triggers a
// scroll. // scroll.

View file

@ -104,6 +104,9 @@ module.exports = React.createClass({
// True to show the 'labs' section of experimental features // True to show the 'labs' section of experimental features
enableLabs: React.PropTypes.bool, enableLabs: React.PropTypes.bool,
// The base URL to use in the referral link. Defaults to window.location.origin.
referralBaseUrl: React.PropTypes.string,
// true if RightPanel is collapsed // true if RightPanel is collapsed
collapsedRhs: React.PropTypes.bool, collapsedRhs: React.PropTypes.bool,
}, },
@ -458,6 +461,27 @@ module.exports = React.createClass({
); );
}, },
_renderReferral: function() {
const teamToken = window.localStorage.getItem('mx_team_token');
if (!teamToken) {
return null;
}
if (typeof teamToken !== 'string') {
console.warn('Team token not a string');
return null;
}
const href = (this.props.referralBaseUrl || window.location.origin) +
`/#/register?referrer=${this._me}&team_token=${teamToken}`;
return (
<div>
<h3>Referral</h3>
<div className="mx_UserSettings_section">
Refer a friend to Riot: <a href={href}>{href}</a>
</div>
</div>
);
},
_renderUserInterfaceSettings: function() { _renderUserInterfaceSettings: function() {
var client = MatrixClientPeg.get(); var client = MatrixClientPeg.get();
@ -857,6 +881,8 @@ module.exports = React.createClass({
{accountJsx} {accountJsx}
</div> </div>
{this._renderReferral()}
{notification_area} {notification_area}
{this._renderUserInterfaceSettings()} {this._renderUserInterfaceSettings()}

View file

@ -25,6 +25,7 @@ var ServerConfig = require("../../views/login/ServerConfig");
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var RegistrationForm = require("../../views/login/RegistrationForm"); var RegistrationForm = require("../../views/login/RegistrationForm");
var CaptchaForm = require("../../views/login/CaptchaForm"); var CaptchaForm = require("../../views/login/CaptchaForm");
var RtsClient = require("../../../RtsClient");
var MIN_PASSWORD_LENGTH = 6; var MIN_PASSWORD_LENGTH = 6;
@ -47,23 +48,16 @@ module.exports = React.createClass({
defaultIsUrl: React.PropTypes.string, defaultIsUrl: React.PropTypes.string,
brand: React.PropTypes.string, brand: React.PropTypes.string,
email: React.PropTypes.string, email: React.PropTypes.string,
referrer: React.PropTypes.string,
username: React.PropTypes.string, username: React.PropTypes.string,
guestAccessToken: React.PropTypes.string, guestAccessToken: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({ teamServerConfig: React.PropTypes.shape({
// Email address to request new teams // Email address to request new teams
supportEmail: React.PropTypes.string, supportEmail: React.PropTypes.string.isRequired,
teams: React.PropTypes.arrayOf(React.PropTypes.shape({ // URL of the riot-team-server to get team configurations and track referrals
// The displayed name of the team teamServerURL: React.PropTypes.string.isRequired,
"name": React.PropTypes.string,
// The suffix with which every team email address ends
"emailSuffix": React.PropTypes.string,
// The rooms to use during auto-join
"rooms": React.PropTypes.arrayOf(React.PropTypes.shape({
"id": React.PropTypes.string,
"autoJoin": React.PropTypes.bool,
})),
})).required,
}), }),
teamSelected: React.PropTypes.object,
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: React.PropTypes.string,
@ -75,6 +69,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
busy: false, busy: false,
teamServerBusy: false,
errorText: null, errorText: null,
// We remember the values entered by the user because // We remember the values entered by the user because
// the registration form will be unmounted during the // the registration form will be unmounted during the
@ -90,6 +85,7 @@ module.exports = React.createClass({
}, },
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false;
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
// attach this to the instance rather than this.state since it isn't UI // attach this to the instance rather than this.state since it isn't UI
this.registerLogic = new Signup.Register( this.registerLogic = new Signup.Register(
@ -103,10 +99,40 @@ module.exports = React.createClass({
this.registerLogic.setIdSid(this.props.idSid); this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken); this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
this.registerLogic.recheckState(); this.registerLogic.recheckState();
if (
this.props.teamServerConfig &&
this.props.teamServerConfig.teamServerURL &&
!this._rtsClient
) {
this._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,
});
});
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
this._unmounted = true;
}, },
componentDidMount: function() { componentDidMount: function() {
@ -184,24 +210,41 @@ module.exports = React.createClass({
accessToken: response.access_token accessToken: response.access_token
}); });
// Auto-join rooms if (
if (self.props.teamsConfig && self.props.teamsConfig.teams) { self._rtsClient &&
for (let i = 0; i < self.props.teamsConfig.teams.length; i++) { self.props.referrer &&
let team = self.props.teamsConfig.teams[i]; self.state.teamSelected
if (self.state.formVals.email.endsWith(team.emailSuffix)) { ) {
console.log("User successfully registered with team " + team.name); // Track referral, get team_token in order to retrieve team config
self._rtsClient.trackReferral(
self.props.referrer,
response.user_id,
self.state.formVals.email
).then((data) => {
const teamToken = data.team_token;
// Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken);
self._rtsClient.getTeam(teamToken).then((team) => {
console.log(
`User successfully registered with team ${team.name}`
);
if (!team.rooms) { if (!team.rooms) {
break; return;
} }
// Auto-join rooms
team.rooms.forEach((room) => { team.rooms.forEach((room) => {
if (room.autoJoin) { if (room.auto_join && room.room_id) {
console.log("Auto-joining " + room.id); console.log(`Auto-joining ${room.room_id}`);
MatrixClientPeg.get().joinRoom(room.id); MatrixClientPeg.get().joinRoom(room.room_id);
} }
}); });
break; }, (err) => {
} console.error('Error getting team config', err);
} });
}, (err) => {
console.error('Error tracking referral', err);
});
} }
if (self.props.brand) { if (self.props.brand) {
@ -273,7 +316,15 @@ module.exports = React.createClass({
}); });
}, },
onTeamSelected: function(teamSelected) {
if (!this._unmounted) {
this.setState({ teamSelected });
}
},
_getRegisterContentJsx: function() { _getRegisterContentJsx: function() {
const Spinner = sdk.getComponent("elements.Spinner");
var currStep = this.registerLogic.getStep(); var currStep = this.registerLogic.getStep();
var registerStep; var registerStep;
switch (currStep) { switch (currStep) {
@ -283,17 +334,23 @@ module.exports = React.createClass({
case "Register.STEP_m.login.dummy": case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading // NB. Our 'username' prop is specifically for upgrading
// a guest account // a guest account
if (this.state.teamServerBusy) {
registerStep = <Spinner />;
break;
}
registerStep = ( registerStep = (
<RegistrationForm <RegistrationForm
showEmail={true} showEmail={true}
defaultUsername={this.state.formVals.username} defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email} defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password} defaultPassword={this.state.formVals.password}
teamsConfig={this.props.teamsConfig} teamsConfig={this.state.teamsConfig}
guestUsername={this.props.username} guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH} minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} /> onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
/>
); );
break; break;
case "Register.STEP_m.login.email.identity": case "Register.STEP_m.login.email.identity":
@ -322,7 +379,6 @@ module.exports = React.createClass({
} }
var busySpinner; var busySpinner;
if (this.state.busy) { if (this.state.busy) {
var Spinner = sdk.getComponent("elements.Spinner");
busySpinner = ( busySpinner = (
<Spinner /> <Spinner />
); );
@ -367,7 +423,7 @@ module.exports = React.createClass({
return ( return (
<div className="mx_Login"> <div className="mx_Login">
<div className="mx_Login_box"> <div className="mx_Login_box">
<LoginHeader /> <LoginHeader icon={this.state.teamSelected ? this.state.teamSelected.icon : null}/>
{this._getRegisterContentJsx()} {this._getRegisterContentJsx()}
<LoginFooter /> <LoginFooter />
</div> </div>

View file

@ -145,27 +145,48 @@ module.exports = React.createClass({
if (imageUrl === this.state.defaultImageUrl) { if (imageUrl === this.state.defaultImageUrl) {
const initialLetter = this._getInitialLetter(name); const initialLetter = this._getInitialLetter(name);
return ( const textNode = (
<span className="mx_BaseAvatar" {...otherProps}> <EmojiText className="mx_BaseAvatar_initial" aria-hidden="true"
<EmojiText className="mx_BaseAvatar_initial" aria-hidden="true" style={{ fontSize: (width * 0.65) + "px",
style={{ fontSize: (width * 0.65) + "px", width: width + "px",
width: width + "px", lineHeight: height + "px" }}
lineHeight: height + "px" }}>{initialLetter}</EmojiText> >
<img className="mx_BaseAvatar_image" src={imageUrl} {initialLetter}
alt="" title={title} onError={this.onError} </EmojiText>
width={width} height={height} />
</span>
); );
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) { if (onClick != null) {
return ( return (
<AccessibleButton className="mx_BaseAvatar" onClick={onClick}> <AccessibleButton className="mx_BaseAvatar mx_BaseAvatar_image"
<img className="mx_BaseAvatar_image" src={imageUrl} element='img'
onError={this.onError} src={imageUrl}
width={width} height={height} onClick={onClick}
title={title} alt="" onError={this.onError}
{...otherProps} /> width={width} height={height}
</AccessibleButton> title={title} alt=""
{...otherProps} />
); );
} else { } else {
return ( return (

View file

@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require("react"); import React from 'react';
var classNames = require('classnames'); import classNames from 'classnames';
var sdk = require("../../../index"); import sdk from '../../../index';
var Invite = require("../../../Invite"); import { getAddressType, inviteMultipleToRoom } from '../../../Invite';
var createRoom = require("../../../createRoom"); import createRoom from '../../../createRoom';
var MatrixClientPeg = require("../../../MatrixClientPeg"); import MatrixClientPeg from '../../../MatrixClientPeg';
var DMRoomMap = require('../../../utils/DMRoomMap'); import DMRoomMap from '../../../utils/DMRoomMap';
var rate_limited_func = require("../../../ratelimitedfunc"); import rate_limited_func from '../../../ratelimitedfunc';
var dis = require("../../../dispatcher"); import dis from '../../../dispatcher';
var Modal = require('../../../Modal'); import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import q from 'q';
const TRUNCATE_QUERY_LIST = 40; const TRUNCATE_QUERY_LIST = 40;
@ -186,13 +187,17 @@ module.exports = React.createClass({
// If the query isn't a user we know about, but is a // If the query isn't a user we know about, but is a
// valid address, add an entry for that // valid address, add an entry for that
if (queryList.length == 0) { if (queryList.length == 0) {
const addrType = Invite.getAddressType(query); const addrType = getAddressType(query);
if (addrType !== null) { if (addrType !== null) {
queryList.push({ queryList[0] = {
addressType: addrType, addressType: addrType,
address: query, address: query,
isKnown: false, isKnown: false,
}); };
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType == 'email') {
this._lookupThreepid(addrType, query).done();
}
} }
} }
} }
@ -212,6 +217,7 @@ module.exports = React.createClass({
inviteList: inviteList, inviteList: inviteList,
queryList: [], queryList: [],
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
}; };
}, },
@ -229,6 +235,7 @@ module.exports = React.createClass({
inviteList: inviteList, inviteList: inviteList,
queryList: [], queryList: [],
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
}, },
_getDirectMessageRoom: function(addr) { _getDirectMessageRoom: function(addr) {
@ -266,7 +273,7 @@ module.exports = React.createClass({
if (this.props.roomId) { if (this.props.roomId) {
// Invite new user to a room // Invite new user to a room
var self = this; var self = this;
Invite.inviteMultipleToRoom(this.props.roomId, addrTexts) inviteMultipleToRoom(this.props.roomId, addrTexts)
.then(function(addrs) { .then(function(addrs) {
var room = MatrixClientPeg.get().getRoom(self.props.roomId); var room = MatrixClientPeg.get().getRoom(self.props.roomId);
return self._showAnyInviteErrors(addrs, room); return self._showAnyInviteErrors(addrs, room);
@ -300,7 +307,7 @@ module.exports = React.createClass({
var room; var room;
createRoom().then(function(roomId) { createRoom().then(function(roomId) {
room = MatrixClientPeg.get().getRoom(roomId); room = MatrixClientPeg.get().getRoom(roomId);
return Invite.inviteMultipleToRoom(roomId, addrTexts); return inviteMultipleToRoom(roomId, addrTexts);
}) })
.then(function(addrs) { .then(function(addrs) {
return self._showAnyInviteErrors(addrs, room); return self._showAnyInviteErrors(addrs, room);
@ -380,7 +387,7 @@ module.exports = React.createClass({
}, },
_isDmChat: function(addrs) { _isDmChat: function(addrs) {
if (addrs.length === 1 && Invite.getAddressType(addrs[0]) === "mx" && !this.props.roomId) { if (addrs.length === 1 && getAddressType(addrs[0]) === "mx" && !this.props.roomId) {
return true; return true;
} else { } else {
return false; return false;
@ -408,7 +415,7 @@ module.exports = React.createClass({
_addInputToList: function() { _addInputToList: function() {
const addressText = this.refs.textinput.value.trim(); const addressText = this.refs.textinput.value.trim();
const addrType = Invite.getAddressType(addressText); const addrType = getAddressType(addressText);
const addrObj = { const addrObj = {
addressType: addrType, addressType: addrType,
address: addressText, address: addressText,
@ -432,9 +439,45 @@ module.exports = React.createClass({
inviteList: inviteList, inviteList: inviteList,
queryList: [], queryList: [],
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
return inviteList; return inviteList;
}, },
_lookupThreepid: function(medium, address) {
let cancelled = false;
// Note that we can't safely remove this after we're done
// because we don't know that it's the same one, so we just
// leave it: it's replacing the old one each time so it's
// not like they leak.
this._cancelThreepidLookup = function() {
cancelled = true;
}
// wait a bit to let the user finish typing
return q.delay(500).then(() => {
if (cancelled) return null;
return MatrixClientPeg.get().lookupThreePid(medium, address);
}).then((res) => {
if (res === null || !res.mxid) return null;
if (cancelled) return null;
return MatrixClientPeg.get().getProfileInfo(res.mxid);
}).then((res) => {
if (res === null) return null;
if (cancelled) return null;
this.setState({
queryList: [{
// an InviteAddressType
addressType: medium,
address: address,
displayName: res.displayname,
avatarMxc: res.avatar_url,
isKnown: true,
}]
});
});
},
render: function() { render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AddressSelector = sdk.getComponent("elements.AddressSelector"); const AddressSelector = sdk.getComponent("elements.AddressSelector");

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import GeminiScrollbar from 'react-gemini-scrollbar';
function DeviceListEntry(props) { function DeviceListEntry(props) {
const {userId, device} = props; const {userId, device} = props;
@ -118,7 +119,19 @@ export default React.createClass({
</h4> </h4>
); );
} else { } else {
warning = <h4>We strongly recommend you verify them before continuing.</h4>; warning = (
<div>
<p>
This means there is no guarantee that the devices belong
to a rightful user of the room.
</p>
<p>
We recommend you go through the verification process
for each device before continuing, but you can resend
the message without verifying if you prefer.
</p>
</div>
);
} }
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -127,15 +140,16 @@ export default React.createClass({
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title='Room contains unknown devices' title='Room contains unknown devices'
> >
<div className="mx_Dialog_content"> <GeminiScrollbar autoshow={false} className="mx_Dialog_content">
<h4> <h4>
This room contains unknown devices which have not been This room contains unknown devices which have not been
verified. verified.
</h4> </h4>
{ warning } { warning }
Unknown devices: Unknown devices:
<UnknownDeviceList devices={this.props.devices} /> <UnknownDeviceList devices={this.props.devices} />
</div> </GeminiScrollbar>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" autoFocus={ true } <button className="mx_Dialog_primary" autoFocus={ true }
onClick={ this.props.onFinished } > onClick={ this.props.onFinished } >

View file

@ -94,14 +94,14 @@ export default React.createClass({
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const nameClasses = classNames({
"mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified,
});
let info; let info;
let error = false; let error = false;
if (address.addressType === "mx" && address.isKnown) { if (address.addressType === "mx" && address.isKnown) {
const nameClasses = classNames({
"mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified,
});
const idClasses = classNames({ const idClasses = classNames({
"mx_AddressTile_id": true, "mx_AddressTile_id": true,
"mx_AddressTile_justified": this.props.justified, "mx_AddressTile_justified": this.props.justified,
@ -123,13 +123,21 @@ export default React.createClass({
<div className={unknownMxClasses}>{ this.props.address.address }</div> <div className={unknownMxClasses}>{ this.props.address.address }</div>
); );
} else if (address.addressType === "email") { } else if (address.addressType === "email") {
var emailClasses = classNames({ const emailClasses = classNames({
"mx_AddressTile_email": true, "mx_AddressTile_email": true,
"mx_AddressTile_justified": this.props.justified, "mx_AddressTile_justified": this.props.justified,
}); });
let nameNode = null;
if (address.displayName) {
nameNode = <div className={nameClasses}>{ address.displayName }</div>
}
info = ( info = (
<div className={emailClasses}>{ address.address }</div> <div className="mx_AddressTile_mx">
<div className={emailClasses}>{ address.address }</div>
{nameNode}
</div>
); );
} else { } else {
error = true; error = true;

View file

@ -44,8 +44,8 @@ module.exports = React.createClass({
teams: React.PropTypes.arrayOf(React.PropTypes.shape({ teams: React.PropTypes.arrayOf(React.PropTypes.shape({
// The displayed name of the team // The displayed name of the team
"name": React.PropTypes.string, "name": React.PropTypes.string,
// The suffix with which every team email address ends // The domain of team email addresses
"emailSuffix": React.PropTypes.string, "domain": React.PropTypes.string,
})).required, })).required,
}), }),
@ -117,9 +117,6 @@ module.exports = React.createClass({
_doSubmit: function() { _doSubmit: function() {
let email = this.refs.email.value.trim(); let email = this.refs.email.value.trim();
if (this.state.selectedTeam) {
email += "@" + this.state.selectedTeam.emailSuffix;
}
var promise = this.props.onRegisterClick({ var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim() || this.props.guestUsername, username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(), password: this.refs.password.value.trim(),
@ -134,25 +131,6 @@ module.exports = React.createClass({
} }
}, },
onSelectTeam: function(teamIndex) {
let team = this._getSelectedTeam(teamIndex);
if (team) {
this.refs.email.value = this.refs.email.value.split("@")[0];
}
this.setState({
selectedTeam: team,
showSupportEmail: teamIndex === "other",
});
},
_getSelectedTeam: function(teamIndex) {
if (this.props.teamsConfig &&
this.props.teamsConfig.teams[teamIndex]) {
return this.props.teamsConfig.teams[teamIndex];
}
return null;
},
/** /**
* Returns true if all fields were valid last time * Returns true if all fields were valid last time
* they were validated. * they were validated.
@ -167,20 +145,36 @@ module.exports = React.createClass({
return true; return true;
}, },
_isUniEmail: function(email) {
return email.endsWith('.ac.uk') || email.endsWith('.edu');
},
validateField: function(field_id) { validateField: function(field_id) {
var pwd1 = this.refs.password.value.trim(); var pwd1 = this.refs.password.value.trim();
var pwd2 = this.refs.passwordConfirm.value.trim(); var pwd2 = this.refs.passwordConfirm.value.trim();
switch (field_id) { switch (field_id) {
case FIELD_EMAIL: case FIELD_EMAIL:
let email = this.refs.email.value; const email = this.refs.email.value;
if (this.props.teamsConfig) { if (this.props.teamsConfig && this._isUniEmail(email)) {
let team = this.state.selectedTeam; const matchingTeam = this.props.teamsConfig.teams.find(
if (team) { (team) => {
email = email + "@" + team.emailSuffix; return email.split('@').pop() === team.domain;
} }
) || null;
this.setState({
selectedTeam: matchingTeam,
showSupportEmail: !matchingTeam,
});
this.props.onTeamSelected(matchingTeam);
} else {
this.props.onTeamSelected(null);
this.setState({
selectedTeam: null,
showSupportEmail: false,
});
} }
let valid = email === '' || Email.looksValid(email); const valid = email === '' || Email.looksValid(email);
this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID"); this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID");
break; break;
case FIELD_USERNAME: case FIELD_USERNAME:
@ -260,61 +254,35 @@ module.exports = React.createClass({
return cls; return cls;
}, },
_renderEmailInputSuffix: function() {
let suffix = null;
if (!this.state.selectedTeam) {
return suffix;
}
let team = this.state.selectedTeam;
if (team) {
suffix = "@" + team.emailSuffix;
}
return suffix;
},
render: function() { render: function() {
var self = this; var self = this;
var emailSection, teamSection, teamAdditionSupport, registerButton; var emailSection, belowEmailSection, registerButton;
if (this.props.showEmail) { if (this.props.showEmail) {
let emailSuffix = this._renderEmailInputSuffix();
emailSection = ( emailSection = (
<div> <input type="text" ref="email"
<input type="text" ref="email" autoFocus={true} placeholder="Email address (optional)"
autoFocus={true} placeholder="Email address (optional)" defaultValue={this.props.defaultEmail}
defaultValue={this.props.defaultEmail} className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')} onBlur={function() {self.validateField(FIELD_EMAIL);}}
onBlur={function() {self.validateField(FIELD_EMAIL);}} value={self.state.email}/>
value={self.state.email}/>
{emailSuffix ? <input className="mx_Login_field" value={emailSuffix} disabled/> : null }
</div>
); );
if (this.props.teamsConfig) { if (this.props.teamsConfig) {
teamSection = (
<select
defaultValue="-1"
className="mx_Login_field"
onBlur={function() {self.validateField(FIELD_EMAIL);}}
onChange={function(ev) {self.onSelectTeam(ev.target.value);}}
>
<option key="-1" value="-1">No team</option>
{this.props.teamsConfig.teams.map((t, index) => {
return (
<option key={index} value={index}>
{t.name}
</option>
);
})}
<option key="-2" value="other">Other</option>
</select>
);
if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) { if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) {
teamAdditionSupport = ( belowEmailSection = (
<span> <p className="mx_Login_support">
If your team is not listed, email&nbsp; Sorry, but your university is not registered with us just yet.&nbsp;
Email us on&nbsp;
<a href={"mailto:" + this.props.teamsConfig.supportEmail}> <a href={"mailto:" + this.props.teamsConfig.supportEmail}>
{this.props.teamsConfig.supportEmail} {this.props.teamsConfig.supportEmail}
</a> </a>&nbsp;
</span> to get your university signed up. Or continue to register with Riot to enjoy our open source platform.
</p>
);
} else if (this.state.selectedTeam) {
belowEmailSection = (
<p className="mx_Login_support">
You are registering with {this.state.selectedTeam.name}
</p>
); );
} }
} }
@ -333,11 +301,8 @@ module.exports = React.createClass({
return ( return (
<div> <div>
<form onSubmit={this.onSubmit}> <form onSubmit={this.onSubmit}>
{teamSection}
{teamAdditionSupport}
<br />
{emailSection} {emailSection}
<br /> {belowEmailSection}
<input type="text" ref="username" <input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername} placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')} className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}

View file

@ -35,7 +35,7 @@ export function onSendMessageFailed(err, room) {
Modal.createDialog(UnknownDeviceDialog, { Modal.createDialog(UnknownDeviceDialog, {
devices: err.devices, devices: err.devices,
room: room, room: room,
}); }, "mx_Dialog_unknownDevice");
} }
dis.dispatch({ dis.dispatch({
action: 'message_send_failed', action: 'message_send_failed',

View file

@ -170,15 +170,15 @@ module.exports = React.createClass({
let title; let title;
if (this.props.timestamp) { if (this.props.timestamp) {
let suffix = " (" + this.props.member.userId + ")"; const prefix = "Seen by " + this.props.member.userId + " at ";
let ts = new Date(this.props.timestamp); let ts = new Date(this.props.timestamp);
if (this.props.showFullTimestamp) { if (this.props.showFullTimestamp) {
// "15/12/2016, 7:05:45 PM (@alice:matrix.org)" // "15/12/2016, 7:05:45 PM (@alice:matrix.org)"
title = ts.toLocaleString() + suffix; title = prefix + ts.toLocaleString();
} }
else { else {
// "7:05:45 PM (@alice:matrix.org)" // "7:05:45 PM (@alice:matrix.org)"
title = ts.toLocaleTimeString() + suffix; title = prefix + ts.toLocaleTimeString();
} }
} }
@ -192,9 +192,9 @@ module.exports = React.createClass({
width={14} height={14} resizeMethod="crop" width={14} height={14} resizeMethod="crop"
style={style} style={style}
title={title} title={title}
onClick={this.props.onClick}
/> />
</Velociraptor> </Velociraptor>
); );
/* onClick={this.props.onClick} */
}, },
}); });

View file

@ -301,8 +301,8 @@ module.exports = React.createClass({
var rightPanel_buttons; var rightPanel_buttons;
if (this.props.collapsedRhs) { if (this.props.collapsedRhs) {
rightPanel_buttons = rightPanel_buttons =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title="<"> <AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title="Show panel">
<TintableSvg src="img/minimise.svg" width="10" height="16"/> <TintableSvg src="img/maximise.svg" width="10" height="16"/>
</AccessibleButton>; </AccessibleButton>;
} }

View file

@ -146,7 +146,7 @@ module.exports = React.createClass({
<div> <div>
<div className="mx_RoomPreviewBar_join_text"> <div className="mx_RoomPreviewBar_join_text">
You are trying to access { name }.<br/> You are trying to access { name }.<br/>
Would you like to <a onClick={ this.props.onJoinClick }>join</a> in order to participate in the discussion? <a onClick={ this.props.onJoinClick }><b>Click here</b></a> to join the discussion!
</div> </div>
</div> </div>
); );

View file

@ -50,7 +50,7 @@ export function decryptMegolmKeyFile(data, password) {
} }
const ciphertextLength = body.length-(1+16+16+4+32); const ciphertextLength = body.length-(1+16+16+4+32);
if (body.length < 0) { if (ciphertextLength < 0) {
throw new Error('Invalid file: too short'); throw new Error('Invalid file: too short');
} }
@ -102,19 +102,19 @@ export function decryptMegolmKeyFile(data, password) {
*/ */
export function encryptMegolmKeyFile(data, password, options) { export function encryptMegolmKeyFile(data, password, options) {
options = options || {}; options = options || {};
const kdf_rounds = options.kdf_rounds || 100000; const kdf_rounds = options.kdf_rounds || 500000;
const salt = new Uint8Array(16); const salt = new Uint8Array(16);
window.crypto.getRandomValues(salt); window.crypto.getRandomValues(salt);
// clear bit 63 of the salt to stop us hitting the 64-bit counter boundary
// (which would mean we wouldn't be able to decrypt on Android). The loss
// of a single bit of salt is a price we have to pay.
salt[9] &= 0x7f;
const iv = new Uint8Array(16); const iv = new Uint8Array(16);
window.crypto.getRandomValues(iv); window.crypto.getRandomValues(iv);
// clear bit 63 of the IV to stop us hitting the 64-bit counter boundary
// (which would mean we wouldn't be able to decrypt on Android). The loss
// of a single bit of iv is a price we have to pay.
iv[9] &= 0x7f;
return deriveKeys(salt, kdf_rounds, password).then((keys) => { return deriveKeys(salt, kdf_rounds, password).then((keys) => {
const [aes_key, hmac_key] = keys; const [aes_key, hmac_key] = keys;
@ -164,6 +164,7 @@ export function encryptMegolmKeyFile(data, password, options) {
* @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key] * @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key]
*/ */
function deriveKeys(salt, iterations, password) { function deriveKeys(salt, iterations, password) {
const start = new Date();
return subtleCrypto.importKey( return subtleCrypto.importKey(
'raw', 'raw',
new TextEncoder().encode(password), new TextEncoder().encode(password),
@ -182,6 +183,9 @@ function deriveKeys(salt, iterations, password) {
512 512
); );
}).then((keybits) => { }).then((keybits) => {
const now = new Date();
console.log("E2e import/export: deriveKeys took " + (now - start) + "ms");
const aes_key = keybits.slice(0, 32); const aes_key = keybits.slice(0, 32);
const hmac_key = keybits.slice(32); const hmac_key = keybits.slice(32);

View file

@ -42,17 +42,12 @@ describe('RoomView', function () {
it('resolves a room alias to a room id', function (done) { it('resolves a room alias to a room id', function (done) {
peg.get().getRoomIdForAlias.returns(q({room_id: "!randomcharacters:aser.ver"})); peg.get().getRoomIdForAlias.returns(q({room_id: "!randomcharacters:aser.ver"}));
var onRoomIdResolved = sinon.spy(); function onRoomIdResolved(room_id) {
expect(room_id).toEqual("!randomcharacters:aser.ver");
done();
}
ReactDOM.render(<RoomView roomAddress="#alias:ser.ver" onRoomIdResolved={onRoomIdResolved} />, parentDiv); ReactDOM.render(<RoomView roomAddress="#alias:ser.ver" onRoomIdResolved={onRoomIdResolved} />, parentDiv);
process.nextTick(function() {
// These expect()s don't read very well and don't give very good failure
// messages, but expect's toHaveBeenCalled only takes an expect spy object,
// not a sinon spy object.
expect(onRoomIdResolved.called).toExist();
done();
});
}); });
it('joins by alias if given an alias', function (done) { it('joins by alias if given an alias', function (done) {

View file

@ -73,6 +73,7 @@ var Tester = React.createClass({
/* returns a promise which will resolve when the fill happens */ /* returns a promise which will resolve when the fill happens */
awaitFill: function(dir) { awaitFill: function(dir) {
console.log("ScrollPanel Tester: awaiting " + dir + " fill");
var defer = q.defer(); var defer = q.defer();
this._fillDefers[dir] = defer; this._fillDefers[dir] = defer;
return defer.promise; return defer.promise;
@ -80,7 +81,7 @@ var Tester = React.createClass({
_onScroll: function(ev) { _onScroll: function(ev) {
var st = ev.target.scrollTop; var st = ev.target.scrollTop;
console.log("Scroll event; scrollTop: " + st); console.log("ScrollPanel Tester: scroll event; scrollTop: " + st);
this.lastScrollEvent = st; this.lastScrollEvent = st;
var d = this._scrollDefer; var d = this._scrollDefer;
@ -159,10 +160,29 @@ describe('ScrollPanel', function() {
scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass(
tester, "gm-scroll-view"); tester, "gm-scroll-view");
// wait for a browser tick to let the initial paginates complete // we need to make sure we don't call done() until q has finished
setTimeout(function() { // running the completion handlers from the fill requests. We can't
done(); // just use .done(), because that will end up ahead of those handlers
}, 0); // in the queue. We can't use window.setTimeout(0), because that also might
// run ahead of those handlers.
const sp = tester.scrollPanel();
let retriesRemaining = 1;
const awaitReady = function() {
return q().then(() => {
if (sp._pendingFillRequests.b === false &&
sp._pendingFillRequests.f === false
) {
return;
}
if (retriesRemaining == 0) {
throw new Error("fillRequests did not complete");
}
retriesRemaining--;
return awaitReady();
});
};
awaitReady().done(done);
}); });
afterEach(function() { afterEach(function() {

View file

@ -99,7 +99,11 @@ describe('TimelinePanel', function() {
// the document so that we can interact with it properly. // the document so that we can interact with it properly.
parentDiv = document.createElement('div'); parentDiv = document.createElement('div');
parentDiv.style.width = '800px'; parentDiv.style.width = '800px';
parentDiv.style.height = '600px';
// This has to be slightly carefully chosen. We expect to have to do
// exactly one pagination to fill it.
parentDiv.style.height = '500px';
parentDiv.style.overflow = 'hidden'; parentDiv.style.overflow = 'hidden';
document.body.appendChild(parentDiv); document.body.appendChild(parentDiv);
}); });
@ -235,7 +239,7 @@ describe('TimelinePanel', function() {
expect(client.paginateEventTimeline.callCount).toEqual(0); expect(client.paginateEventTimeline.callCount).toEqual(0);
done(); done();
}, 0); }, 0);
}, 0); }, 10);
}); });
it("should let you scroll down to the bottom after you've scrolled up", function(done) { it("should let you scroll down to the bottom after you've scrolled up", function(done) {

View file

@ -14,7 +14,15 @@ var MatrixEvent = jssdk.MatrixEvent;
*/ */
export function beforeEach(context) { export function beforeEach(context) {
var desc = context.currentTest.fullTitle(); var desc = context.currentTest.fullTitle();
console.log(); console.log();
// this puts a mark in the chrome devtools timeline, which can help
// figure out what's been going on.
if (console.timeStamp) {
console.timeStamp(desc);
}
console.log(desc); console.log(desc);
console.log(new Array(1 + desc.length).join("=")); console.log(new Array(1 + desc.length).join("="));
}; };

View file

@ -75,6 +75,16 @@ describe('MegolmExportEncryption', function() {
.toThrow('Trailer line not found'); .toThrow('Trailer line not found');
}); });
it('should handle a too-short body', function() {
const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA-----
AXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx
cissyYBxjsfsAn
-----END MEGOLM SESSION DATA-----
`);
expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')})
.toThrow('Invalid file: too short');
});
it('should decrypt a range of inputs', function(done) { it('should decrypt a range of inputs', function(done) {
function next(i) { function next(i) {
if (i >= TEST_VECTORS.length) { if (i >= TEST_VECTORS.length) {