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
* 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"],
"new-cap": ["warn"],
"key-spacing": ["warn"],

View file

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

View file

@ -15,110 +15,143 @@ limitations under the License.
*/
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
* it's plain text.
*/
export default class Markdown {
constructor(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() {
// 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;
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;
}
const dummy_renderer = new commonmark.HtmlRenderer();
for (const k of Object.keys(commonmark.HtmlRenderer.prototype)) {
dummy_renderer[k] = setNotPlain;
} else {
return false;
}
// 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;
}
return true;
}
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
// 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.
var par = node;
while (par.parent) {
par = par.parent;
}
if (par.firstChild != par.lastChild) {
if (is_multi_line(node)) {
real_paragraph.call(this, node, entering);
}
};
var parsed = this.parser.parse(this.input);
var rendered = this.renderer.render(parsed);
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);
this.renderer.paragraph = real_paragraph;
return rendered;
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 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
// escaping function, which causes messages to be entity encoded,
// 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.
this.lit(s);
};
this.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.
var par = node;
while (par.parent) {
node = par;
par = par.parent;
}
if (node != par.lastChild) {
if (!entering) {
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');
}
var parsed = this.parser.parse(this.input);
var rendered = this.renderer.render(parsed);
this.renderer.paragraph = real_paragraph;
return rendered;
return renderer.render(this.parsed);
}
}

View file

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

View file

@ -34,7 +34,7 @@ module.exports = {
Modal.createDialog(UnknownDeviceDialog, {
devices: err.devices,
room: MatrixClientPeg.get().getRoom(event.getRoomId()),
});
}, "mx_Dialog_unknownDevice");
}
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;
}
});
//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;
}
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
self.children[c.key] = old;
} else {
// 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");
module.exports = {

View file

@ -71,7 +71,7 @@ export default React.createClass({
return this.props.matrixClient.exportRoomKeys();
}).then((k) => {
return MegolmExportEncryption.encryptMegolmKeyFile(
JSON.stringify(k), passphrase
JSON.stringify(k), passphrase,
);
}).then((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() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
const disableForm = (this.state.phase === PHASE_EXPORTING);
@ -159,10 +164,9 @@ export default React.createClass({
<input className='mx_Dialog_primary' type='submit' value='Export'
disabled={disableForm}
/>
<AccessibleButton element='button' onClick={this.props.onFinished}
disabled={disableForm}>
<button onClick={this._onCancelClick} disabled={disableForm}>
Cancel
</AccessibleButton>
</button>
</div>
</form>
</BaseDialog>

View file

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

View file

@ -105,6 +105,7 @@ var FilePanel = React.createClass({
showUrlPreview = { false }
tileShape="file_grid"
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}
collapsedRhs={this.props.collapse_rhs}
enableLabs={this.props.config.enableLabs}
referralBaseUrl={this.props.config.referralBaseUrl}
/>;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break;

View file

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

View file

@ -48,6 +48,7 @@ var NotificationPanel = React.createClass({
showUrlPreview = { false }
opacity={ this.props.opacity }
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
// not displaying anything
onHidden: React.PropTypes.func,
// callback for when the status bar is displaying something and should
// be visible
onVisible: React.PropTypes.func,
@ -113,7 +114,9 @@ module.exports = React.createClass({
clearTimeout(this.hideDebouncer);
}
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);
}
},
@ -238,7 +241,7 @@ module.exports = React.createClass({
if (othersCount > 0) {
avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining">
<span className="mx_RoomStatusBar_typingIndicatorRemaining" key="others">
+{othersCount}
</span>
);

View file

@ -1332,12 +1332,14 @@ module.exports = React.createClass({
},
onStatusBarVisible: function() {
if (this.unmounted) return;
this.setState({
statusBarVisible: true,
});
},
onStatusBarHidden: function() {
if (this.unmounted) return;
this.setState({
statusBarVisible: false,
});
@ -1507,13 +1509,14 @@ module.exports = React.createClass({
});
var statusBar;
let isStatusAreaExpanded = true;
if (ContentMessages.getCurrentUploads().length > 0) {
var UploadBar = sdk.getComponent('structures.UploadBar');
statusBar = <UploadBar room={this.state.room} />;
} else if (!this.state.searchResults) {
var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar');
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar
room={this.state.room}
tabComplete={this.tabComplete}
@ -1683,7 +1686,7 @@ module.exports = React.createClass({
);
}
let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable";
if (this.state.statusBarVisible) {
if (isStatusAreaExpanded) {
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.
// See _getExcessHeight.
const UNPAGINATION_PADDING = 1500;
const UNPAGINATION_PADDING = 3000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
@ -570,7 +570,7 @@ module.exports = React.createClass({
var boundingRect = node.getBoundingClientRect();
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+")");
if(scrollDelta != 0) {
@ -582,7 +582,7 @@ module.exports = React.createClass({
_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;
}
@ -601,12 +601,12 @@ module.exports = React.createClass({
trackedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
debuglog("Saved scroll state", this.scrollState);
debuglog("ScrollPanel: saved scroll state", this.scrollState);
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() {
@ -640,7 +640,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);
},

View file

@ -96,6 +96,9 @@ var TimelinePanel = React.createClass({
// shape property to be passed to EventTiles
tileShape: React.PropTypes.string,
// placeholder text to use if the timeline is empty
empty: React.PropTypes.string,
},
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
// live timeline, so that the arrival of new events triggers a
// scroll.

View file

@ -104,6 +104,9 @@ module.exports = React.createClass({
// True to show the 'labs' section of experimental features
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
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() {
var client = MatrixClientPeg.get();
@ -857,6 +881,8 @@ module.exports = React.createClass({
{accountJsx}
</div>
{this._renderReferral()}
{notification_area}
{this._renderUserInterfaceSettings()}

View file

@ -25,6 +25,7 @@ var ServerConfig = require("../../views/login/ServerConfig");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var RegistrationForm = require("../../views/login/RegistrationForm");
var CaptchaForm = require("../../views/login/CaptchaForm");
var RtsClient = require("../../../RtsClient");
var MIN_PASSWORD_LENGTH = 6;
@ -47,23 +48,16 @@ module.exports = React.createClass({
defaultIsUrl: React.PropTypes.string,
brand: React.PropTypes.string,
email: React.PropTypes.string,
referrer: React.PropTypes.string,
username: React.PropTypes.string,
guestAccessToken: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({
teamServerConfig: React.PropTypes.shape({
// Email address to request new teams
supportEmail: React.PropTypes.string,
teams: React.PropTypes.arrayOf(React.PropTypes.shape({
// The displayed name of the team
"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,
supportEmail: React.PropTypes.string.isRequired,
// URL of the riot-team-server to get team configurations and track referrals
teamServerURL: React.PropTypes.string.isRequired,
}),
teamSelected: React.PropTypes.object,
defaultDeviceDisplayName: React.PropTypes.string,
@ -75,6 +69,7 @@ module.exports = React.createClass({
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
@ -90,6 +85,7 @@ module.exports = React.createClass({
},
componentWillMount: function() {
this._unmounted = false;
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(
@ -103,10 +99,40 @@ module.exports = React.createClass({
this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
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() {
dis.unregister(this.dispatcherRef);
this._unmounted = true;
},
componentDidMount: function() {
@ -184,24 +210,41 @@ module.exports = React.createClass({
accessToken: response.access_token
});
// Auto-join rooms
if (self.props.teamsConfig && self.props.teamsConfig.teams) {
for (let i = 0; i < self.props.teamsConfig.teams.length; i++) {
let team = self.props.teamsConfig.teams[i];
if (self.state.formVals.email.endsWith(team.emailSuffix)) {
console.log("User successfully registered with team " + team.name);
if (
self._rtsClient &&
self.props.referrer &&
self.state.teamSelected
) {
// 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) {
break;
return;
}
// Auto-join rooms
team.rooms.forEach((room) => {
if (room.autoJoin) {
console.log("Auto-joining " + room.id);
MatrixClientPeg.get().joinRoom(room.id);
if (room.auto_join && room.room_id) {
console.log(`Auto-joining ${room.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) {
@ -273,7 +316,15 @@ module.exports = React.createClass({
});
},
onTeamSelected: function(teamSelected) {
if (!this._unmounted) {
this.setState({ teamSelected });
}
},
_getRegisterContentJsx: function() {
const Spinner = sdk.getComponent("elements.Spinner");
var currStep = this.registerLogic.getStep();
var registerStep;
switch (currStep) {
@ -283,17 +334,23 @@ module.exports = React.createClass({
case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading
// a guest account
if (this.state.teamServerBusy) {
registerStep = <Spinner />;
break;
}
registerStep = (
<RegistrationForm
showEmail={true}
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password}
teamsConfig={this.props.teamsConfig}
teamsConfig={this.state.teamsConfig}
guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} />
onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
/>
);
break;
case "Register.STEP_m.login.email.identity":
@ -322,7 +379,6 @@ module.exports = React.createClass({
}
var busySpinner;
if (this.state.busy) {
var Spinner = sdk.getComponent("elements.Spinner");
busySpinner = (
<Spinner />
);
@ -367,7 +423,7 @@ module.exports = React.createClass({
return (
<div className="mx_Login">
<div className="mx_Login_box">
<LoginHeader />
<LoginHeader icon={this.state.teamSelected ? this.state.teamSelected.icon : null}/>
{this._getRegisterContentJsx()}
<LoginFooter />
</div>

View file

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

View file

@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
var classNames = require('classnames');
var sdk = require("../../../index");
var Invite = require("../../../Invite");
var createRoom = require("../../../createRoom");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var DMRoomMap = require('../../../utils/DMRoomMap');
var rate_limited_func = require("../../../ratelimitedfunc");
var dis = require("../../../dispatcher");
var Modal = require('../../../Modal');
import React from 'react';
import classNames from 'classnames';
import sdk from '../../../index';
import { getAddressType, inviteMultipleToRoom } from '../../../Invite';
import createRoom from '../../../createRoom';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap';
import rate_limited_func from '../../../ratelimitedfunc';
import dis from '../../../dispatcher';
import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton';
import q from 'q';
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
// valid address, add an entry for that
if (queryList.length == 0) {
const addrType = Invite.getAddressType(query);
const addrType = getAddressType(query);
if (addrType !== null) {
queryList.push({
queryList[0] = {
addressType: addrType,
address: query,
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,
queryList: [],
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
};
},
@ -229,6 +235,7 @@ module.exports = React.createClass({
inviteList: inviteList,
queryList: [],
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
},
_getDirectMessageRoom: function(addr) {
@ -266,7 +273,7 @@ module.exports = React.createClass({
if (this.props.roomId) {
// Invite new user to a room
var self = this;
Invite.inviteMultipleToRoom(this.props.roomId, addrTexts)
inviteMultipleToRoom(this.props.roomId, addrTexts)
.then(function(addrs) {
var room = MatrixClientPeg.get().getRoom(self.props.roomId);
return self._showAnyInviteErrors(addrs, room);
@ -300,7 +307,7 @@ module.exports = React.createClass({
var room;
createRoom().then(function(roomId) {
room = MatrixClientPeg.get().getRoom(roomId);
return Invite.inviteMultipleToRoom(roomId, addrTexts);
return inviteMultipleToRoom(roomId, addrTexts);
})
.then(function(addrs) {
return self._showAnyInviteErrors(addrs, room);
@ -380,7 +387,7 @@ module.exports = React.createClass({
},
_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;
} else {
return false;
@ -408,7 +415,7 @@ module.exports = React.createClass({
_addInputToList: function() {
const addressText = this.refs.textinput.value.trim();
const addrType = Invite.getAddressType(addressText);
const addrType = getAddressType(addressText);
const addrObj = {
addressType: addrType,
address: addressText,
@ -432,9 +439,45 @@ module.exports = React.createClass({
inviteList: inviteList,
queryList: [],
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
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() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
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");
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 sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import GeminiScrollbar from 'react-gemini-scrollbar';
function DeviceListEntry(props) {
const {userId, device} = props;
@ -118,7 +119,19 @@ export default React.createClass({
</h4>
);
} 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');
@ -127,15 +140,16 @@ export default React.createClass({
onFinished={this.props.onFinished}
title='Room contains unknown devices'
>
<div className="mx_Dialog_content">
<GeminiScrollbar autoshow={false} className="mx_Dialog_content">
<h4>
This room contains unknown devices which have not been
verified.
</h4>
{ warning }
Unknown devices:
<UnknownDeviceList devices={this.props.devices} />
</div>
</GeminiScrollbar>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" autoFocus={ true }
onClick={ this.props.onFinished } >

View file

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

View file

@ -44,8 +44,8 @@ module.exports = React.createClass({
teams: React.PropTypes.arrayOf(React.PropTypes.shape({
// The displayed name of the team
"name": React.PropTypes.string,
// The suffix with which every team email address ends
"emailSuffix": React.PropTypes.string,
// The domain of team email addresses
"domain": React.PropTypes.string,
})).required,
}),
@ -117,9 +117,6 @@ module.exports = React.createClass({
_doSubmit: function() {
let email = this.refs.email.value.trim();
if (this.state.selectedTeam) {
email += "@" + this.state.selectedTeam.emailSuffix;
}
var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim() || this.props.guestUsername,
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
* they were validated.
@ -167,20 +145,36 @@ module.exports = React.createClass({
return true;
},
_isUniEmail: function(email) {
return email.endsWith('.ac.uk') || email.endsWith('.edu');
},
validateField: function(field_id) {
var pwd1 = this.refs.password.value.trim();
var pwd2 = this.refs.passwordConfirm.value.trim();
switch (field_id) {
case FIELD_EMAIL:
let email = this.refs.email.value;
if (this.props.teamsConfig) {
let team = this.state.selectedTeam;
if (team) {
email = email + "@" + team.emailSuffix;
const email = this.refs.email.value;
if (this.props.teamsConfig && this._isUniEmail(email)) {
const matchingTeam = this.props.teamsConfig.teams.find(
(team) => {
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");
break;
case FIELD_USERNAME:
@ -260,61 +254,35 @@ module.exports = React.createClass({
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() {
var self = this;
var emailSection, teamSection, teamAdditionSupport, registerButton;
var emailSection, belowEmailSection, registerButton;
if (this.props.showEmail) {
let emailSuffix = this._renderEmailInputSuffix();
emailSection = (
<div>
<input type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.props.defaultEmail}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_EMAIL);}}
value={self.state.email}/>
{emailSuffix ? <input className="mx_Login_field" value={emailSuffix} disabled/> : null }
</div>
);
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) {
teamAdditionSupport = (
<span>
If your team is not listed, email&nbsp;
belowEmailSection = (
<p className="mx_Login_support">
Sorry, but your university is not registered with us just yet.&nbsp;
Email us on&nbsp;
<a href={"mailto:" + this.props.teamsConfig.supportEmail}>
{this.props.teamsConfig.supportEmail}
</a>
</span>
</a>&nbsp;
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 (
<div>
<form onSubmit={this.onSubmit}>
{teamSection}
{teamAdditionSupport}
<br />
{emailSection}
<br />
{belowEmailSection}
<input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}

View file

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

View file

@ -170,15 +170,15 @@ module.exports = React.createClass({
let title;
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);
if (this.props.showFullTimestamp) {
// "15/12/2016, 7:05:45 PM (@alice:matrix.org)"
title = ts.toLocaleString() + suffix;
title = prefix + ts.toLocaleString();
}
else {
// "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"
style={style}
title={title}
onClick={this.props.onClick}
/>
</Velociraptor>
);
/* onClick={this.props.onClick} */
},
});

View file

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

View file

@ -146,7 +146,7 @@ module.exports = React.createClass({
<div>
<div className="mx_RoomPreviewBar_join_text">
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>
);

View file

@ -50,7 +50,7 @@ export function decryptMegolmKeyFile(data, password) {
}
const ciphertextLength = body.length-(1+16+16+4+32);
if (body.length < 0) {
if (ciphertextLength < 0) {
throw new Error('Invalid file: too short');
}
@ -102,19 +102,19 @@ export function decryptMegolmKeyFile(data, password) {
*/
export function encryptMegolmKeyFile(data, password, options) {
options = options || {};
const kdf_rounds = options.kdf_rounds || 100000;
const kdf_rounds = options.kdf_rounds || 500000;
const salt = new Uint8Array(16);
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);
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) => {
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]
*/
function deriveKeys(salt, iterations, password) {
const start = new Date();
return subtleCrypto.importKey(
'raw',
new TextEncoder().encode(password),
@ -182,6 +183,9 @@ function deriveKeys(salt, iterations, password) {
512
);
}).then((keybits) => {
const now = new Date();
console.log("E2e import/export: deriveKeys took " + (now - start) + "ms");
const aes_key = keybits.slice(0, 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) {
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);
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) {

View file

@ -73,6 +73,7 @@ var Tester = React.createClass({
/* returns a promise which will resolve when the fill happens */
awaitFill: function(dir) {
console.log("ScrollPanel Tester: awaiting " + dir + " fill");
var defer = q.defer();
this._fillDefers[dir] = defer;
return defer.promise;
@ -80,7 +81,7 @@ var Tester = React.createClass({
_onScroll: function(ev) {
var st = ev.target.scrollTop;
console.log("Scroll event; scrollTop: " + st);
console.log("ScrollPanel Tester: scroll event; scrollTop: " + st);
this.lastScrollEvent = st;
var d = this._scrollDefer;
@ -159,10 +160,29 @@ describe('ScrollPanel', function() {
scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass(
tester, "gm-scroll-view");
// wait for a browser tick to let the initial paginates complete
setTimeout(function() {
done();
}, 0);
// we need to make sure we don't call done() until q has finished
// running the completion handlers from the fill requests. We can't
// just use .done(), because that will end up ahead of those handlers
// 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() {

View file

@ -99,7 +99,11 @@ describe('TimelinePanel', function() {
// the document so that we can interact with it properly.
parentDiv = document.createElement('div');
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';
document.body.appendChild(parentDiv);
});
@ -235,7 +239,7 @@ describe('TimelinePanel', function() {
expect(client.paginateEventTimeline.callCount).toEqual(0);
done();
}, 0);
}, 0);
}, 10);
});
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) {
var desc = context.currentTest.fullTitle();
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(new Array(1 + desc.length).join("="));
};

View file

@ -75,6 +75,16 @@ describe('MegolmExportEncryption', function() {
.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) {
function next(i) {
if (i >= TEST_VECTORS.length) {