diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfcf5..0000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.eslintrc.js b/.eslintrc.js index d5684e21a7..34d3af270c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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"], diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1e33e61c..488a9814e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/karma.conf.js b/karma.conf.js index 131a03ce79..6d3047bb3b 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -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, }); }; diff --git a/package.json b/package.json index dabac0a060..6e7013fb93 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index c7b13bc071..b9d0ce67e8 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -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; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 739e8e3832..9aded55193 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -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!"); } diff --git a/src/Markdown.js b/src/Markdown.js index 2f278183a3..d6dc979a5a 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -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 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); } } diff --git a/src/Modal.js b/src/Modal.js index 89e8b1361c..7be37da92e 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -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 = ( -
+
{modal.elem}
diff --git a/src/PageTypes.js b/src/PageTypes.js index b2e2ecf4bc..d87b363a6f 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.js @@ -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", diff --git a/src/Resend.js b/src/Resend.js index ecf504e780..e2f0c5a1ee 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -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 diff --git a/src/RtsClient.js b/src/RtsClient.js new file mode 100644 index 0000000000..5cf2e811ad --- /dev/null +++ b/src/RtsClient.js @@ -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, + }, + } + ); + } +} diff --git a/src/Signup.js b/src/Signup.js index d3643bd749..022a93524c 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -91,6 +91,10 @@ class Register extends Signup { this.params.idSid = idSid; } + setReferrer(referrer) { + this.params.referrer = referrer; + } + setGuestAccessToken(token) { this.guestAccessToken = token; } diff --git a/src/SignupStages.js b/src/SignupStages.js index 6bdc331566..cdb9d5989b 100644 --- a/src/SignupStages.js +++ b/src/SignupStages.js @@ -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, diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index e5dba62ee7..d7d3e7bc7a 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -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; diff --git a/src/Velociraptor.js b/src/Velociraptor.js index 006dbcb0ac..18c871a12d 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -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 diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index 96e76d618b..4502b0ccd9 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -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(); diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 816b8eb73d..56b9d56cc9 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -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({ - +
diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 586bd9b6cc..ddd13813e2 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -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({ - +
diff --git a/src/component-index.js b/src/component-index.js index c50ee0dfc8..5b28be0627 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -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'; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 5166619d48..fc4cbd9423 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -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" /> ); } diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index c00bd2c6db..961277a4a1 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -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 = ; break; @@ -190,6 +194,16 @@ export default React.createClass({ />; if (!this.props.collapse_rhs) right_panel = ; break; + + case PageTypes.HomePage: + page_element = + if (!this.props.collapse_rhs) right_panel = + break; + case PageTypes.UserView: page_element = null; // deliberately null for now right_panel = ; @@ -218,7 +232,12 @@ export default React.createClass({
{topBar}
- +
{page_element}
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index cb61041d48..8fdcf15e1b 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -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} diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index 7d9e752657..16f9723c76 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -48,6 +48,7 @@ var NotificationPanel = React.createClass({ showUrlPreview = { false } opacity={ this.props.opacity } tileShape="notif" + empty="You have no visible notifications" /> ); } diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 3ba73bb181..288ca0b974 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -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( - + +{othersCount} ); @@ -285,12 +286,12 @@ module.exports = React.createClass({ ); } - if (this.props.hasUnsentMessages) { + if (this.props.unsentMessageError) { return (
/!\
- Some of your messages have not been sent. + { this.props.unsentMessageError }
@@ -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 (
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 24c8ff53c0..432dc5b724 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -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 = ; } else if (!this.state.searchResults) { var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); - + isStatusAreaExpanded = this.state.statusBarVisible; statusBar = ; } @@ -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"; } diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 1391d2b740..4a0faae9db 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -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); }, diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 490b83f2bf..cb42f701a3 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -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 ( +
+
{ this.props.empty }
+
+ ); + } + // 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. diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index e91e558cb2..8266a11bc8 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -90,8 +90,8 @@ module.exports = React.createClass({displayName: 'UploadBar',
- - +
diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index cf4a63e2f7..fdade60dfd 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -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 ( + + ); + }, + _renderUserInterfaceSettings: function() { var client = MatrixClientPeg.get(); @@ -514,21 +552,20 @@ module.exports = React.createClass({ const deviceId = client.deviceId; const identityKey = client.getDeviceEd25519Key() || ""; - let exportButton = null, - importButton = null; + let importExportButtons = null; if (client.isCryptoEnabled) { - exportButton = ( - - Export E2E room keys - - ); - importButton = ( - - Import E2E room keys - + importExportButtons = ( +
+ + Export E2E room keys + + + Import E2E room keys + +
); } return ( @@ -539,13 +576,36 @@ module.exports = React.createClass({
  • {deviceId}
  • {identityKey}
  • - {exportButton} - {importButton} + { importExportButtons } +
    +
    + { CRYPTO_SETTINGS_LABELS.map( this._renderLocalSetting ) }
    ); }, + _renderLocalSetting: function(setting) { + const client = MatrixClientPeg.get(); + return
    + { + UserSettingsStore.setLocalSetting(setting.id, e.target.checked) + if (setting.id === 'blacklistUnverifiedDevices') { // XXX: this is a bit ugly + client.setGlobalBlacklistUnverifiedDevices(e.target.checked); + } + } + } + /> + +
    ; + }, + _renderDevicesPanel: function() { var DevicesPanel = sdk.getComponent('settings.DevicesPanel'); return ( @@ -819,6 +879,8 @@ module.exports = React.createClass({ {accountJsx}
    + {this._renderReferral()} + {notification_area} {this._renderUserInterfaceSettings()} @@ -842,7 +904,7 @@ module.exports = React.createClass({
    matrix-react-sdk version: {REACT_SDK_VERSION}
    - vector-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}
    + riot-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}
    olm version: {olmVersionString}
    diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 90140b3280..efe7dae723 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -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 = ; + break; + } registerStep = ( + 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 = ( ); @@ -367,7 +426,7 @@ module.exports = React.createClass({ return (
    - + {this._getRegisterContentJsx()}
    diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index c9c84aa1bf..65730be40b 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -145,27 +145,48 @@ module.exports = React.createClass({ if (imageUrl === this.state.defaultImageUrl) { const initialLetter = this._getInitialLetter(name); - return ( - - - - + const textNode = ( + ); + const imgNode = ( + + ); + if (onClick != null) { + return ( + + {textNode} + {imgNode} + + ); + } else { + return ( + + {textNode} + {imgNode} + + ); + } } if (onClick != null) { return ( - - - + ); } else { return ( diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js index 61503196e5..ca3b07aa00 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -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"); diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js new file mode 100644 index 0000000000..3bebb8fdda --- /dev/null +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -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 ( +
  • + + { device.deviceId } +
    + { device.getDisplayName() } +
  • + ); +} + +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) => + , + ); + + return ( +
      + {deviceListEntries} +
    + ); +} + +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) => +
  • +

    { userId }:

    + +
  • , + ); + + return
      {userListEntries}
    ; +} + +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 = ( +

    + You are currently blacklisting unverified devices; to send + messages to these devices you must verify them. +

    + ); + } else { + warning = ( +
    +

    + This means there is no guarantee that the devices + belong to the users they claim to. +

    +

    + We recommend you go through the verification process + for each device before continuing, but you can resend + the message without verifying if you prefer. +

    +
    + ); + } + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + return ( + { + // 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' + > + +

    + This room contains unknown devices which have not been + verified. +

    + { warning } + Unknown devices: + + +
    +
    + +
    +
    + ); + // 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... + }, +}); diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index 01c1ed3255..18492d8ae6 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -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({
    { this.props.address.address }
    ); } 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 =
    { address.displayName }
    + } + info = ( -
    { address.address }
    +
    +
    { address.address }
    + {nameNode} +
    ); } else { error = true; diff --git a/src/components/views/elements/DeviceVerifyButtons.js b/src/components/views/elements/DeviceVerifyButtons.js index da3975e4db..fdd34e6ad2 100644 --- a/src/components/views/elements/DeviceVerifyButtons.js +++ b/src/components/views/elements/DeviceVerifyButtons.js @@ -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({

      -
    • { this.props.device.getDisplayName() }
    • -
    • { this.props.device.deviceId}
    • -
    • { this.props.device.getFingerprint() }
    • +
    • { this.state.device.getDisplayName() }
    • +
    • { this.state.device.deviceId}
    • +
    • { this.state.device.getFingerprint() }

    @@ -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 = (