Merge branch 'develop' into kegan/indexeddb

This commit is contained in:
Kegan Dougal 2017-02-10 16:16:17 +00:00
commit f628ee2ef0
50 changed files with 1288 additions and 368 deletions

BIN
.DS_Store vendored

Binary file not shown.

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

@ -1,3 +1,141 @@
Changes in [0.8.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6) (2017-02-04)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.3...v0.8.6)
* Update to matrix-js-sdk 0.7.5 (no changes from 0.7.5-rc.3)
Changes in [0.8.6-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.3) (2017-02-03)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.2...v0.8.6-rc.3)
* Update to matrix-js-sdk 0.7.5-rc.3
* Fix deviceverifybuttons
[5fd7410](https://github.com/matrix-org/matrix-react-sdk/commit/827b5a6811ac6b9d1f9a3002a94f9f6ac3f1d49c)
Changes in [0.8.6-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.2) (2017-02-03)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.1...v0.8.6-rc.2)
* Update to new matrix-js-sdk to get support for new device change notifications interface
Changes in [0.8.6-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.1) (2017-02-03)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.5...v0.8.6-rc.1)
* Fix timeline & notifs panel spuriously being empty
[\#675](https://github.com/matrix-org/matrix-react-sdk/pull/675)
* UI for blacklisting unverified devices per-room & globally
[\#636](https://github.com/matrix-org/matrix-react-sdk/pull/636)
* Show better error message in statusbar after UnkDevDialog
[\#674](https://github.com/matrix-org/matrix-react-sdk/pull/674)
* Make default avatars clickable
[\#673](https://github.com/matrix-org/matrix-react-sdk/pull/673)
* Fix one read receipt randomly not appearing
[\#672](https://github.com/matrix-org/matrix-react-sdk/pull/672)
* very barebones support for warning users when rooms contain unknown devices
[\#635](https://github.com/matrix-org/matrix-react-sdk/pull/635)
* Fix expanding/unexapnding read receipts
[\#671](https://github.com/matrix-org/matrix-react-sdk/pull/671)
* show placeholder when timeline empty
[\#670](https://github.com/matrix-org/matrix-react-sdk/pull/670)
* Make read receipt's titles more explanatory
[\#669](https://github.com/matrix-org/matrix-react-sdk/pull/669)
* Fix spurious HTML tags being passed through literally
[\#667](https://github.com/matrix-org/matrix-react-sdk/pull/667)
* Reinstate max-len lint configs
[\#665](https://github.com/matrix-org/matrix-react-sdk/pull/665)
* Throw errors on !==200 status codes from RTS
[\#662](https://github.com/matrix-org/matrix-react-sdk/pull/662)
* Exempt lines which look like pure JSX from the maxlen line
[\#664](https://github.com/matrix-org/matrix-react-sdk/pull/664)
* Make tests pass on Chrome again
[\#663](https://github.com/matrix-org/matrix-react-sdk/pull/663)
* Add referral section to user settings
[\#661](https://github.com/matrix-org/matrix-react-sdk/pull/661)
* Two megolm export fixes:
[\#660](https://github.com/matrix-org/matrix-react-sdk/pull/660)
* GET /teams from RTS instead of config.json
[\#658](https://github.com/matrix-org/matrix-react-sdk/pull/658)
* Guard onStatusBarVisible/Hidden with this.unmounted
[\#656](https://github.com/matrix-org/matrix-react-sdk/pull/656)
* Fix cancel button on e2e import/export dialogs
[\#654](https://github.com/matrix-org/matrix-react-sdk/pull/654)
* Look up email addresses in ChatInviteDialog
[\#653](https://github.com/matrix-org/matrix-react-sdk/pull/653)
* Move BugReportDialog to riot-web
[\#652](https://github.com/matrix-org/matrix-react-sdk/pull/652)
* Fix dark theme styling of roomheader cancel button
[\#651](https://github.com/matrix-org/matrix-react-sdk/pull/651)
* Allow modals to stack up
[\#649](https://github.com/matrix-org/matrix-react-sdk/pull/649)
* Add bug report UI
[\#642](https://github.com/matrix-org/matrix-react-sdk/pull/642)
* Better feedback in invite dialog
[\#625](https://github.com/matrix-org/matrix-react-sdk/pull/625)
* Import and export for Megolm session data
[\#647](https://github.com/matrix-org/matrix-react-sdk/pull/647)
* Overhaul MELS to deal with causality, kicks, etc.
[\#613](https://github.com/matrix-org/matrix-react-sdk/pull/613)
* Re-add dispatcher as alt-up/down uses it
[\#650](https://github.com/matrix-org/matrix-react-sdk/pull/650)
* Create a common BaseDialog
[\#645](https://github.com/matrix-org/matrix-react-sdk/pull/645)
* Fix SetDisplayNameDialog
[\#648](https://github.com/matrix-org/matrix-react-sdk/pull/648)
* Sync typing indication with avatar typing indication
[\#643](https://github.com/matrix-org/matrix-react-sdk/pull/643)
* Warn users of E2E key loss when changing/resetting passwords or logging out
[\#646](https://github.com/matrix-org/matrix-react-sdk/pull/646)
* Better user interface for screen readers and keyboard navigation
[\#616](https://github.com/matrix-org/matrix-react-sdk/pull/616)
* Reduce log spam: Revert a16aeeef2a0f16efedf7e6616cdf3c2c8752a077
[\#644](https://github.com/matrix-org/matrix-react-sdk/pull/644)
* Expand timeline in situations when _getIndicator not null
[\#641](https://github.com/matrix-org/matrix-react-sdk/pull/641)
* Correctly get the path of the js-sdk .eslintrc.js
[\#640](https://github.com/matrix-org/matrix-react-sdk/pull/640)
* Add 'searching known users' to the user picker
[\#621](https://github.com/matrix-org/matrix-react-sdk/pull/621)
* Add mocha env for tests in eslint config
[\#639](https://github.com/matrix-org/matrix-react-sdk/pull/639)
* Fix typing avatars displaying "me"
[\#637](https://github.com/matrix-org/matrix-react-sdk/pull/637)
* Fix device verification from e2e info
[\#638](https://github.com/matrix-org/matrix-react-sdk/pull/638)
* Make user search do a bit better on word boundary
[\#623](https://github.com/matrix-org/matrix-react-sdk/pull/623)
* Use an eslint config based on the js-sdk
[\#634](https://github.com/matrix-org/matrix-react-sdk/pull/634)
* Fix error display in account deactivate dialog
[\#633](https://github.com/matrix-org/matrix-react-sdk/pull/633)
* Configure travis to test riot-web after building
[\#629](https://github.com/matrix-org/matrix-react-sdk/pull/629)
* Sanitize ChatInviteDialog
[\#626](https://github.com/matrix-org/matrix-react-sdk/pull/626)
* (hopefully) fix theming on Chrome
[\#630](https://github.com/matrix-org/matrix-react-sdk/pull/630)
* Megolm session import and export
[\#617](https://github.com/matrix-org/matrix-react-sdk/pull/617)
* Allow Modal to be used with async-loaded components
[\#618](https://github.com/matrix-org/matrix-react-sdk/pull/618)
* Fix escaping markdown by rendering plaintext
[\#622](https://github.com/matrix-org/matrix-react-sdk/pull/622)
* Implement auto-join rooms on registration
[\#628](https://github.com/matrix-org/matrix-react-sdk/pull/628)
* Matthew/fix theme npe
[\#627](https://github.com/matrix-org/matrix-react-sdk/pull/627)
* Implement theming via alternate stylesheets
[\#624](https://github.com/matrix-org/matrix-react-sdk/pull/624)
* Replace marked with commonmark
[\#575](https://github.com/matrix-org/matrix-react-sdk/pull/575)
* Fix vector-im/riot-web#2833 : Fail nicely when people try to register
numeric user IDs
[\#619](https://github.com/matrix-org/matrix-react-sdk/pull/619)
* Show the error dialog when requests to PUT power levels fail
[\#614](https://github.com/matrix-org/matrix-react-sdk/pull/614)
Changes in [0.8.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.5) (2017-01-16)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.5-rc.1...v0.8.5)

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

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "0.8.5",
"version": "0.8.6",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {

View file

@ -290,7 +290,7 @@ export function bodyToHtml(content, highlights, opts) {
}
EMOJI_REGEX.lastIndex = 0;
let contentBodyTrimmed = content.body.trim();
let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : '';
let match = EMOJI_REGEX.exec(contentBodyTrimmed);
let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;

View file

@ -23,6 +23,7 @@ import UserActivity from './UserActivity';
import Presence from './Presence';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import RtsClient from './RtsClient';
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -229,6 +230,11 @@ function _restoreFromLocalStorage() {
}
}
let rtsClient = null;
export function initRtsClient(url) {
rtsClient = new RtsClient(url);
}
/**
* Transitions to a logged-in state using the given credentials
* @param {MatrixClientCreds} credentials The credentials to use
@ -261,6 +267,19 @@ export function setLoggedIn(credentials) {
} catch (e) {
console.warn("Error using local storage: can't persist session!", e);
}
if (rtsClient) {
rtsClient.login(credentials.userId).then((body) => {
if (body.team_token) {
localStorage.setItem("mx_team_token", body.team_token);
}
}, (err) =>{
console.error(
"Failed to get team token on login, not persisting to localStorage",
err
);
});
}
} else {
console.warn("No local storage available: can't persist session!");
}

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;
}
} else {
return false;
}
}
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;
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;
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() {
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

@ -43,7 +43,13 @@ const AsyncWrapper = React.createClass({
componentWillMount: function() {
this._unmounted = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Starting load of AsyncWrapper for modal');
this.props.loader((e) => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('AsyncWrapper load completed with '+e.displayName);
if (this._unmounted) {
return;
}
@ -177,7 +183,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

@ -16,6 +16,7 @@ limitations under the License.
/** The types of page which can be shown by the LoggedInView */
export default {
HomePage: "home_page",
RoomView: "room_view",
UserSettings: "user_settings",
CreateRoom: "create_room",

View file

@ -16,17 +16,35 @@ limitations under the License.
var MatrixClientPeg = require('./MatrixClientPeg');
var dis = require('./dispatcher');
var sdk = require('./index');
var Modal = require('./Modal');
module.exports = {
resend: function(event) {
MatrixClientPeg.get().resendEvent(
event, MatrixClientPeg.get().getRoom(event.getRoomId())
).done(function() {
).done(function(res) {
dis.dispatch({
action: 'message_sent',
event: event
});
}, function() {
}, function(err) {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") {
var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog");
Modal.createDialog(UnknownDeviceDialog, {
devices: err.devices,
room: MatrixClientPeg.get().getRoom(event.getRoomId()),
onFinished: (r) => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('UnknownDeviceDialog closed with '+r);
},
}, "mx_Dialog_unknownDevice");
}
dis.dispatch({
action: 'message_send_failed',
event: event

97
src/RtsClient.js Normal file
View file

@ -0,0 +1,97 @@
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,
},
}
);
}
/**
* Signal to the RTS that a login has occurred and that a user requires their team's
* token.
* @param {string} userId the user ID of the user who is a member of a team.
* @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon
* success.
*/
login(userId) {
return request(this._url + '/login',
{
qs: {
user_id: userId,
},
}
);
}
}

View file

@ -91,6 +91,10 @@ class Register extends Signup {
this.params.idSid = idSid;
}
setReferrer(referrer) {
this.params.referrer = referrer;
}
setGuestAccessToken(token) {
this.guestAccessToken = token;
}

View file

@ -136,6 +136,11 @@ class EmailIdentityStage extends Stage {
"&session_id=" +
encodeURIComponent(this.signupInstance.getServerData().session);
// Add the user ID of the referring user, if set
if (this.signupInstance.params.referrer) {
nextLink += "&referrer=" + encodeURIComponent(this.signupInstance.params.referrer);
}
var self = this;
return this.client.requestRegisterEmailToken(
this.signupInstance.email,

View file

@ -149,6 +149,23 @@ module.exports = {
return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings);
},
getLocalSettings: function() {
var localSettingsString = localStorage.getItem('mx_local_settings') || '{}';
return JSON.parse(localSettingsString);
},
getLocalSetting: function(type, defaultValue = null) {
var settings = this.getLocalSettings();
return settings.hasOwnProperty(type) ? settings[type] : null;
},
setLocalSetting: function(type, value) {
var settings = this.getLocalSettings();
settings[type] = value;
// FIXME: handle errors
localStorage.setItem('mx_local_settings', JSON.stringify(settings));
},
isFeatureEnabled: function(feature: string): boolean {
// Disable labs for guests.
if (MatrixClientPeg.get().isGuest()) return false;

View file

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

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 = {
@ -32,10 +48,11 @@ module.exports = {
return whoIsTyping;
},
whoIsTypingString: function(room, limit) {
const whoIsTyping = this.usersTypingApartFromMe(room);
const othersCount = limit === undefined ?
0 : Math.max(whoIsTyping.length - limit, 0);
whoIsTypingString: function(whoIsTyping, limit) {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
if (whoIsTyping.length == 0) {
return '';
} else if (whoIsTyping.length == 1) {
@ -46,7 +63,7 @@ module.exports = {
});
if (othersCount) {
const other = ' other' + (othersCount > 1 ? 's' : '');
return names.slice(0, limit).join(', ') + ' and ' +
return names.slice(0, limit - 1).join(', ') + ' and ' +
othersCount + other + ' are typing';
} else {
const lastPerson = names.pop();

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

@ -89,6 +89,8 @@ import views$dialogs$SetDisplayNameDialog from './components/views/dialogs/SetDi
views$dialogs$SetDisplayNameDialog && (module.exports.components['views.dialogs.SetDisplayNameDialog'] = views$dialogs$SetDisplayNameDialog);
import views$dialogs$TextInputDialog from './components/views/dialogs/TextInputDialog';
views$dialogs$TextInputDialog && (module.exports.components['views.dialogs.TextInputDialog'] = views$dialogs$TextInputDialog);
import views$dialogs$UnknownDeviceDialog from './components/views/dialogs/UnknownDeviceDialog';
views$dialogs$UnknownDeviceDialog && (module.exports.components['views.dialogs.UnknownDeviceDialog'] = views$dialogs$UnknownDeviceDialog);
import views$elements$AccessibleButton from './components/views/elements/AccessibleButton';
views$elements$AccessibleButton && (module.exports.components['views.elements.AccessibleButton'] = views$elements$AccessibleButton);
import views$elements$AddressSelector from './components/views/elements/AddressSelector';

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

@ -42,6 +42,8 @@ export default React.createClass({
onRoomCreated: React.PropTypes.func,
onUserSettingsClose: React.PropTypes.func,
teamToken: React.PropTypes.string,
// and lots and lots of other stuff.
},
@ -137,6 +139,7 @@ export default React.createClass({
var UserSettings = sdk.getComponent('structures.UserSettings');
var CreateRoom = sdk.getComponent('structures.CreateRoom');
var RoomDirectory = sdk.getComponent('structures.RoomDirectory');
var HomePage = sdk.getComponent('structures.HomePage');
var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
var GuestWarningBar = sdk.getComponent('globals.GuestWarningBar');
var NewVersionBar = sdk.getComponent('globals.NewVersionBar');
@ -171,6 +174,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;
@ -190,6 +194,16 @@ export default React.createClass({
/>;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break;
case PageTypes.HomePage:
page_element = <HomePage
collapsedRhs={this.props.collapse_rhs}
teamServerUrl={this.props.config.teamServerConfig.teamServerURL}
teamToken={this.props.teamToken}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>
break;
case PageTypes.UserView:
page_element = null; // deliberately null for now
right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.sideOpacity} />;
@ -218,7 +232,12 @@ export default React.createClass({
<div className='mx_MatrixChat_wrapper'>
{topBar}
<div className={bodyClasses}>
<LeftPanel selectedRoom={this.props.currentRoomId} collapsed={this.props.collapse_lhs || false} opacity={this.props.sideOpacity}/>
<LeftPanel
selectedRoom={this.props.currentRoomId}
collapsed={this.props.collapse_lhs || false}
opacity={this.props.sideOpacity}
teamToken={this.props.teamToken}
/>
<main className='mx_MatrixChat_middlePanel'>
{page_element}
</main>

View file

@ -190,6 +190,20 @@ module.exports = React.createClass({
if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
}
// Persist the team token across refreshes using sessionStorage. A new window or
// tab will not persist sessionStorage, but refreshes will.
if (this.props.startingFragmentQueryParams.team_token) {
window.sessionStorage.setItem(
'mx_team_token',
this.props.startingFragmentQueryParams.team_token,
);
}
// Use the locally-stored team token first, then as a fall-back, check to see if
// a referral link was used, which will contain a query parameter `team_token`.
this._teamToken = window.localStorage.getItem('mx_team_token') ||
window.sessionStorage.getItem('mx_team_token');
},
componentDidMount: function() {
@ -210,6 +224,12 @@ module.exports = React.createClass({
window.addEventListener('resize', this.handleResize);
this.handleResize();
if (this.props.config.teamServerConfig &&
this.props.config.teamServerConfig.teamServerURL
) {
Lifecycle.initRtsClient(this.props.config.teamServerConfig.teamServerURL);
}
// the extra q() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones.
q().then(() => {
@ -421,6 +441,10 @@ module.exports = React.createClass({
this._setPage(PageTypes.RoomDirectory);
this.notifyNewScreen('directory');
break;
case 'view_home_page':
this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
break;
case 'view_create_chat':
this._createChat();
break;
@ -690,7 +714,11 @@ module.exports = React.createClass({
)[0].roomId;
self.setState({ready: true, currentRoomId: firstRoom, page_type: PageTypes.RoomView});
} else {
self.setState({ready: true, page_type: PageTypes.RoomDirectory});
if (self._teamToken) {
self.setState({ready: true, page_type: PageTypes.HomePage});
} else {
self.setState({ready: true, page_type: PageTypes.RoomDirectory});
}
}
} else {
self.setState({ready: true, page_type: PageTypes.RoomView});
@ -710,7 +738,11 @@ module.exports = React.createClass({
} else {
// There is no information on presentedId
// so point user to fallback like /directory
self.notifyNewScreen('directory');
if (self._teamToken) {
self.notifyNewScreen('home');
} else {
self.notifyNewScreen('directory');
}
}
dis.dispatch({action: 'focus_composer'});
@ -774,6 +806,10 @@ module.exports = React.createClass({
dis.dispatch({
action: 'view_user_settings',
});
} else if (screen == 'home') {
dis.dispatch({
action: 'view_home_page',
});
} else if (screen == 'directory') {
dis.dispatch({
action: 'view_room_directory',
@ -1033,6 +1069,7 @@ module.exports = React.createClass({
onRoomIdResolved={this.onRoomIdResolved}
onRoomCreated={this.onRoomCreated}
onUserSettingsClose={this.onUserSettingsClose}
teamToken={this._teamToken}
{...this.props}
{...this.state}
/>
@ -1055,12 +1092,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

@ -39,8 +39,8 @@ module.exports = React.createClass({
// the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number,
// true if there are messages in the room which had errors on send
hasUnsentMessages: React.PropTypes.bool,
// string to display when there are messages in the room which had errors on send
unsentMessageError: React.PropTypes.string,
// this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline.
@ -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,
@ -81,17 +82,14 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
whoIsTypingLimit: 2,
whoIsTypingLimit: 3,
};
},
getInitialState: function() {
return {
syncState: MatrixClientPeg.get().getSyncState(),
whoisTypingString: WhoIsTyping.whoIsTypingString(
this.props.room,
this.props.whoIsTypingLimit
),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
};
},
@ -105,7 +103,7 @@ module.exports = React.createClass({
this.props.onResize();
}
const size = this._getSize(this.state, this.props);
const size = this._getSize(this.props, this.state);
if (size > 0) {
this.props.onVisible();
} else {
@ -113,7 +111,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);
}
},
@ -138,26 +138,23 @@ module.exports = React.createClass({
onRoomMemberTyping: function(ev, member) {
this.setState({
whoisTypingString: WhoIsTyping.whoIsTypingString(
this.props.room,
this.props.whoIsTypingLimit
),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
});
},
// We don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize: function(state, props) {
_getSize: function(props, state) {
if (state.syncState === "ERROR" ||
state.whoisTypingString ||
(state.usersTyping.length > 0) ||
props.numUnreadMessages ||
!props.atEndOfLiveTimeline ||
props.hasActiveCall) {
return STATUS_BAR_EXPANDED;
} else if (props.tabCompleteEntries) {
return STATUS_BAR_HIDDEN;
} else if (props.hasUnsentMessages) {
} else if (props.unsentMessageError) {
return STATUS_BAR_EXPANDED_LARGE;
}
return STATUS_BAR_HIDDEN;
@ -166,7 +163,8 @@ module.exports = React.createClass({
// determine if we need to call onResize
_checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar.
return this._getSize(prevProps, prevState) !== this._getSize(this.props, this.state);
return this._getSize(prevProps, prevState)
!== this._getSize(this.props, this.state);
},
// return suitable content for the image on the left of the status bar.
@ -217,10 +215,13 @@ module.exports = React.createClass({
},
_renderTypingIndicatorAvatars: function(limit) {
let users = WhoIsTyping.usersTypingApartFromMe(this.props.room);
let users = this.state.usersTyping;
let othersCount = Math.max(users.length - limit, 0);
users = users.slice(0, limit);
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
users = users.slice(0, limit - 1);
}
let avatars = users.map((u, index) => {
let showInitial = othersCount === 0 && index === users.length - 1;
@ -238,7 +239,7 @@ module.exports = React.createClass({
if (othersCount > 0) {
avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining">
<span className="mx_RoomStatusBar_typingIndicatorRemaining" key="others">
+{othersCount}
</span>
);
@ -285,12 +286,12 @@ module.exports = React.createClass({
);
}
if (this.props.hasUnsentMessages) {
if (this.props.unsentMessageError) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ "/>
<div className="mx_RoomStatusBar_connectionLostBar_title">
Some of your messages have not been sent.
{ this.props.unsentMessageError }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
<a className="mx_RoomStatusBar_resend_link"
@ -321,7 +322,10 @@ module.exports = React.createClass({
);
}
var typingString = this.state.whoisTypingString;
const typingString = WhoIsTyping.whoIsTypingString(
this.state.usersTyping,
this.props.whoIsTypingLimit
);
if (typingString) {
return (
<div className="mx_RoomStatusBar_typingBar">
@ -344,7 +348,7 @@ module.exports = React.createClass({
render: function() {
var content = this._getContent();
var indicator = this._getIndicator(this.state.whoisTypingString !== null);
var indicator = this._getIndicator(this.state.usersTyping.length > 0);
return (
<div className="mx_RoomStatusBar">

View file

@ -128,7 +128,7 @@ module.exports = React.createClass({
draggingFile: false,
searching: false,
searchResults: null,
hasUnsentMessages: false,
unsentMessageError: '',
callState: null,
guestsCanJoin: false,
canPeek: false,
@ -182,7 +182,7 @@ module.exports = React.createClass({
room: room,
roomId: result.room_id,
roomLoading: !room,
hasUnsentMessages: this._hasUnsentMessages(room),
unsentMessageError: this._getUnsentMessageError(room),
}, this._onHaveRoom);
}, (err) => {
this.setState({
@ -196,7 +196,7 @@ module.exports = React.createClass({
roomId: this.props.roomAddress,
room: room,
roomLoading: !room,
hasUnsentMessages: this._hasUnsentMessages(room),
unsentMessageError: this._getUnsentMessageError(room),
}, this._onHaveRoom);
}
},
@ -397,7 +397,7 @@ module.exports = React.createClass({
case 'message_sent':
case 'message_send_cancelled':
this.setState({
hasUnsentMessages: this._hasUnsentMessages(this.state.room)
unsentMessageError: this._getUnsentMessageError(this.state.room),
});
break;
case 'notifier_enabled':
@ -636,8 +636,15 @@ module.exports = React.createClass({
}
}, 500),
_hasUnsentMessages: function(room) {
return this._getUnsentMessages(room).length > 0;
_getUnsentMessageError: function(room) {
const unsentMessages = this._getUnsentMessages(room);
if (!unsentMessages.length) return "";
for (const event of unsentMessages) {
if (!event.error || event.error.name !== "UnknownDeviceError") {
return "Some of your messages have not been sent.";
}
}
return "Message not sent due to unknown devices being present";
},
_getUnsentMessages: function(room) {
@ -1332,12 +1339,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,18 +1516,19 @@ 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}
numUnreadMessages={this.state.numUnreadMessages}
hasUnsentMessages={this.state.hasUnsentMessages}
unsentMessageError={this.state.unsentMessageError}
atEndOfLiveTimeline={this.state.atEndOfLiveTimeline}
hasActiveCall={inCall}
onResendAllClick={this.onResendAllClick}
@ -1527,7 +1537,7 @@ module.exports = React.createClass({
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
whoIsTypingLimit={2}
whoIsTypingLimit={3}
/>;
}
@ -1683,7 +1693,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 && !this.state.canBackPaginate && this.props.empty) {
return (
<div className={ this.props.className + " mx_RoomView_messageListWrapper" }>
<div className="mx_RoomView_empty">{ this.props.empty }</div>
</div>
);
}
// give the messagepanel a stickybottom if we're at the end of the
// live timeline, so that the arrival of new events triggers a
// scroll.

View file

@ -90,8 +90,8 @@ module.exports = React.createClass({displayName: 'UploadBar',
<div className="mx_UploadBar_uploadProgressOuter">
<div className="mx_UploadBar_uploadProgressInner" style={innerProgressStyle}></div>
</div>
<img className="mx_UploadBar_uploadIcon" src="img/fileicon.png" width="17" height="22"/>
<img className="mx_UploadBar_uploadCancel" src="img/cancel.svg" width="18" height="18"
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src="img/fileicon.png" width="17" height="22"/>
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src="img/cancel.svg" width="18" height="18"
onClick={function() { ContentMessages.cancelUpload(upload.promise); }}
/>
<div className="mx_UploadBar_uploadBytes">

View file

@ -59,6 +59,18 @@ const SETTINGS_LABELS = [
*/
];
const CRYPTO_SETTINGS_LABELS = [
{
id: 'blacklistUnverifiedDevices',
label: 'Never send encrypted messages to unverified devices from this device',
},
// XXX: this is here for documentation; the actual setting is managed via RoomSettings
// {
// id: 'blacklistUnverifiedDevicesPerRoom'
// label: 'Never send encrypted messages to unverified devices in this room',
// }
];
// Enumerate the available themes, with a nice human text label.
// 'id' gives the key name in the im.vector.web.settings account data event
// 'value' is the value for that key in the event
@ -92,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,
},
@ -148,6 +163,8 @@ module.exports = React.createClass({
syncedSettings.theme = 'light';
}
this._syncedSettings = syncedSettings;
this._localSettings = UserSettingsStore.getLocalSettings();
},
componentDidMount: function() {
@ -444,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();
@ -514,21 +552,20 @@ module.exports = React.createClass({
const deviceId = client.deviceId;
const identityKey = client.getDeviceEd25519Key() || "<not supported>";
let exportButton = null,
importButton = null;
let importExportButtons = null;
if (client.isCryptoEnabled) {
exportButton = (
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</AccessibleButton>
);
importButton = (
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onImportE2eKeysClicked}>
Import E2E room keys
</AccessibleButton>
importExportButtons = (
<div className="mx_UserSettings_importExportButtons">
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</AccessibleButton>
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onImportE2eKeysClicked}>
Import E2E room keys
</AccessibleButton>
</div>
);
}
return (
@ -539,13 +576,36 @@ module.exports = React.createClass({
<li><label>Device ID:</label> <span><code>{deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{identityKey}</b></code></span></li>
</ul>
{exportButton}
{importButton}
{ importExportButtons }
</div>
<div className="mx_UserSettings_section">
{ CRYPTO_SETTINGS_LABELS.map( this._renderLocalSetting ) }
</div>
</div>
);
},
_renderLocalSetting: function(setting) {
const client = MatrixClientPeg.get();
return <div className="mx_UserSettings_toggle" key={ setting.id }>
<input id={ setting.id }
type="checkbox"
defaultChecked={ this._localSettings[setting.id] }
onChange={
e => {
UserSettingsStore.setLocalSetting(setting.id, e.target.checked)
if (setting.id === 'blacklistUnverifiedDevices') { // XXX: this is a bit ugly
client.setGlobalBlacklistUnverifiedDevices(e.target.checked);
}
}
}
/>
<label htmlFor={ setting.id }>
{ setting.label }
</label>
</div>;
},
_renderDevicesPanel: function() {
var DevicesPanel = sdk.getComponent('settings.DevicesPanel');
return (
@ -819,6 +879,8 @@ module.exports = React.createClass({
{accountJsx}
</div>
{this._renderReferral()}
{notification_area}
{this._renderUserInterfaceSettings()}
@ -842,7 +904,7 @@ module.exports = React.createClass({
</div>
<div className="mx_UserSettings_advanced">
matrix-react-sdk version: {REACT_SDK_VERSION}<br/>
vector-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}<br/>
riot-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}<br/>
olm version: {olmVersionString}<br/>
</div>
</div>

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(
@ -102,11 +98,44 @@ module.exports = React.createClass({
this.registerLogic.setRegistrationUrl(this.props.registrationUrl);
this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
if (this.props.referrer) {
this.registerLogic.setReferrer(this.props.referrer);
}
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 +213,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 +319,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 +337,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 +382,6 @@ module.exports = React.createClass({
}
var busySpinner;
if (this.state.busy) {
var Spinner = sdk.getComponent("elements.Spinner");
busySpinner = (
<Spinner />
);
@ -367,7 +426,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}>
<EmojiText className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (width * 0.65) + "px",
width: width + "px",
lineHeight: height + "px" }}>{initialLetter}</EmojiText>
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
width={width} height={height} />
</span>
const textNode = (
<EmojiText className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (width * 0.65) + "px",
width: width + "px",
lineHeight: height + "px" }}
>
{initialLetter}
</EmojiText>
);
const imgNode = (
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
width={width} height={height} />
);
if (onClick != null) {
return (
<AccessibleButton element='span' className="mx_BaseAvatar"
onClick={onClick} {...otherProps}
>
{textNode}
{imgNode}
</AccessibleButton>
);
} else {
return (
<span className="mx_BaseAvatar" {...otherProps}>
{textNode}
{imgNode}
</span>
);
}
}
if (onClick != null) {
return (
<AccessibleButton className="mx_BaseAvatar" onClick={onClick}>
<img className="mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
</AccessibleButton>
<AccessibleButton className="mx_BaseAvatar mx_BaseAvatar_image"
element='img'
src={imageUrl}
onClick={onClick}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
);
} else {
return (

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

@ -0,0 +1,178 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import GeminiScrollbar from 'react-gemini-scrollbar';
function DeviceListEntry(props) {
const {userId, device} = props;
const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
return (
<li>
<DeviceVerifyButtons device={ device } userId={ userId } />
{ device.deviceId }
<br/>
{ device.getDisplayName() }
</li>
);
}
DeviceListEntry.propTypes = {
userId: React.PropTypes.string.isRequired,
// deviceinfo
device: React.PropTypes.object.isRequired,
};
function UserUnknownDeviceList(props) {
const {userId, userDevices} = props;
const deviceListEntries = Object.keys(userDevices).map((deviceId) =>
<DeviceListEntry key={ deviceId } userId={ userId }
device={ userDevices[deviceId] } />,
);
return (
<ul className="mx_UnknownDeviceDialog_deviceList">
{deviceListEntries}
</ul>
);
}
UserUnknownDeviceList.propTypes = {
userId: React.PropTypes.string.isRequired,
// map from deviceid -> deviceinfo
userDevices: React.PropTypes.object.isRequired,
};
function UnknownDeviceList(props) {
const {devices} = props;
const userListEntries = Object.keys(devices).map((userId) =>
<li key={ userId }>
<p>{ userId }:</p>
<UserUnknownDeviceList userId={ userId } userDevices={ devices[userId] } />
</li>,
);
return <ul>{userListEntries}</ul>;
}
UnknownDeviceList.propTypes = {
// map from userid -> deviceid -> deviceinfo
devices: React.PropTypes.object.isRequired,
};
export default React.createClass({
displayName: 'UnknownEventDialog',
propTypes: {
room: React.PropTypes.object.isRequired,
// map from userid -> deviceid -> deviceinfo
devices: React.PropTypes.object.isRequired,
onFinished: React.PropTypes.func.isRequired,
},
componentDidMount: function() {
// Given we've now shown the user the unknown device, it is no longer
// unknown to them. Therefore mark it as 'known'.
Object.keys(this.props.devices).forEach((userId) => {
Object.keys(this.props.devices[userId]).map((deviceId) => {
MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
});
});
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Opening UnknownDeviceDialog');
},
render: function() {
const client = MatrixClientPeg.get();
const blacklistUnverified = client.getGlobalBlacklistUnverifiedDevices() ||
this.props.room.getBlacklistUnverifiedDevices();
let warning;
if (blacklistUnverified) {
warning = (
<h4>
You are currently blacklisting unverified devices; to send
messages to these devices you must verify them.
</h4>
);
} else {
warning = (
<div>
<p>
This means there is no guarantee that the devices
belong to the users they claim to.
</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');
return (
<BaseDialog className='mx_UnknownDeviceDialog'
onFinished={() => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log("UnknownDeviceDialog closed by escape");
this.props.onFinished();
}}
title='Room contains unknown devices'
>
<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} />
</GeminiScrollbar>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" autoFocus={ true }
onClick={() => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log("UnknownDeviceDialog closed by OK");
this.props.onFinished();
}}>
OK
</button>
</div>
</BaseDialog>
);
// XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point?
// It feels like confused users will likely turn it on and then disappear in a cloud of UISIs...
},
});

View file

@ -94,14 +94,14 @@ export default React.createClass({
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
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 nameClasses = classNames({
"mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified,
});
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={emailClasses}>{ address.address }</div>
<div className="mx_AddressTile_mx">
<div className={emailClasses}>{ address.address }</div>
{nameNode}
</div>
);
} else {
error = true;

View file

@ -27,6 +27,28 @@ export default React.createClass({
device: React.PropTypes.object.isRequired,
},
getInitialState: function() {
return {
device: this.props.device
};
},
componentWillMount: function() {
const cli = MatrixClientPeg.get();
cli.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
},
componentWillUnmount: function() {
const cli = MatrixClientPeg.get();
cli.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
},
onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) {
if (userId === this.props.userId && deviceId === this.props.device.deviceId) {
this.setState({ device: deviceInfo });
}
},
onVerifyClick: function() {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
@ -41,9 +63,9 @@ export default React.createClass({
</p>
<div className="mx_UserSettings_cryptoSection">
<ul>
<li><label>Device name:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>Device ID:</label> <span><code>{ this.props.device.deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{ this.props.device.getFingerprint() }</b></code></span></li>
<li><label>Device name:</label> <span>{ this.state.device.getDisplayName() }</span></li>
<li><label>Device ID:</label> <span><code>{ this.state.device.deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{ this.state.device.getFingerprint() }</b></code></span></li>
</ul>
</div>
<p>
@ -60,7 +82,7 @@ export default React.createClass({
onFinished: confirm=>{
if (confirm) {
MatrixClientPeg.get().setDeviceVerified(
this.props.userId, this.props.device.deviceId, true
this.props.userId, this.state.device.deviceId, true
);
}
},
@ -69,26 +91,26 @@ export default React.createClass({
onUnverifyClick: function() {
MatrixClientPeg.get().setDeviceVerified(
this.props.userId, this.props.device.deviceId, false
this.props.userId, this.state.device.deviceId, false
);
},
onBlacklistClick: function() {
MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.deviceId, true
this.props.userId, this.state.device.deviceId, true
);
},
onUnblacklistClick: function() {
MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.deviceId, false
this.props.userId, this.state.device.deviceId, false
);
},
render: function() {
var blacklistButton = null, verifyButton = null;
if (this.props.device.isBlocked()) {
if (this.state.device.isBlocked()) {
blacklistButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unblacklist"
onClick={this.onUnblacklistClick}>
@ -104,7 +126,7 @@ export default React.createClass({
);
}
if (this.props.device.isVerified()) {
if (this.state.device.isVerified()) {
verifyButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unverify"
onClick={this.onUnverifyClick}>

View file

@ -385,7 +385,7 @@ module.exports = React.createClass({
}
userEvents[userId].push({
mxEvent: e,
displayName: e.target.name || userId,
displayName: (e.target ? e.target.name : null) || userId,
index: index,
});
});

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') || email.endsWith('matrix.org');
},
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>
<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}/>
);
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

@ -40,6 +40,7 @@ import * as HtmlUtils from '../../../HtmlUtils';
import Autocomplete from './Autocomplete';
import {Completion} from "../../../autocomplete/Autocompleter";
import Markdown from '../../../Markdown';
import {onSendMessageFailed} from './MessageComposerInputOld';
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
@ -553,15 +554,11 @@ export default class MessageComposerInput extends React.Component {
sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText);
}
sendMessagePromise.then(() => {
sendMessagePromise.done((res) => {
dis.dispatch({
action: 'message_sent',
});
}, () => {
dis.dispatch({
action: 'message_send_failed',
});
});
}, (e) => onSendMessageFailed(e, this.props.room));
this.setState({
editorState: this.createEditorState(),

View file

@ -29,10 +29,31 @@ var TYPING_USER_TIMEOUT = 10000;
var TYPING_SERVER_TIMEOUT = 30000;
var MARKDOWN_ENABLED = true;
export function onSendMessageFailed(err, room) {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('MessageComposer got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") {
const UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog");
Modal.createDialog(UnknownDeviceDialog, {
devices: err.devices,
room: room,
onFinished: (r) => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('UnknownDeviceDialog closed with '+r);
},
}, "mx_Dialog_unknownDevice");
}
dis.dispatch({
action: 'message_send_failed',
});
}
/*
* The textInput part of the MessageComposer
*/
module.exports = React.createClass({
export default React.createClass({
displayName: 'MessageComposerInput',
statics: {
@ -331,21 +352,18 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
}
else {
const contentText = mdown.toPlaintext();
if (mdown) contentText = mdown.toPlaintext();
sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
}
sendMessagePromise.done(function() {
sendMessagePromise.done(function(res) {
dis.dispatch({
action: 'message_sent'
});
}, function() {
dis.dispatch({
action: 'message_send_failed'
});
});
}, (e) => onSendMessageFailed(e, this.props.room));
this.refs.textarea.value = '';
this.resizeInput();
ev.preventDefault();

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

@ -24,6 +24,8 @@ var ObjectUtils = require("../../../ObjectUtils");
var dis = require("../../../dispatcher");
var ScalarAuthClient = require("../../../ScalarAuthClient");
var ScalarMessaging = require('../../../ScalarMessaging');
var UserSettingsStore = require('../../../UserSettingsStore');
// parse a string as an integer; if the input is undefined, or cannot be parsed
// as an integer, return a default.
@ -228,11 +230,13 @@ module.exports = React.createClass({
}
// encryption
p = this.saveEncryption();
p = this.saveEnableEncryption();
if (!q.isFulfilled(p)) {
promises.push(p);
}
this.saveBlacklistUnverifiedDevicesPerRoom();
console.log("Performing %s operations: %s", promises.length, JSON.stringify(promises));
return promises;
},
@ -252,7 +256,7 @@ module.exports = React.createClass({
return this.refs.url_preview_settings.saveSettings();
},
saveEncryption: function() {
saveEnableEncryption: function() {
if (!this.refs.encrypt) { return q(); }
var encrypt = this.refs.encrypt.checked;
@ -265,6 +269,29 @@ module.exports = React.createClass({
);
},
saveBlacklistUnverifiedDevicesPerRoom: function() {
if (!this.refs.blacklistUnverified) return;
if (this._isRoomBlacklistUnverified() !== this.refs.blacklistUnverified.checked) {
this._setRoomBlacklistUnverified(this.refs.blacklistUnverified.checked);
}
},
_isRoomBlacklistUnverified: function() {
var blacklistUnverifiedDevicesPerRoom = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevicesPerRoom;
if (blacklistUnverifiedDevicesPerRoom) {
return blacklistUnverifiedDevicesPerRoom[this.props.room.roomId];
}
return false;
},
_setRoomBlacklistUnverified: function(value) {
var blacklistUnverifiedDevicesPerRoom = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevicesPerRoom || {};
blacklistUnverifiedDevicesPerRoom[this.props.room.roomId] = value;
UserSettingsStore.setLocalSetting('blacklistUnverifiedDevicesPerRoom', blacklistUnverifiedDevicesPerRoom);
this.props.room.setBlacklistUnverifiedDevices(value);
},
_hasDiff: function(strA, strB) {
// treat undefined as an empty string because other components may blindly
// call setName("") when there has been no diff made to the name!
@ -477,26 +504,42 @@ module.exports = React.createClass({
var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState;
var isEncrypted = cli.isRoomEncrypted(this.props.room.roomId);
var isGlobalBlacklistUnverified = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevices;
var isRoomBlacklistUnverified = this._isRoomBlacklistUnverified();
var settings =
<label>
<input type="checkbox" ref="blacklistUnverified"
defaultChecked={ isGlobalBlacklistUnverified || isRoomBlacklistUnverified }
disabled={ isGlobalBlacklistUnverified || (this.refs.encrypt && !this.refs.encrypt.checked) }/>
Never send encrypted messages to unverified devices in this room from this device.
</label>;
if (!isEncrypted &&
roomState.mayClientSendStateEvent("m.room.encryption", cli)) {
return (
<label>
<input type="checkbox" ref="encrypt" onClick={ this.onEnableEncryptionClick }/>
<img className="mx_RoomSettings_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12" />
Enable encryption (warning: cannot be disabled again!)
</label>
<div>
<label>
<input type="checkbox" ref="encrypt" onClick={ this.onEnableEncryptionClick }/>
<img className="mx_RoomSettings_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12" />
Enable encryption (warning: cannot be disabled again!)
</label>
{ settings }
</div>
);
}
else {
return (
<label>
{ isEncrypted
? <img className="mx_RoomSettings_e2eIcon" src="img/e2e-verified.svg" width="10" height="12" />
: <img className="mx_RoomSettings_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12" />
}
Encryption is { isEncrypted ? "" : "not " } enabled in this room.
</label>
<div>
<label>
{ isEncrypted
? <img className="mx_RoomSettings_e2eIcon" src="img/e2e-verified.svg" width="10" height="12" />
: <img className="mx_RoomSettings_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12" />
}
Encryption is { isEncrypted ? "" : "not " } enabled in this room.
</label>
{ settings }
</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) {