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/.babelrc b/.babelrc
index 8c7b66269d..6ba0e0dae0 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,4 +1,4 @@
{
"presets": ["react", "es2015", "es2016"],
- "plugins": ["transform-class-properties", "transform-object-rest-spread", "transform-async-to-generator", "transform-runtime", "add-module-exports"]
+ "plugins": ["transform-class-properties", "transform-object-rest-spread", "transform-async-to-bluebird", "transform-runtime", "add-module-exports"]
}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000000..880331a09e
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,23 @@
+# Copyright 2017 Aviral Dasgupta
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+root = true
+
+[*]
+charset=utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000000..c4c7fe5067
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1 @@
+src/component-index.js
diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles
new file mode 100644
index 0000000000..f501f373cd
--- /dev/null
+++ b/.eslintignore.errorfiles
@@ -0,0 +1,109 @@
+# autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
+
+src/autocomplete/AutocompleteProvider.js
+src/autocomplete/Autocompleter.js
+src/autocomplete/EmojiProvider.js
+src/autocomplete/UserProvider.js
+src/CallHandler.js
+src/component-index.js
+src/components/structures/ContextualMenu.js
+src/components/structures/CreateRoom.js
+src/components/structures/LoggedInView.js
+src/components/structures/login/ForgotPassword.js
+src/components/structures/login/Login.js
+src/components/structures/login/Registration.js
+src/components/structures/MessagePanel.js
+src/components/structures/NotificationPanel.js
+src/components/structures/RoomStatusBar.js
+src/components/structures/RoomView.js
+src/components/structures/ScrollPanel.js
+src/components/structures/TimelinePanel.js
+src/components/structures/UploadBar.js
+src/components/views/avatars/BaseAvatar.js
+src/components/views/avatars/MemberAvatar.js
+src/components/views/create_room/RoomAlias.js
+src/components/views/dialogs/ChatCreateOrReuseDialog.js
+src/components/views/dialogs/DeactivateAccountDialog.js
+src/components/views/dialogs/UnknownDeviceDialog.js
+src/components/views/elements/AddressSelector.js
+src/components/views/elements/DeviceVerifyButtons.js
+src/components/views/elements/DirectorySearchBox.js
+src/components/views/elements/EditableText.js
+src/components/views/elements/MemberEventListSummary.js
+src/components/views/elements/TintableSvg.js
+src/components/views/elements/UserSelector.js
+src/components/views/login/CountryDropdown.js
+src/components/views/login/InteractiveAuthEntryComponents.js
+src/components/views/login/PasswordLogin.js
+src/components/views/login/RegistrationForm.js
+src/components/views/login/ServerConfig.js
+src/components/views/messages/MFileBody.js
+src/components/views/messages/MImageBody.js
+src/components/views/messages/RoomAvatarEvent.js
+src/components/views/messages/TextualBody.js
+src/components/views/room_settings/AliasSettings.js
+src/components/views/room_settings/ColorSettings.js
+src/components/views/room_settings/UrlPreviewSettings.js
+src/components/views/rooms/Autocomplete.js
+src/components/views/rooms/AuxPanel.js
+src/components/views/rooms/EntityTile.js
+src/components/views/rooms/EventTile.js
+src/components/views/rooms/LinkPreviewWidget.js
+src/components/views/rooms/MemberDeviceInfo.js
+src/components/views/rooms/MemberInfo.js
+src/components/views/rooms/MemberList.js
+src/components/views/rooms/MemberTile.js
+src/components/views/rooms/MessageComposer.js
+src/components/views/rooms/MessageComposerInput.js
+src/components/views/rooms/ReadReceiptMarker.js
+src/components/views/rooms/RoomList.js
+src/components/views/rooms/RoomPreviewBar.js
+src/components/views/rooms/RoomSettings.js
+src/components/views/rooms/RoomTile.js
+src/components/views/rooms/SearchableEntityList.js
+src/components/views/rooms/SearchResultTile.js
+src/components/views/rooms/TopUnreadMessagesBar.js
+src/components/views/rooms/UserTile.js
+src/components/views/settings/AddPhoneNumber.js
+src/components/views/settings/ChangeAvatar.js
+src/components/views/settings/ChangeDisplayName.js
+src/components/views/settings/ChangePassword.js
+src/components/views/settings/DevicesPanel.js
+src/ContentMessages.js
+src/HtmlUtils.js
+src/ImageUtils.js
+src/languageHandler.js
+src/linkify-matrix.js
+src/Login.js
+src/Markdown.js
+src/MatrixClientPeg.js
+src/Modal.js
+src/Notifier.js
+src/PlatformPeg.js
+src/Presence.js
+src/ratelimitedfunc.js
+src/RichText.js
+src/Roles.js
+src/Rooms.js
+src/ScalarAuthClient.js
+src/UiEffects.js
+src/Unread.js
+src/utils/DecryptFile.js
+src/utils/DMRoomMap.js
+src/utils/FormattingUtils.js
+src/utils/MultiInviter.js
+src/utils/Receipt.js
+src/Velociraptor.js
+src/VelocityBounce.js
+src/WhoIsTyping.js
+src/wrappers/withMatrixClient.js
+test/components/structures/login/Registration-test.js
+test/components/structures/MessagePanel-test.js
+test/components/structures/ScrollPanel-test.js
+test/components/structures/TimelinePanel-test.js
+test/components/views/dialogs/InteractiveAuthDialog-test.js
+test/components/views/elements/MemberEventListSummary-test.js
+test/components/views/login/RegistrationForm-test.js
+test/components/views/rooms/MessageComposerInput-test.js
+test/mock-clock.js
+test/stores/RoomViewStore-test.js
diff --git a/.eslintrc b/.eslintrc
deleted file mode 100644
index e2baaed5a6..0000000000
--- a/.eslintrc
+++ /dev/null
@@ -1,117 +0,0 @@
-{
- "parser": "babel-eslint",
- "plugins": [
- "react",
- "flowtype"
- ],
- "parserOptions": {
- "ecmaVersion": 6,
- "sourceType": "module",
- "ecmaFeatures": {
- "jsx": true,
- "impliedStrict": true
- }
- },
- "env": {
- "browser": true,
- "amd": true,
- "es6": true,
- "node": true,
- "mocha": true
- },
- "extends": ["eslint:recommended", "plugin:react/recommended"],
- "rules": {
- "no-undef": ["warn"],
- "global-strict": ["off"],
- "no-extra-semi": ["warn"],
- "no-underscore-dangle": ["off"],
- "no-console": ["off"],
- "no-unused-vars": ["off"],
- "no-trailing-spaces": ["warn", {
- "skipBlankLines": true
- }],
- "no-unreachable": ["warn"],
- "no-spaced-func": ["warn"],
- "no-new-func": ["error"],
- "no-new-wrappers": ["error"],
- "no-invalid-regexp": ["error"],
- "no-extra-bind": ["error"],
- "no-magic-numbers": ["error", {
- "ignore": [-1, 0, 1], // usually used in array/string indexing
- "ignoreArrayIndexes": true,
- "enforceConst": true,
- "detectObjects": true
- }],
- "consistent-return": ["error"],
- "valid-jsdoc": ["error"],
- "no-use-before-define": ["error"],
- "camelcase": ["warn"],
- "array-callback-return": ["error"],
- "dot-location": ["warn", "property"],
- "guard-for-in": ["error"],
- "no-useless-call": ["warn"],
- "no-useless-escape": ["warn"],
- "no-useless-concat": ["warn"],
- "brace-style": ["warn", "1tbs"],
- "comma-style": ["warn", "last"],
- "space-before-function-paren": ["warn", "never"],
- "space-before-blocks": ["warn", "always"],
- "keyword-spacing": ["warn", {
- "before": true,
- "after": true
- }],
-
- // dangling commas required, but only for multiline objects/arrays
- "comma-dangle": ["warn", "always-multiline"],
- // always === instead of ==, unless dealing with null/undefined
- "eqeqeq": ["error", "smart"],
- // always use curly braces, even with single statements
- "curly": ["error", "all"],
- // phasing out var in favour of let/const is a good idea
- "no-var": ["warn"],
- // always require semicolons
- "semi": ["error", "always"],
- // prefer rest and spread over the Old Ways
- "prefer-spread": ["warn"],
- "prefer-rest-params": ["warn"],
-
- /** react **/
-
- // bind or arrow function in props causes performance issues
- "react/jsx-no-bind": ["error", {
- "ignoreRefs": true
- }],
- "react/jsx-key": ["error"],
- "react/prefer-stateless-function": ["warn"],
-
- /** flowtype **/
- "flowtype/require-parameter-type": [
- 1,
- {
- "excludeArrowFunctions": true
- }
- ],
- "flowtype/define-flow-type": 1,
- "flowtype/require-return-type": [
- 1,
- "always",
- {
- "annotateUndefined": "never",
- "excludeArrowFunctions": true
- }
- ],
- "flowtype/space-after-type-colon": [
- 1,
- "always"
- ],
- "flowtype/space-before-type-colon": [
- 1,
- "never"
- ]
- },
- "settings": {
- "flowtype": {
- "onlyFilesWithFlowAnnotation": true
- }
- }
-}
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000000..c6aeb0d1be
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,102 @@
+const path = require('path');
+
+// get the path of the js-sdk so we can extend the config
+// eslint supports loading extended configs by module,
+// but only if they come from a module that starts with eslint-config-
+// So we load the filename directly (and it could be in node_modules/
+// or or ../node_modules/ etc)
+const matrixJsSdkPath = path.dirname(require.resolve('matrix-js-sdk'));
+
+module.exports = {
+ parser: "babel-eslint",
+ extends: [matrixJsSdkPath + "/.eslintrc.js"],
+ plugins: [
+ "react",
+ "flowtype",
+ "babel"
+ ],
+ env: {
+ es6: true,
+ },
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ }
+ },
+ rules: {
+ // eslint's built in no-invalid-this rule breaks with class properties
+ "no-invalid-this": "off",
+ // so we replace it with a version that is class property aware
+ "babel/no-invalid-this": "error",
+
+ // We appear to follow this most of the time, so let's enforce it instead
+ // of occasionally following it (or catching it in review)
+ "keyword-spacing": "error",
+
+ /** react **/
+ // This just uses the react plugin to help eslint known when
+ // variables have been used in JSX
+ "react/jsx-uses-vars": "error",
+ // Don't mark React as unused if we're using JSX
+ "react/jsx-uses-react": "error",
+
+ // bind or arrow function in props causes performance issues
+ "react/jsx-no-bind": ["error", {
+ "ignoreRefs": true,
+ }],
+ "react/jsx-key": ["error"],
+
+ // Assert no spacing in JSX curly brackets
+ //
{ _t(customVariables[row[0]].expl) } | +{ row[1] } |
+
{ getRedactedHash() }
,
+ CurrentUserAgent: { navigator.userAgent }
,
+ CurrentDeviceResolution: { resolution }
,
+ },
+ ) }
+
+ { _t('Where this page includes identifiable information, such as a room, '
+ + 'user or group ID, that data is removed before being sent to the server.') }
+
+ if (i !== contentDiv.children.length - 1) {
+ contentHTML += '
';
+ }
+ } else if (element.tagName.toLowerCase() === 'pre') {
+ // Replace "
\n" with "\n" within `
` tags because the
is + // redundant. This is a workaround for a bug in draft-js-export-html: + // https://github.com/sstur/draft-js-export-html/issues/62 + contentHTML += '' + + element.innerHTML.replace(/'; } else { const temp = document.createElement('div'); temp.appendChild(element.cloneNode(true)); @@ -80,32 +142,39 @@ export function stripParagraphs(html: string): string { return contentHTML; } -var sanitizeHtmlParams = { +/* + * Given an untrusted HTML string, return a React node with an sanitized version + * of that HTML. + */ +export function sanitizedHtmlNode(insaneHtml) { + const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); + + return ; +} + +const sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown - // deliberately no h1/h2 to stop people shouting. - 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', - 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre' + 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', ], allowedAttributes: { // custom ones first: - font: [ 'color' ], // custom to matrix - a: [ 'href', 'name', 'target', 'rel' ], // remote target: custom to matrix - // We don't currently allow img itself by default, but this - // would make sense if we did - img: [ 'src' ], + font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix + span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix + a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix + img: ['src', 'width', 'height', 'alt', 'title'], + ol: ['start'], + code: ['class'], // We don't actually allow all classes, we filter them in transformTags }, // Lots of these won't come up by default because we don't allow them - selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ], + selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit - allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ], + allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'], - // DO NOT USE. sanitize-html allows all URL starting with '//' - // so this will always allow links to whatever scheme the - // host page is served over. - allowedSchemesByTag: {}, + allowProtocolRelative: false, transformTags: { // custom to matrix // add blank targets to all hyperlinks except vector URLs @@ -113,28 +182,86 @@ var sanitizeHtmlParams = { if (attribs.href) { attribs.target = '_blank'; // by default - var m; + let m; // FIXME: horrible duplication with linkify-matrix m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); if (m) { attribs.href = m[1]; delete attribs.target; - } - - m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); - if (m) { - var entity = m[1]; - if (entity[0] === '@') { - attribs.href = '#/user/' + entity; + } else { + m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); + if (m) { + const entity = m[1]; + if (entity[0] === '@') { + attribs.href = '#/user/' + entity; + } else if (entity[0] === '#' || entity[0] === '!') { + attribs.href = '#/room/' + entity; + } + delete attribs.target; } - else if (entity[0] === '#' || entity[0] === '!') { - attribs.href = '#/room/' + entity; - } - delete attribs.target; } } attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ - return { tagName: tagName, attribs : attribs }; + return { tagName: tagName, attribs: attribs }; + }, + 'img': function(tagName, attribs) { + // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag + // because transformTags is used _before_ we filter by allowedSchemesByTag and + // we don't want to allow images with `https?` `src`s. + if (!attribs.src || !attribs.src.startsWith('mxc://')) { + return { tagName, attribs: {}}; + } + attribs.src = MatrixClientPeg.get().mxcUrlToHttp( + attribs.src, + attribs.width || 800, + attribs.height || 600, + ); + return { tagName: tagName, attribs: attribs }; + }, + 'code': function(tagName, attribs) { + if (typeof attribs.class !== 'undefined') { + // Filter out all classes other than ones starting with language- for syntax highlighting. + const classes = attribs.class.split(/\s+/).filter(function(cl) { + return cl.startsWith('language-'); + }); + attribs.class = classes.join(' '); + } + return { + tagName: tagName, + attribs: attribs, + }; + }, + '*': function(tagName, attribs) { + // Delete any style previously assigned, style is an allowedTag for font and span + // because attributes are stripped after transforming + delete attribs.style; + + // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS + // equivalents + const customCSSMapper = { + 'data-mx-color': 'color', + 'data-mx-bg-color': 'background-color', + // $customAttributeKey: $cssAttributeKey + }; + + let style = ""; + Object.keys(customCSSMapper).forEach((customAttributeKey) => { + const cssAttributeKey = customCSSMapper[customAttributeKey]; + const customAttributeValue = attribs[customAttributeKey]; + if (customAttributeValue && + typeof customAttributeValue === 'string' && + COLOR_REGEX.test(customAttributeValue) + ) { + style += cssAttributeKey + ":" + customAttributeValue + ";"; + delete attribs[customAttributeKey]; + } + }); + + if (style) { + attribs.style = style; + } + + return { tagName: tagName, attribs: attribs }; }, }, }; @@ -157,11 +284,11 @@ class BaseHighlighter { * TextHighlighter). */ applyHighlights(safeSnippet, safeHighlights) { - var lastOffset = 0; - var offset; - var nodes = []; + let lastOffset = 0; + let offset; + let nodes = []; - var safeHighlight = safeHighlights[0]; + const safeHighlight = safeHighlights[0]; while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) { // handle preamble if (offset > lastOffset) { @@ -171,7 +298,7 @@ class BaseHighlighter { // do highlight. use the original string rather than safeHighlight // to preserve the original casing. - var endOffset = offset + safeHighlight.length; + const endOffset = offset + safeHighlight.length; nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true)); lastOffset = endOffset; @@ -189,8 +316,7 @@ class BaseHighlighter { if (safeHighlights[1]) { // recurse into this range to check for the next set of highlight matches return this.applyHighlights(safeSnippet, safeHighlights.slice(1)); - } - else { + } else { // no more highlights to be found, just return the unhighlighted string return [this._processSnippet(safeSnippet, false)]; } @@ -211,7 +337,7 @@ class HtmlHighlighter extends BaseHighlighter { return snippet; } - var span = "" + let span = "" + snippet + ""; if (this.highlightLink) { @@ -236,15 +362,15 @@ class TextHighlighter extends BaseHighlighter { * returns a React node */ _processSnippet(snippet, highlight) { - var key = this._key++; + const key = this._key++; - var node = - + let node = + { snippet } ; if (highlight && this.highlightLink) { - node = {node}; + node = { node }; } return node; @@ -259,22 +385,23 @@ class TextHighlighter extends BaseHighlighter { * highlights: optional list of words to highlight, ordered by longest word first * * opts.highlightLink: optional href to add to highlighted words + * opts.disableBigEmoji: optional argument to disable the big emoji class. */ -export function bodyToHtml(content, highlights, opts) { - opts = opts || {}; +export function bodyToHtml(content, highlights, opts={}) { + const isHtml = (content.format === "org.matrix.custom.html"); + const body = isHtml ? content.formatted_body : escape(content.body); - var isHtml = (content.format === "org.matrix.custom.html"); - let body = isHtml ? content.formatted_body : escape(content.body); + let bodyHasEmoji = false; - var safeBody; + let safeBody; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which // are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either try { if (highlights && highlights.length > 0) { - var highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); - var safeHighlights = highlights.map(function(highlight) { + const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); + const safeHighlights = highlights.map(function(highlight) { return sanitizeHtml(highlight, sanitizeHtmlParams); }); // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure. @@ -283,23 +410,26 @@ export function bodyToHtml(content, highlights, opts) { }; } safeBody = sanitizeHtml(body, sanitizeHtmlParams); - safeBody = unicodeToImage(safeBody); - } - finally { + bodyHasEmoji = containsEmoji(body); + if (bodyHasEmoji) safeBody = unicodeToImage(safeBody); + } finally { delete sanitizeHtmlParams.textFilter; } - EMOJI_REGEX.lastIndex = 0; - let contentBodyTrimmed = content.body.trim(); - let match = EMOJI_REGEX.exec(contentBodyTrimmed); - let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + let emojiBody = false; + if (!opts.disableBigEmoji && bodyHasEmoji) { + EMOJI_REGEX.lastIndex = 0; + const contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; + const match = EMOJI_REGEX.exec(contentBodyTrimmed); + emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + } const className = classNames({ 'mx_EventTile_body': true, 'mx_EventTile_bigEmoji': emojiBody, 'markdown-body': isHtml, }); - return ; + return ; } export function emojifyText(text) { diff --git a/src/ImageUtils.js b/src/ImageUtils.js index fdb12c7608..a83d94a633 100644 --- a/src/ImageUtils.js +++ b/src/ImageUtils.js @@ -42,16 +42,15 @@ module.exports = { // no scaling needs to be applied return fullHeight; } - var widthMulti = thumbWidth / fullWidth; - var heightMulti = thumbHeight / fullHeight; + const widthMulti = thumbWidth / fullWidth; + const heightMulti = thumbHeight / fullHeight; if (widthMulti < heightMulti) { // width is the dominant dimension so scaling will be fixed on that return Math.floor(widthMulti * fullHeight); - } - else { + } else { // height is the dominant dimension so scaling will be fixed on that return Math.floor(heightMulti * fullHeight); } }, -} +}; diff --git a/src/Invite.js b/src/Invite.js deleted file mode 100644 index 6422812734..0000000000 --- a/src/Invite.js +++ /dev/null @@ -1,83 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import MatrixClientPeg from './MatrixClientPeg'; -import MultiInviter from './utils/MultiInviter'; - -const emailRegex = /^\S+@\S+\.\S+$/; - -export function getAddressType(inputText) { - const isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText); - const isMatrixId = inputText[0] === '@' && inputText.indexOf(":") > 0; - - // sanity check the input for user IDs - if (isEmailAddress) { - return 'email'; - } else if (isMatrixId) { - return 'mx'; - } else { - return null; - } -} - -export function inviteToRoom(roomId, addr) { - const addrType = getAddressType(addr); - - if (addrType == 'email') { - return MatrixClientPeg.get().inviteByEmail(roomId, addr); - } else if (addrType == 'mx') { - return MatrixClientPeg.get().invite(roomId, addr); - } else { - throw new Error('Unsupported address'); - } -} - -/** - * Invites multiple addresses to a room - * Simpler interface to utils/MultiInviter but with - * no option to cancel. - * - * @param {roomId} The ID of the room to invite to - * @param {array} Array of strings of addresses to invite. May be matrix IDs or 3pids. - * @returns Promise - */ -export function inviteMultipleToRoom(roomId, addrs) { - this.inviter = new MultiInviter(roomId); - return this.inviter.invite(addrs); -} - -/** - * Checks is the supplied address is valid - * - * @param {addr} The mx userId or email address to check - * @returns true, false, or null for unsure - */ -export function isValidAddress(addr) { - // Check if the addr is a valid type - var addrType = this.getAddressType(addr); - if (addrType === "mx") { - let user = MatrixClientPeg.get().getUser(addr); - if (user) { - return true; - } else { - return null; - } - } else if (addrType === "email") { - return true; - } else { - return false; - } -} diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js new file mode 100644 index 0000000000..0b54d88e5f --- /dev/null +++ b/src/KeyRequestHandler.js @@ -0,0 +1,138 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import sdk from './index'; +import Modal from './Modal'; + +export default class KeyRequestHandler { + constructor(matrixClient) { + this._matrixClient = matrixClient; + + // the user/device for which we currently have a dialog open + this._currentUser = null; + this._currentDevice = null; + + // userId -> deviceId -> [keyRequest] + this._pendingKeyRequests = Object.create(null); + } + + handleKeyRequest(keyRequest) { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const requestId = keyRequest.requestId; + + if (!this._pendingKeyRequests[userId]) { + this._pendingKeyRequests[userId] = Object.create(null); + } + if (!this._pendingKeyRequests[userId][deviceId]) { + this._pendingKeyRequests[userId][deviceId] = []; + } + + // check if we already have this request + const requests = this._pendingKeyRequests[userId][deviceId]; + if (requests.find((r) => r.requestId === requestId)) { + console.log("Already have this key request, ignoring"); + return; + } + + requests.push(keyRequest); + + if (this._currentUser) { + // ignore for now + console.log("Key request, but we already have a dialog open"); + return; + } + + this._processNextRequest(); + } + + handleKeyRequestCancellation(cancellation) { + // see if we can find the request in the queue + const userId = cancellation.userId; + const deviceId = cancellation.deviceId; + const requestId = cancellation.requestId; + + if (userId === this._currentUser && deviceId === this._currentDevice) { + console.log( + "room key request cancellation for the user we currently have a" + + " dialog open for", + ); + // TODO: update the dialog. For now, we just ignore the + // cancellation. + return; + } + + if (!this._pendingKeyRequests[userId]) { + return; + } + const requests = this._pendingKeyRequests[userId][deviceId]; + if (!requests) { + return; + } + const idx = requests.findIndex((r) => r.requestId === requestId); + if (idx < 0) { + return; + } + console.log("Forgetting room key request"); + requests.splice(idx, 1); + if (requests.length === 0) { + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + } + } + + _processNextRequest() { + const userId = Object.keys(this._pendingKeyRequests)[0]; + if (!userId) { + return; + } + const deviceId = Object.keys(this._pendingKeyRequests[userId])[0]; + if (!deviceId) { + return; + } + console.log(`Starting KeyShareDialog for ${userId}:${deviceId}`); + + const finished = (r) => { + this._currentUser = null; + this._currentDevice = null; + + if (r) { + for (const req of this._pendingKeyRequests[userId][deviceId]) { + req.share(); + } + } + delete this._pendingKeyRequests[userId][deviceId]; + if (Object.keys(this._pendingKeyRequests[userId]).length === 0) { + delete this._pendingKeyRequests[userId]; + } + + this._processNextRequest(); + }; + + const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog"); + Modal.createTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, { + matrixClient: this._matrixClient, + userId: userId, + deviceId: deviceId, + onFinished: finished, + }); + this._currentUser = userId; + this._currentDevice = deviceId; + } +} + diff --git a/src/Keyboard.js b/src/Keyboard.js new file mode 100644 index 0000000000..bf83a1a05f --- /dev/null +++ b/src/Keyboard.js @@ -0,0 +1,79 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* a selection of key codes, as used in KeyboardEvent.keyCode */ +export const KeyCode = { + BACKSPACE: 8, + TAB: 9, + ENTER: 13, + SHIFT: 16, + ESCAPE: 27, + SPACE: 32, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + DELETE: 46, + KEY_A: 65, + KEY_B: 66, + KEY_C: 67, + KEY_D: 68, + KEY_E: 69, + KEY_F: 70, + KEY_G: 71, + KEY_H: 72, + KEY_I: 73, + KEY_J: 74, + KEY_K: 75, + KEY_L: 76, + KEY_M: 77, + KEY_N: 78, + KEY_O: 79, + KEY_P: 80, + KEY_Q: 81, + KEY_R: 82, + KEY_S: 83, + KEY_T: 84, + KEY_U: 85, + KEY_V: 86, + KEY_W: 87, + KEY_X: 88, + KEY_Y: 89, + KEY_Z: 90, +}; + +export function isOnlyCtrlOrCmdKeyEvent(ev) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + if (isMac) { + return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; + } else { + return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; + } +} + +export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + if (isMac) { + return ev.metaKey && !ev.altKey && !ev.ctrlKey; + } else { + return ev.ctrlKey && !ev.altKey && !ev.metaKey; + } +} diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0a61dc6105..efd5c20d5c 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,42 +15,38 @@ See the License for the specific language governing permissions and limitations under the License. */ -import q from 'q'; +import Promise from 'bluebird'; import Matrix from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; -import Notifier from './Notifier' +import createMatrixClient from './utils/createMatrixClient'; +import Analytics from './Analytics'; +import Notifier from './Notifier'; import UserActivity from './UserActivity'; import Presence from './Presence'; import dis from './dispatcher'; import DMRoomMap from './utils/DMRoomMap'; +import RtsClient from './RtsClient'; +import Modal from './Modal'; +import sdk from './index'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries * a number of things: * - * 0. if it looks like we are in the middle of a registration process, it does - * nothing. * - * 1. if we have a loginToken in the (real) query params, it uses that to log - * in. - * - * 2. if we have a guest access token in the fragment query params, it uses + * 1. if we have a guest access token in the fragment query params, it uses * that. * - * 3. if an access token is stored in local storage (from a previous session), + * 2. if an access token is stored in local storage (from a previous session), * it uses that. * - * 4. it attempts to auto-register as a guest user. + * 3. it attempts to auto-register as a guest user. * - * If any of steps 1-4 are successful, it will call {setLoggedIn}, which in + * If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in * turn will raise on_logged_in and will_start_client events. * - * It returns a promise which resolves when the above process completes. - * - * @param {object} opts.realQueryParams: string->string map of the - * query-parameters extracted from the real query-string of the starting - * URI. + * @param {object} opts * * @param {object} opts.fragmentQueryParams: string->string map of the * query-parameters extracted from the #-fragment of the starting URI. @@ -63,66 +60,72 @@ import DMRoomMap from './utils/DMRoomMap'; * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is * true; defines the IS to use. * + * @returns {Promise} a promise which resolves when the above process completes. + * Resolves to `true` if we ended up starting a session, or `false` if we + * failed. */ export function loadSession(opts) { - const realQueryParams = opts.realQueryParams || {}; const fragmentQueryParams = opts.fragmentQueryParams || {}; let enableGuest = opts.enableGuest || false; const guestHsUrl = opts.guestHsUrl; const guestIsUrl = opts.guestIsUrl; const defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - if (fragmentQueryParams.client_secret && fragmentQueryParams.sid) { - // this happens during email validation: the email contains a link to the - // IS, which in turn redirects back to vector. We let MatrixChat create a - // Registration component which completes the next stage of registration. - console.log("Not registering as guest: registration already in progress."); - return q(); - } - if (!guestHsUrl) { console.warn("Cannot enable guest access: can't determine HS URL to use"); enableGuest = false; } - if (realQueryParams.loginToken) { - if (!realQueryParams.homeserver) { - console.warn("Cannot log in with token: can't determine HS URL to use"); - } else { - return _loginWithToken(realQueryParams, defaultDeviceDisplayName); - } - } - if (enableGuest && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token ) { console.log("Using guest access credentials"); - setLoggedIn({ + return _doSetLoggedIn({ userId: fragmentQueryParams.guest_user_id, accessToken: fragmentQueryParams.guest_access_token, homeserverUrl: guestHsUrl, identityServerUrl: guestIsUrl, guest: true, - }); - return q(); + }, true).then(() => true); } - if (_restoreFromLocalStorage()) { - return q(); - } + return _restoreFromLocalStorage().then((success) => { + if (success) { + return true; + } - if (enableGuest) { - return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); - } + if (enableGuest) { + return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); + } - // fall back to login screen - return q(); + // fall back to login screen + return false; + }); } -function _loginWithToken(queryParams, defaultDeviceDisplayName) { +/** + * @param {Object} queryParams string->string map of the + * query-parameters extracted from the real query-string of the starting + * URI. + * + * @param {String} defaultDeviceDisplayName + * + * @returns {Promise} promise which resolves to true if we completed the token + * login, else false + */ +export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { + if (!queryParams.loginToken) { + return Promise.resolve(false); + } + + if (!queryParams.homeserver) { + console.warn("Cannot log in with token: can't determine HS URL to use"); + return Promise.resolve(false); + } + // create a temporary MatrixClient to do the login - var client = Matrix.createClient({ + const client = Matrix.createClient({ baseUrl: queryParams.homeserver, }); @@ -133,28 +136,32 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) { }, ).then(function(data) { console.log("Logged in with token"); - setLoggedIn({ - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token, - homeserverUrl: queryParams.homeserver, - identityServerUrl: queryParams.identityServer, - guest: false, - }) - }, (err) => { + return _clearStorage().then(() => { + _persistCredentialsToLocalStorage({ + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + homeserverUrl: queryParams.homeserver, + identityServerUrl: queryParams.identityServer, + guest: false, + }); + return true; + }); + }).catch((err) => { console.error("Failed to log in with login token: " + err + " " + err.data); + return false; }); } function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { - console.log("Doing guest login on %s", hsUrl); + console.log(`Doing guest login on ${hsUrl}`); - // TODO: we should probably de-duplicate this and Signup.Login.loginAsGuest. + // TODO: we should probably de-duplicate this and Login.loginAsGuest. // Not really sure where the right home for it is. // create a temporary MatrixClient to do the login - var client = Matrix.createClient({ + const client = Matrix.createClient({ baseUrl: hsUrl, }); @@ -163,119 +170,227 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { initial_device_display_name: defaultDeviceDisplayName, }, }).then((creds) => { - console.log("Registered as guest: %s", creds.user_id); - setLoggedIn({ + console.log(`Registered as guest: ${creds.user_id}`); + return _doSetLoggedIn({ userId: creds.user_id, deviceId: creds.device_id, accessToken: creds.access_token, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: true, - }); + }, true).then(() => true); }, (err) => { console.error("Failed to register as guest: " + err + " " + err.data); + return false; }); } -// returns true if a session is found in localstorage +// returns a promise which resolves to true if a session is found in +// localstorage +// +// N.B. Lifecycle.js should not maintain any further localStorage state, we +// are moving towards using SessionStore to keep track of state related +// to the current session (which is typically backed by localStorage). +// +// The plan is to gradually move the localStorage access done here into +// SessionStore to avoid bugs where the view becomes out-of-sync with +// localStorage (e.g. teamToken, isGuest etc.) function _restoreFromLocalStorage() { if (!localStorage) { - return false; + return Promise.resolve(false); } - const hs_url = localStorage.getItem("mx_hs_url"); - const is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; - const access_token = localStorage.getItem("mx_access_token"); - const user_id = localStorage.getItem("mx_user_id"); - const device_id = localStorage.getItem("mx_device_id"); + const hsUrl = localStorage.getItem("mx_hs_url"); + const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org'; + const accessToken = localStorage.getItem("mx_access_token"); + const userId = localStorage.getItem("mx_user_id"); + const deviceId = localStorage.getItem("mx_device_id"); - let is_guest; + let isGuest; if (localStorage.getItem("mx_is_guest") !== null) { - is_guest = localStorage.getItem("mx_is_guest") === "true"; + isGuest = localStorage.getItem("mx_is_guest") === "true"; } else { // legacy key name - is_guest = localStorage.getItem("matrix-is-guest") === "true"; + isGuest = localStorage.getItem("matrix-is-guest") === "true"; } - if (access_token && user_id && hs_url) { - console.log("Restoring session for %s", user_id); + if (accessToken && userId && hsUrl) { + console.log(`Restoring session for ${userId}`); try { - setLoggedIn({ - userId: user_id, - deviceId: device_id, - accessToken: access_token, - homeserverUrl: hs_url, - identityServerUrl: is_url, - guest: is_guest, - }); - return true; + return _doSetLoggedIn({ + userId: userId, + deviceId: deviceId, + accessToken: accessToken, + homeserverUrl: hsUrl, + identityServerUrl: isUrl, + guest: isGuest, + }, false).then(() => true); } catch (e) { - console.log("Unable to restore session", e); - - var msg = e.message; - if (msg == "OLM.BAD_LEGACY_ACCOUNT_PICKLE") { - msg = "You need to log back in to generate end-to-end encryption keys " - + "for this device and submit the public key to your homeserver. " - + "This is a once off; sorry for the inconvenience."; - } - - // don't leak things into the new session - _clearLocalStorage(); - - throw new Error("Unable to restore previous session: " + msg); + return _handleRestoreFailure(e); } } else { console.log("No previous session found."); - return false; + return Promise.resolve(false); + } +} + +function _handleRestoreFailure(e) { + console.log("Unable to restore session", e); + + const def = Promise.defer(); + const SessionRestoreErrorDialog = + sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); + + Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { + error: e.message, + onFinished: (success) => { + def.resolve(success); + }, + }); + + return def.promise.then((success) => { + if (success) { + // user clicked continue. + _clearStorage(); + return false; + } + + // try, try again + return _restoreFromLocalStorage(); + }); +} + +let rtsClient = null; +export function initRtsClient(url) { + if (url) { + rtsClient = new RtsClient(url); + } else { + rtsClient = null; } } /** - * Transitions to a logged-in state using the given credentials + * Transitions to a logged-in state using the given credentials. + * + * Starts the matrix client and all other react-sdk services that + * listen for events while a session is logged in. + * + * Also stops the old MatrixClient and clears old credentials/etc out of + * storage before starting the new client. + * * @param {MatrixClientCreds} credentials The credentials to use + * + * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ export function setLoggedIn(credentials) { - credentials.guest = Boolean(credentials.guest); - console.log("setLoggedIn => %s (guest=%s) hs=%s", - credentials.userId, credentials.guest, - credentials.homeserverUrl); + stopMatrixClient(); + return _doSetLoggedIn(credentials, true); +} + +/** + * fires on_logging_in, optionally clears localstorage, persists new credentials + * to localstorage, starts the new client. + * + * @param {MatrixClientCreds} credentials + * @param {Boolean} clearStorage + * + * @returns {Promise} promise which resolves to the new MatrixClient once it has been started + */ +async function _doSetLoggedIn(credentials, clearStorage) { + credentials.guest = Boolean(credentials.guest); + + console.log( + "setLoggedIn: mxid: " + credentials.userId + + " deviceId: " + credentials.deviceId + + " guest: " + credentials.guest + + " hs: " + credentials.homeserverUrl, + ); + + // This is dispatched to indicate that the user is still in the process of logging in + // because `teamPromise` may take some time to resolve, breaking the assumption that + // `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms + // later than MatrixChat might assume. + // + // we fire it *synchronously* to make sure it fires before on_logged_in. + // (dis.dispatch uses `setTimeout`, which does not guarantee ordering.) + dis.dispatch({action: 'on_logging_in'}, true); + + if (clearStorage) { + await _clearStorage(); + } + + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl); + + // Resolves by default + let teamPromise = Promise.resolve(null); + - // persist the session if (localStorage) { try { - localStorage.setItem("mx_hs_url", credentials.homeserverUrl); - localStorage.setItem("mx_is_url", credentials.identityServerUrl); - localStorage.setItem("mx_user_id", credentials.userId); - localStorage.setItem("mx_access_token", credentials.accessToken); - localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + _persistCredentialsToLocalStorage(credentials); - // if we didn't get a deviceId from the login, leave mx_device_id unset, - // rather than setting it to "undefined". - // - // (in this case MatrixClient doesn't bother with the crypto stuff - // - that's fine for us). - if (credentials.deviceId) { - localStorage.setItem("mx_device_id", credentials.deviceId); + // The user registered as a PWLU (PassWord-Less User), the generated password + // is cached here such that the user can change it at a later time. + if (credentials.password) { + // Update SessionStore + dis.dispatch({ + action: 'cached_password', + cachedPassword: credentials.password, + }); } - - console.log("Session persisted for %s", credentials.userId); } catch (e) { console.warn("Error using local storage: can't persist session!", e); } + + if (rtsClient && !credentials.guest) { + teamPromise = rtsClient.login(credentials.userId).then((body) => { + if (body.team_token) { + localStorage.setItem("mx_team_token", body.team_token); + } + return body.team_token; + }, (err) => { + console.warn(`Failed to get team token on login: ${err}` ); + return null; + }); + } } else { console.warn("No local storage available: can't persist session!"); } MatrixClientPeg.replaceUsingCreds(credentials); - dis.dispatch({action: 'on_logged_in'}); + teamPromise.then((teamToken) => { + dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); + }); startMatrixClient(); + return MatrixClientPeg.get(); +} + +function _persistCredentialsToLocalStorage(credentials) { + localStorage.setItem("mx_hs_url", credentials.homeserverUrl); + localStorage.setItem("mx_is_url", credentials.identityServerUrl); + localStorage.setItem("mx_user_id", credentials.userId); + localStorage.setItem("mx_access_token", credentials.accessToken); + localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + + // if we didn't get a deviceId from the login, leave mx_device_id unset, + // rather than setting it to "undefined". + // + // (in this case MatrixClient doesn't bother with the crypto stuff + // - that's fine for us). + if (credentials.deviceId) { + localStorage.setItem("mx_device_id", credentials.deviceId); + } + + console.log(`Session persisted for ${credentials.userId}`); } /** * Logs the current session out and transitions to the logged-out state */ export function logout() { + if (!MatrixClientPeg.get()) return; + if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions // Also we sometimes want to re-log in a guest session @@ -289,7 +404,7 @@ export function logout() { return; } - return MatrixClientPeg.get().logout().then(onLoggedOut, + MatrixClientPeg.get().logout().then(onLoggedOut, (err) => { // Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and @@ -300,15 +415,17 @@ export function logout() { // change your password). console.log("Failed to call logout API: token will not be invalidated"); onLoggedOut(); - } - ); + }, + ).done(); } /** * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. */ -export function startMatrixClient() { +function startMatrixClient() { + console.log(`Lifecycle: Starting MatrixClient`); + // dispatch this before starting the matrix client: it's used // to add listeners for the 'sync' event so otherwise we'd have // a race condition (and we need to dispatch synchronously for this @@ -321,43 +438,58 @@ export function startMatrixClient() { DMRoomMap.makeShared().start(); MatrixClientPeg.start(); + + // dispatch that we finished starting up to wire up any other bits + // of the matrix client that cannot be set prior to starting up. + dis.dispatch({action: 'client_started'}); } /* - * Stops a running client and all related services, used after - * a session has been logged out / ended. + * Stops a running client and all related services, and clears persistent + * storage. Used after a session has been logged out. */ export function onLoggedOut() { - _clearLocalStorage(); stopMatrixClient(); + _clearStorage().done(); dis.dispatch({action: 'on_logged_out'}); } -function _clearLocalStorage() { - if (!window.localStorage) { - return; - } - const hsUrl = window.localStorage.getItem("mx_hs_url"); - const isUrl = window.localStorage.getItem("mx_is_url"); - window.localStorage.clear(); +/** + * @returns {Promise} promise which resolves once the stores have been cleared + */ +function _clearStorage() { + Analytics.logout(); - // preserve our HS & IS URLs for convenience - // N.B. we cache them in hsUrl/isUrl and can't really inline them - // as getCurrentHsUrl() may call through to localStorage. - // NB. We do clear the device ID (as well as all the settings) - if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl); - if (isUrl) window.localStorage.setItem("mx_is_url", isUrl); + if (window.localStorage) { + const hsUrl = window.localStorage.getItem("mx_hs_url"); + const isUrl = window.localStorage.getItem("mx_is_url"); + window.localStorage.clear(); + + // preserve our HS & IS URLs for convenience + // N.B. we cache them in hsUrl/isUrl and can't really inline them + // as getCurrentHsUrl() may call through to localStorage. + // NB. We do clear the device ID (as well as all the settings) + if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl); + if (isUrl) window.localStorage.setItem("mx_is_url", isUrl); + } + + // create a temporary client to clear out the persistent stores. + const cli = createMatrixClient({ + // we'll never make any requests, so can pass a bogus HS URL + baseUrl: "", + }); + return cli.clearStores(); } /** - * Stop all the background processes related to the current client + * Stop all the background processes related to the current client. */ export function stopMatrixClient() { Notifier.stop(); UserActivity.stop(); Presence.stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); - var cli = MatrixClientPeg.get(); + const cli = MatrixClientPeg.get(); if (cli) { cli.stopClient(); cli.removeAllListeners(); diff --git a/src/Login.js b/src/Login.js new file mode 100644 index 0000000000..61a14959d8 --- /dev/null +++ b/src/Login.js @@ -0,0 +1,243 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Matrix from "matrix-js-sdk"; +import { _t } from "./languageHandler"; + +import Promise from 'bluebird'; +import url from 'url'; + +export default class Login { + constructor(hsUrl, isUrl, fallbackHsUrl, opts) { + this._hsUrl = hsUrl; + this._isUrl = isUrl; + this._fallbackHsUrl = fallbackHsUrl; + this._currentFlowIndex = 0; + this._flows = []; + this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + } + + getHomeserverUrl() { + return this._hsUrl; + } + + getIdentityServerUrl() { + return this._isUrl; + } + + setHomeserverUrl(hsUrl) { + this._hsUrl = hsUrl; + } + + setIdentityServerUrl(isUrl) { + this._isUrl = isUrl; + } + + /** + * Get a temporary MatrixClient, which can be used for login or register + * requests. + */ + _createTemporaryClient() { + return Matrix.createClient({ + baseUrl: this._hsUrl, + idBaseUrl: this._isUrl, + }); + } + + getFlows() { + const self = this; + const client = this._createTemporaryClient(); + return client.loginFlows().then(function(result) { + self._flows = result.flows; + self._currentFlowIndex = 0; + // technically the UI should display options for all flows for the + // user to then choose one, so return all the flows here. + return self._flows; + }); + } + + chooseFlow(flowIndex) { + this._currentFlowIndex = flowIndex; + } + + getCurrentFlowStep() { + // technically the flow can have multiple steps, but no one does this + // for login so we can ignore it. + const flowStep = this._flows[this._currentFlowIndex]; + return flowStep ? flowStep.type : null; + } + + loginAsGuest() { + const client = this._createTemporaryClient(); + return client.registerGuest({ + body: { + initial_device_display_name: this._defaultDeviceDisplayName, + }, + }).then((creds) => { + return { + userId: creds.user_id, + deviceId: creds.device_id, + accessToken: creds.access_token, + homeserverUrl: this._hsUrl, + identityServerUrl: this._isUrl, + guest: true, + }; + }, (error) => { + throw error; + }); + } + + loginViaPassword(username, phoneCountry, phoneNumber, pass) { + const self = this; + + const isEmail = username.indexOf("@") > 0; + + let identifier; + let legacyParams; // parameters added to support old HSes + if (phoneCountry && phoneNumber) { + identifier = { + type: 'm.id.phone', + country: phoneCountry, + number: phoneNumber, + }; + // No legacy support for phone number login + } else if (isEmail) { + identifier = { + type: 'm.id.thirdparty', + medium: 'email', + address: username, + }; + legacyParams = { + medium: 'email', + address: username, + }; + } else { + identifier = { + type: 'm.id.user', + user: username, + }; + legacyParams = { + user: username, + }; + } + + const loginParams = { + password: pass, + identifier: identifier, + initial_device_display_name: this._defaultDeviceDisplayName, + }; + Object.assign(loginParams, legacyParams); + + const client = this._createTemporaryClient(); + + const tryFallbackHs = (originalError) => { + const fbClient = Matrix.createClient({ + baseUrl: self._fallbackHsUrl, + idBaseUrl: this._isUrl, + }); + + return fbClient.login('m.login.password', loginParams).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._fallbackHsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((fallback_error) => { + console.log("fallback HS login failed", fallback_error); + // throw the original error + throw originalError; + }); + }; + const tryLowercaseUsername = (originalError) => { + const loginParamsLowercase = Object.assign({}, loginParams, { + user: username.toLowerCase(), + identifier: { + user: username.toLowerCase(), + }, + }); + return client.login('m.login.password', loginParamsLowercase).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._hsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((fallback_error) => { + console.log("Lowercase username login failed", fallback_error); + // throw the original error + throw originalError; + }); + }; + + let originalLoginError = null; + return client.login('m.login.password', loginParams).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._hsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((error) => { + originalLoginError = error; + if (error.httpStatus === 403) { + if (self._fallbackHsUrl) { + return tryFallbackHs(originalLoginError); + } + } + throw originalLoginError; + }).catch((error) => { + // We apparently squash case at login serverside these days: + // https://github.com/matrix-org/synapse/blob/1189be43a2479f5adf034613e8d10e3f4f452eb9/synapse/handlers/auth.py#L475 + // so this wasn't needed after all. Keeping the code around in case the + // the situation changes... + + /* + if ( + error.httpStatus === 403 && + loginParams.identifier.type === 'm.id.user' && + username.search(/[A-Z]/) > -1 + ) { + return tryLowercaseUsername(originalLoginError); + } + */ + throw originalLoginError; + }).catch((error) => { + console.log("Login failed", error); + throw error; + }); + } + + redirectToCas() { + const client = this._createTemporaryClient(); + const parsedUrl = url.parse(window.location.href, true); + + // XXX: at this point, the fragment will always be #/login, which is no + // use to anyone. Ideally, we would get the intended fragment from + // MatrixChat.screenAfterLogin so that you could follow #/room links etc + // through a CAS login. + parsedUrl.hash = ""; + + parsedUrl.query["homeserver"] = client.getHomeserverUrl(); + parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); + const casUrl = client.getCasLoginUrl(url.format(parsedUrl)); + window.location.href = casUrl; + } +} diff --git a/src/Markdown.js b/src/Markdown.js index a7b267b110..aa1c7e45b1 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -14,115 +14,153 @@ See the License for the specific language governing permissions and limitations under the License. */ -import marked from 'marked'; +import commonmark from 'commonmark'; +import escape from 'lodash/escape'; -// marked only applies the default options on the high -// level marked() interface, so we do it here. -const marked_options = Object.assign({}, marked.defaults, { - gfm: true, - tables: true, - breaks: true, - pedantic: false, - sanitize: true, - smartLists: true, - smartypants: false, - xhtml: true, // return self closing tags (ie.
\n/g, '\n').trim() + + '
not
) -}); +const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; + +// These types of node are definitely text +const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; + +function is_allowed_html_tag(node) { + // Regex won't work for tags with attrs, but we only + // allowanyway. + const matches = /^<\/?(.*)>$/.exec(node.literal); + if (matches && matches.length == 2) { + const tag = matches[1]; + return ALLOWED_HTML_TAGS.indexOf(tag) > -1; + } + return false; +} + +function html_if_tag_allowed(node) { + if (is_allowed_html_tag(node)) { + this.lit(node.literal); + return; + } else { + this.lit(escape(node.literal)); + } +} + +/* + * Returns true if the parse output containing the node + * comprises multiple block level elements (ie. lines), + * or false if it is only a single line. + */ +function is_multi_line(node) { + let par = node; + while (par.parent) { + par = par.parent; + } + return par.firstChild != par.lastChild; +} /** - * Class that wraps marked, adding the ability to see whether + * Class that wraps commonmark, adding the ability to see whether * a given message actually uses any markdown syntax or whether * it's plain text. */ export default class Markdown { constructor(input) { - const lexer = new marked.Lexer(marked_options); - this.tokens = lexer.lex(input); - } + this.input = input; - _copyTokens() { - // copy tokens (the parser modifies its input arg) - const tokens_copy = this.tokens.slice(); - // it also has a 'links' property, because this is javascript - // and why wouldn't you have an array that also has properties? - return Object.assign(tokens_copy, this.tokens); + const parser = new commonmark.Parser(); + this.parsed = parser.parse(this.input); } isPlainText() { - // we determine if the message requires markdown by - // running the parser on the tokens with a dummy - // rendered and seeing if any of the renderer's - // functions are called other than those noted below. - // In case you were wondering, no we can't just examine - // the tokens because the tokens we have are only the - // output of the *first* tokenizer: any line-based - // markdown is processed by marked within Parser by - // the 'inline lexer'... - let is_plain = true; + const walker = this.parsed.walker(); - function setNotPlain() { - is_plain = false; - } - - const dummy_renderer = {}; - for (const k of Object.keys(marked.Renderer.prototype)) { - dummy_renderer[k] = setNotPlain; - } - // text and paragraph are just text - dummy_renderer.text = function(t){return t;} - dummy_renderer.paragraph = function(t){return t;} - - // ignore links where text is just the url: - // this ignores plain URLs that markdown has - // detected whilst preserving markdown syntax links - dummy_renderer.link = function(href, title, text) { - if (text != href) { - is_plain = false; + let ev; + while ( (ev = walker.next()) ) { + const node = ev.node; + if (TEXT_NODES.indexOf(node.type) > -1) { + // definitely text + continue; + } else if (node.type == 'html_inline' || node.type == 'html_block') { + // if it's an allowed html tag, we need to render it and therefore + // we will need to use HTML. If it's not allowed, it's not HTML since + // we'll just be treating it as text. + if (is_allowed_html_tag(node)) { + return false; + } + } else { + return false; } } - - const dummy_options = Object.assign({}, marked_options, { - renderer: dummy_renderer, - }); - const dummy_parser = new marked.Parser(dummy_options); - dummy_parser.parse(this._copyTokens()); - - return is_plain; + return true; } toHTML() { - const real_renderer = new marked.Renderer(); - real_renderer.link = function(href, title, text) { - // prevent marked from turning plain URLs - // into links, because its algorithm is fairly - // poor. Let's send plain URLs rather than - // badly linkified ones (the linkifier Vector - // uses on message display is way better, eg. - // handles URLs with closing parens at the end). - if (text == href) { - return href; - } - return marked.Renderer.prototype.link.apply(this, arguments); - } + const renderer = new commonmark.HtmlRenderer({ + safe: false, - real_renderer.paragraph = (text) => { - // The tokens at the top level are the 'blocks', so if we - // have more than one, there are multiple 'paragraphs'. - // If there is only one top level token, just return the - // bare text: it's a single line of text and so should be - // 'inline', rather than necessarily wrapped in its own - // p tag. If, however, we have multiple tokens, each gets - // its own p tag to keep them as separate paragraphs. - if (this.tokens.length == 1) { - return text; - } - return '' + text + '
'; - } - - const real_options = Object.assign({}, marked_options, { - renderer: real_renderer, + // Set soft breaks to hard HTML breaks: commonmark + // puts softbreaks in for multiple lines in a blockquote, + // so if these are just newline characters then the + // block quote ends up all on one line + // (https://github.com/vector-im/riot-web/issues/3154) + softbreak: '
', }); - const real_parser = new marked.Parser(real_options); - return real_parser.parse(this._copyTokens()); + const real_paragraph = renderer.paragraph; + + renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + if (is_multi_line(node)) { + real_paragraph.call(this, node, entering); + } + }; + + renderer.html_inline = html_if_tag_allowed; + renderer.html_block = function(node) { + // as with `paragraph`, we only insert line breaks + // if there are multiple lines in the markdown. + const isMultiLine = is_multi_line(node); + + if (isMultiLine) this.cr(); + html_if_tag_allowed.call(this, node); + if (isMultiLine) this.cr(); + }; + + return renderer.render(this.parsed); + } + + /* + * Render the markdown message to plain text. That is, essentially + * just remove any backslashes escaping what would otherwise be + * markdown syntax + * (to fix https://github.com/vector-im/riot-web/issues/2870) + */ + toPlaintext() { + const renderer = new commonmark.HtmlRenderer({safe: false}); + const real_paragraph = renderer.paragraph; + + // The default `out` function only sends the input through an XML + // escaping function, which causes messages to be entity encoded, + // which we don't want in this case. + renderer.out = function(s) { + // The `lit` function adds a string literal to the output buffer. + this.lit(s); + }; + + renderer.paragraph = function(node, entering) { + // as with toHTML, only append lines to paragraphs if there are + // multiple paragraphs + if (is_multi_line(node)) { + if (!entering && node.next) { + this.lit('\n\n'); + } + } + }; + renderer.html_block = function(node) { + this.lit(node.literal); + if (is_multi_line(node) && node.next) this.lit('\n\n'); + }; + + return renderer.render(this.parsed); } } diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 9c0daf4726..14dfa91fa4 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,5 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd. +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,12 +18,12 @@ limitations under the License. 'use strict'; -import Matrix from 'matrix-js-sdk'; import utils from 'matrix-js-sdk/lib/utils'; import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; - -const localStorage = window.localStorage; +import createMatrixClient from './utils/createMatrixClient'; +import SettingsStore from './settings/SettingsStore'; +import MatrixActionCreators from './actions/MatrixActionCreators'; interface MatrixClientCreds { homeserverUrl: string, @@ -51,12 +53,25 @@ class MatrixClientPeg { }; } + /** + * Sets the script href passed to the IndexedDB web worker + * If set, a separate web worker will be started to run the IndexedDB + * queries on. + * + * @param {string} script href to the script to be passed to the web worker + */ + setIndexedDbWorkerScript(script) { + createMatrixClient.indexedDbWorkerScript = script; + } + get(): MatrixClient { return this.matrixClient; } unset() { this.matrixClient = null; + + MatrixActionCreators.stop(); } /** @@ -67,11 +82,42 @@ class MatrixClientPeg { this._createClient(creds); } - start() { + async start() { + // try to initialise e2e on the new client + try { + // check that we have a version of the js-sdk which includes initCrypto + if (this.matrixClient.initCrypto) { + await this.matrixClient.initCrypto(); + } + } catch (e) { + // this can happen for a number of reasons, the most likely being + // that the olm library was missing. It's not fatal. + console.warn("Unable to initialise e2e: " + e); + } + const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow opts.pendingEventOrdering = "detached"; + opts.disablePresence = true; // we do this manually + + try { + const promise = this.matrixClient.store.startup(); + console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`); + await promise; + } catch (err) { + // log any errors when starting up the database (if one exists) + console.error(`Error starting matrixclient store: ${err}`); + } + + // regardless of errors, start the client. If we did error out, we'll + // just end up doing a full initial /sync. + + // Connect the matrix client to the dispatcher + MatrixActionCreators.start(this.matrixClient); + + console.log(`MatrixClientPeg: really starting MatrixClient`); this.get().startClient(opts); + console.log(`MatrixClientPeg: MatrixClient started`); } getCredentials(): MatrixClientCreds { @@ -99,20 +145,17 @@ class MatrixClientPeg { } _createClient(creds: MatrixClientCreds) { - var opts = { + const opts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, accessToken: creds.accessToken, userId: creds.userId, deviceId: creds.deviceId, timelineSupport: true, + forceTURN: SettingsStore.getValue('webRtcForceTURN', false), }; - if (localStorage) { - opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); - } - - this.matrixClient = Matrix.createClient(opts); + this.matrixClient = createMatrixClient(opts, this.indexedDbWorkerScript); // we're going to add eventlisteners for each matrix event tile, so the // potential number of event listeners is quite high. @@ -120,8 +163,8 @@ class MatrixClientPeg { this.matrixClient.setGuest(Boolean(creds.guest)); - var notifTimelineSet = new EventTimelineSet(null, { - timelineSupport: true + const notifTimelineSet = new EventTimelineSet(null, { + timelineSupport: true, }); // XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync. notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); diff --git a/src/Modal.js b/src/Modal.js index 44072b9278..c9f08772e7 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -17,46 +17,196 @@ limitations under the License. 'use strict'; -var React = require('react'); -var ReactDOM = require('react-dom'); +const React = require('react'); +const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; +import Analytics from './Analytics'; +import sdk from './index'; -module.exports = { - DialogContainerId: "mx_Dialog_Container", +const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; - getOrCreateContainer: function() { - var container = document.getElementById(this.DialogContainerId); +/** + * Wrap an asynchronous loader function with a react component which shows a + * spinner until the real component loads. + */ +const AsyncWrapper = React.createClass({ + propTypes: { + /** A function which takes a 'callback' argument which it will call + * with the real component once it loads. + */ + loader: PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + component: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Starting load of AsyncWrapper for modal'); + this.props.loader((e) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('AsyncWrapper load completed with '+e.displayName); + if (this._unmounted) { + return; + } + this.setState({component: e}); + }); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + render: function() { + const {loader, ...otherProps} = this.props; + if (this.state.component) { + const Component = this.state.component; + return; + } else { + // show a spinner until the component is loaded. + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + }, +}); + +class ModalManager { + constructor() { + this._counter = 0; + + /** list of the modals we have stacked up, with the most recent at [0] */ + this._modals = [ + /* { + elem: React component for this dialog + onFinished: caller-supplied onFinished callback + className: CSS class for the dialog wrapper div + } */ + ]; + + this.closeAll = this.closeAll.bind(this); + } + + getOrCreateContainer() { + let container = document.getElementById(DIALOG_CONTAINER_ID); if (!container) { container = document.createElement("div"); - container.id = this.DialogContainerId; + container.id = DIALOG_CONTAINER_ID; document.body.appendChild(container); } return container; - }, + } - createDialog: function (Element, props, className) { - var self = this; + createTrackedDialog(analyticsAction, analyticsInfo, Element, props, className) { + Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); + return this.createDialog(Element, props, className); + } - // never call this via modal.close() from onFinished() otherwise it will loop - var closeDialog = function() { + createDialog(Element, props, className) { + return this.createDialogAsync((cb) => {cb(Element);}, props, className); + } + + createTrackedDialogAsync(analyticsAction, analyticsInfo, loader, props, className) { + Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); + return this.createDialogAsync(loader, props, className); + } + + /** + * Open a modal view. + * + * This can be used to display a react component which is loaded as an asynchronous + * webpack component. To do this, set 'loader' as: + * + * (cb) => { + * require([' '], cb); + * } + * + * @param {Function} loader a function which takes a 'callback' argument, + * which it should call with a React component which will be displayed as + * the modal view. + * + * @param {Object} props properties to pass to the displayed + * component. (We will also pass an 'onFinished' property.) + * + * @param {String} className CSS class to apply to the modal wrapper + */ + createDialogAsync(loader, props, className) { + const self = this; + const modal = {}; + + // never call this from onFinished() otherwise it will loop + // + // nb explicit function() rather than arrow function, to get `arguments` + const closeDialog = function() { if (props && props.onFinished) props.onFinished.apply(null, arguments); - ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); + const i = self._modals.indexOf(modal); + if (i >= 0) { + self._modals.splice(i, 1); + } + self._reRender(); }; + // don't attempt to reuse the same AsyncWrapper for different dialogs, + // otherwise we'll get confused. + const modalCount = this._counter++; + // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished // property set here so you can't close the dialog from a button click! - var dialog = ( - + modal.elem = ( ++ ); + modal.onFinished = props ? props.onFinished : null; + modal.className = className; + + this._modals.unshift(modal); + + this._reRender(); + return {close: closeDialog}; + } + + closeAll() { + const modals = this._modals; + this._modals = []; + + for (let i = 0; i < modals.length; i++) { + const m = modals[i]; + if (m.onFinished) { + m.onFinished(false); + } + } + + this._reRender(); + } + + _reRender() { + if (this._modals.length == 0) { + ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); + return; + } + + const modal = this._modals[0]; + const dialog = ( + ); ReactDOM.render(dialog, this.getOrCreateContainer()); + } +} - return {close: closeDialog}; - }, -}; +if (!global.singletonModalManager) { + global.singletonModalManager = new ModalManager(); +} +export default global.singletonModalManager; diff --git a/src/Notifier.js b/src/Notifier.js index a58fc0132f..e69bdf4461 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -1,5 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,13 +16,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -var MatrixClientPeg = require("./MatrixClientPeg"); -var PlatformPeg = require("./PlatformPeg"); -var TextForEvent = require('./TextForEvent'); -var Avatar = require('./Avatar'); -var dis = require("./dispatcher"); +import MatrixClientPeg from './MatrixClientPeg'; +import PlatformPeg from './PlatformPeg'; +import TextForEvent from './TextForEvent'; +import Analytics from './Analytics'; +import Avatar from './Avatar'; +import dis from './dispatcher'; +import sdk from './index'; +import { _t } from './languageHandler'; +import Modal from './Modal'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; /* * Dispatches: @@ -30,9 +35,16 @@ var dis = require("./dispatcher"); * } */ -var Notifier = { +const MAX_PENDING_ENCRYPTED = 20; + +const Notifier = { notifsByRoom: {}, + // A list of event IDs that we've received but need to wait until + // they're decrypted until we decide whether to notify for them + // or not + pendingEncryptedEventIds: [], + notificationMessageForEvent: function(ev) { return TextForEvent.textForEvent(ev); }, @@ -49,16 +61,16 @@ var Notifier = { return; } - var msg = this.notificationMessageForEvent(ev); + let msg = this.notificationMessageForEvent(ev); if (!msg) return; - var title; - if (!ev.sender || room.name == ev.sender.name) { + let title; + if (!ev.sender || room.name === ev.sender.name) { title = room.name; // notificationMessageForEvent includes sender, // but we already have the sender here if (ev.getContent().body) msg = ev.getContent().body; - } else if (ev.getType() == 'm.room.member') { + } else if (ev.getType() === 'm.room.member') { // context is all in the message here, we don't need // to display sender info title = room.name; @@ -69,10 +81,11 @@ var Notifier = { if (ev.getContent().body) msg = ev.getContent().body; } - var avatarUrl = ev.sender ? Avatar.avatarUrlForMember( - ev.sender, 40, 40, 'crop' - ) : null; + if (!this.isBodyEnabled()) { + msg = ''; + } + const avatarUrl = ev.sender ? Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop') : null; const notif = plaf.displayNotification(title, msg, avatarUrl, room); // if displayNotification returns non-null, the platform supports @@ -84,31 +97,33 @@ var Notifier = { }, _playAudioNotification: function(ev, room) { - var e = document.getElementById("messageAudio"); + const e = document.getElementById("messageAudio"); if (e) { - e.load(); e.play(); - }; + } }, start: function() { - this.boundOnRoomTimeline = this.onRoomTimeline.bind(this); + this.boundOnEvent = this.onEvent.bind(this); this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); this.boundOnRoomReceipt = this.onRoomReceipt.bind(this); - MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); - MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt); + this.boundOnEventDecrypted = this.onEventDecrypted.bind(this); + MatrixClientPeg.get().on('event', this.boundOnEvent); + MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt); + MatrixClientPeg.get().on('Event.decrypted', this.boundOnEventDecrypted); MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); this.toolbarHidden = false; - this.isPrepared = false; + this.isSyncing = false; }, stop: function() { - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); - MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt); + if (MatrixClientPeg.get() && this.boundOnRoomTimeline) { + MatrixClientPeg.get().removeListener('Event', this.boundOnEvent); + MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt); + MatrixClientPeg.get().removeListener('Event.decrypted', this.boundOnEventDecrypted); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } - this.isPrepared = false; + this.isSyncing = false; }, supportsDesktopNotifications: function() { @@ -119,12 +134,17 @@ var Notifier = { setEnabled: function(enable, callback) { const plaf = PlatformPeg.get(); if (!plaf) return; + + // Dev note: We don't set the "notificationsEnabled" setting to true here because it is a + // calculated value. It is determined based upon whether or not the master rule is enabled + // and other flags. Setting it here would cause a circular reference. + + Analytics.trackEvent('Notifier', 'Set Enabled', enable); + // make sure that we persist the current setting audio_enabled setting // before changing anything - if (global.localStorage) { - if(global.localStorage.getItem('audio_notifications_enabled') == null) { - this.setAudioEnabled(this.isEnabled()); - } + if (SettingsStore.isLevelSupported(SettingLevel.DEVICE)) { + SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, this.isEnabled()); } if (enable) { @@ -132,116 +152,119 @@ var Notifier = { plaf.requestNotificationPermission().done((result) => { if (result !== 'granted') { // The permission request was dismissed or denied + // TODO: Support alternative branding in messaging + const description = result === 'denied' + ? _t('Riot does not have permission to send you notifications - please check your browser settings') + : _t('Riot was not given permission to send notifications - please try again'); + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, { + title: _t('Unable to enable Notifications'), + description, + }); return; } - if (global.localStorage) { - global.localStorage.setItem('notifications_enabled', 'true'); - } - if (callback) callback(); dis.dispatch({ action: "notifier_enabled", - value: true + value: true, }); }); // clear the notifications_hidden flag, so that if notifications are // disabled again in the future, we will show the banner again. - this.setToolbarHidden(false); + this.setToolbarHidden(true); } else { - if (!global.localStorage) return; - global.localStorage.setItem('notifications_enabled', 'false'); dis.dispatch({ action: "notifier_enabled", - value: false + value: false, }); } }, isEnabled: function() { + return this.isPossible() && SettingsStore.getValue("notificationsEnabled"); + }, + + isPossible: function() { const plaf = PlatformPeg.get(); if (!plaf) return false; if (!plaf.supportsNotifications()) return false; if (!plaf.maySendNotifications()) return false; - if (!global.localStorage) return true; - - var enabled = global.localStorage.getItem('notifications_enabled'); - if (enabled === null) return true; - return enabled === 'true'; + return true; // possible, but not necessarily enabled }, - setAudioEnabled: function(enable) { - if (!global.localStorage) return; - global.localStorage.setItem('audio_notifications_enabled', - enable ? 'true' : 'false'); + isBodyEnabled: function() { + return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled"); }, - isAudioEnabled: function(enable) { - if (!global.localStorage) return true; - var enabled = global.localStorage.getItem( - 'audio_notifications_enabled'); - // default to true if the popups are enabled - if (enabled === null) return this.isEnabled(); - return enabled === 'true'; + isAudioEnabled: function() { + return this.isEnabled() && SettingsStore.getValue("audioNotificationsEnabled"); }, setToolbarHidden: function(hidden, persistent = true) { this.toolbarHidden = hidden; + Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); + // XXX: why are we dispatching this here? // this is nothing to do with notifier_enabled dis.dispatch({ action: "notifier_enabled", - value: this.isEnabled() + value: this.isEnabled(), }); // update the info to localStorage for persistent settings if (persistent && global.localStorage) { - global.localStorage.setItem('notifications_hidden', hidden); + global.localStorage.setItem("notifications_hidden", hidden); } }, isToolbarHidden: function() { // Check localStorage for any such meta data if (global.localStorage) { - if (global.localStorage.getItem('notifications_hidden') === 'true') { - return true; - } + return global.localStorage.getItem("notifications_hidden") === "true"; } return this.toolbarHidden; }, onSyncStateChange: function(state) { - if (state === "PREPARED" || state === "SYNCING") { - this.isPrepared = true; - } - else if (state === "STOPPED" || state === "ERROR") { - this.isPrepared = false; + if (state === "SYNCING") { + this.isSyncing = true; + } else if (state === "STOPPED" || state === "ERROR") { + this.isSyncing = false; } }, - onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { - if (toStartOfTimeline) return; - if (!room) return; - if (!this.isPrepared) return; // don't alert for any messages initially - if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; - if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + onEvent: function(ev) { + if (!this.isSyncing) return; // don't alert for any messages initially + if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; - var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); - if (actions && actions.notify) { - if (this.isEnabled()) { - this._displayPopupNotification(ev, room); - } - if (actions.tweaks.sound && this.isAudioEnabled()) { - this._playAudioNotification(ev, room); + // If it's an encrypted event and the type is still 'm.room.encrypted', + // it hasn't yet been decrypted, so wait until it is. + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { + this.pendingEncryptedEventIds.push(ev.getId()); + // don't let the list fill up indefinitely + while (this.pendingEncryptedEventIds.length > MAX_PENDING_ENCRYPTED) { + this.pendingEncryptedEventIds.shift(); } + return; } + + this._evaluateEvent(ev); + }, + + onEventDecrypted: function(ev) { + const idx = this.pendingEncryptedEventIds.indexOf(ev.getId()); + if (idx === -1) return; + + this.pendingEncryptedEventIds.splice(idx, 1); + this._evaluateEvent(ev); }, onRoomReceipt: function(ev, room) { - if (room.getUnreadNotificationCount() == 0) { + if (room.getUnreadNotificationCount() === 0) { // ideally we would clear each notification when it was read, // but we have no way, given a read receipt, to know whether // the receipt comes before or after an event, so we can't @@ -256,7 +279,21 @@ var Notifier = { } delete this.notifsByRoom[room.roomId]; } - } + }, + + _evaluateEvent: function(ev) { + const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); + const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + if (actions && actions.notify) { + if (this.isEnabled()) { + this._displayPopupNotification(ev, room); + } + if (actions.tweaks.sound && this.isAudioEnabled()) { + PlatformPeg.get().loudNotification(ev, room); + this._playAudioNotification(ev, room); + } + } + }, }; if (!global.mxNotifier) { diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js index 07a16df501..07d8b465af 100644 --- a/src/ObjectUtils.js +++ b/src/ObjectUtils.js @@ -23,8 +23,8 @@ limitations under the License. * { key: $KEY, val: $VALUE, place: "add|del" } */ module.exports.getKeyValueArrayDiffs = function(before, after) { - var results = []; - var delta = {}; + const results = []; + const delta = {}; Object.keys(before).forEach(function(beforeKey) { delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially delta[beforeKey]--; // keys present in the past have -ve values @@ -46,9 +46,9 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { results.push({ place: "del", key: muxedKey, val: beforeVal }); }); break; - case 0: // A mix of added/removed keys + case 0: {// A mix of added/removed keys // compare old & new vals - var itemDelta = {}; + const itemDelta = {}; before[muxedKey].forEach(function(beforeVal) { itemDelta[beforeVal] = itemDelta[beforeVal] || 0; itemDelta[beforeVal]--; @@ -64,13 +64,13 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { } else if (itemDelta[item] === -1) { results.push({ place: "del", key: muxedKey, val: item }); } else { - // itemDelta of 0 means it was unchanged between before/after + // itemDelta of 0 means it was unchanged between before/after } }); break; + } default: - console.error("Calculated key delta of " + delta[muxedKey] + - " - this should never happen!"); + console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!"); break; } }); @@ -79,8 +79,10 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { }; /** - * Shallow-compare two objects for equality: each key and value must be - * identical + * Shallow-compare two objects for equality: each key and value must be identical + * @param {Object} objA First object to compare against the second + * @param {Object} objB Second object to compare against the first + * @return {boolean} whether the two objects have same key=values */ module.exports.shallowEqual = function(objA, objB) { if (objA === objB) { @@ -92,15 +94,15 @@ module.exports.shallowEqual = function(objA, objB) { return false; } - var keysA = Object.keys(objA); - var keysB = Object.keys(objB); + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); if (keysA.length !== keysB.length) { return false; } - for (var i = 0; i < keysA.length; i++) { - var key = keysA[i]; + for (let i = 0; i < keysA.length; i++) { + const key = keysA[i]; if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) { return false; } diff --git a/src/PageTypes.js b/src/PageTypes.js index b2e2ecf4bc..66d930c288 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,9 +17,12 @@ limitations under the License. /** The types of page which can be shown by the LoggedInView */ export default { + HomePage: "home_page", RoomView: "room_view", UserSettings: "user_settings", CreateRoom: "create_room", RoomDirectory: "room_directory", UserView: "user_view", + GroupView: "group_view", + MyGroups: "my_groups", }; diff --git a/src/PasswordReset.js b/src/PasswordReset.js index a03a565459..71fc4f6b31 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -var Matrix = require("matrix-js-sdk"); +import * as Matrix from 'matrix-js-sdk'; +import { _t } from './languageHandler'; /** * Allows a user to reset their password on a homeserver. @@ -33,7 +34,7 @@ class PasswordReset { constructor(homeserverUrl, identityUrl) { this.client = Matrix.createClient({ baseUrl: homeserverUrl, - idBaseUrl: identityUrl + idBaseUrl: identityUrl, }); this.clientSecret = this.client.generateClientSecret(); this.identityServerDomain = identityUrl.split("://")[1]; @@ -52,8 +53,8 @@ class PasswordReset { this.sessionId = res.sid; return res; }, function(err) { - if (err.errcode == 'M_THREEPID_NOT_FOUND') { - err.message = "This email address was not found"; + if (err.errcode === 'M_THREEPID_NOT_FOUND') { + err.message = _t('This email address was not found'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } @@ -74,16 +75,15 @@ class PasswordReset { threepid_creds: { sid: this.sessionId, client_secret: this.clientSecret, - id_server: this.identityServerDomain - } + id_server: this.identityServerDomain, + }, }, this.password).catch(function(err) { if (err.httpStatus === 401) { - err.message = "Failed to verify email address: make sure you clicked the link in the email"; - } - else if (err.httpStatus === 404) { - err.message = "Your email address does not appear to be associated with a Matrix ID on this Homeserver."; - } - else if (err.httpStatus) { + err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); + } else if (err.httpStatus === 404) { + err.message = + _t('Your email address does not appear to be associated with a Matrix ID on this Homeserver.'); + } else if (err.httpStatus) { err.message += ` (Status ${err.httpStatus})`; } throw err; diff --git a/src/Presence.js b/src/Presence.js index 4152d7a487..2652c64c96 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require("./MatrixClientPeg"); -var dis = require("./dispatcher"); +const MatrixClientPeg = require("./MatrixClientPeg"); +const dis = require("./dispatcher"); // Time in ms after that a user is considered as unavailable/away -var UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins -var PRESENCE_STATES = ["online", "offline", "unavailable"]; +const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins +const PRESENCE_STATES = ["online", "offline", "unavailable"]; class Presence { @@ -56,13 +56,27 @@ class Presence { return this.state; } + /** + * Get the current status message. + * @returns {String} the status message, may be null + */ + getStatusMessage() { + return this.statusMessage; + } + /** * Set the presence state. * If the state has changed, the Home Server will be notified. * @param {string} newState the new presence state (see PRESENCE enum) + * @param {String} statusMessage an optional status message for the presence + * @param {boolean} maintain true to have this status maintained by this tracker */ - setState(newState) { - if (newState === this.state) { + setState(newState, statusMessage=null, maintain=false) { + if (this.maintain) { + // Don't update presence if we're maintaining a particular status + return; + } + if (newState === this.state && statusMessage === this.statusMessage) { return; } if (PRESENCE_STATES.indexOf(newState) === -1) { @@ -71,22 +85,38 @@ class Presence { if (!this.running) { return; } - var old_state = this.state; + const old_state = this.state; + const old_message = this.statusMessage; this.state = newState; + this.statusMessage = statusMessage; + this.maintain = maintain; if (MatrixClientPeg.get().isGuest()) { return; // don't try to set presence when a guest; it won't work. } - var self = this; - MatrixClientPeg.get().setPresence(this.state).done(function() { + const updateContent = { + presence: this.state, + status_msg: this.statusMessage ? this.statusMessage : '', + }; + + const self = this; + MatrixClientPeg.get().setPresence(updateContent).done(function() { console.log("Presence: %s", newState); + + // We have to dispatch because the js-sdk is unreliable at telling us about our own presence + dis.dispatch({action: "self_presence_updated", statusInfo: updateContent}); }, function(err) { console.error("Failed to set presence: %s", err); self.state = old_state; + self.statusMessage = old_message; }); } + stopMaintainingStatus() { + this.maintain = false; + } + /** * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. * @private @@ -95,7 +125,8 @@ class Presence { this.setState("unavailable"); } - _onUserActivity() { + _onUserActivity(payload) { + if (payload.action === "sync_state" || payload.action === "self_presence_updated") return; this._resetTimer(); } @@ -104,14 +135,14 @@ class Presence { * @private */ _resetTimer() { - var self = this; + const self = this; this.setState("online"); // Re-arm the timer clearTimeout(this.timer); this.timer = setTimeout(function() { self._onUnavailableTimerFire(); }, UNAVAILABLE_TIME_MS); - } + } } module.exports = new Presence(); diff --git a/src/Resend.js b/src/Resend.js index ecf504e780..4eaee16d1b 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -14,31 +14,44 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require('./MatrixClientPeg'); -var dis = require('./dispatcher'); +import MatrixClientPeg from './MatrixClientPeg'; +import dis from './dispatcher'; +import { EventStatus } from 'matrix-js-sdk'; module.exports = { + resendUnsentEvents: function(room) { + room.getPendingEvents().filter(function(ev) { + return ev.status === EventStatus.NOT_SENT; + }).forEach(function(event) { + module.exports.resend(event); + }); + }, + cancelUnsentEvents: function(room) { + room.getPendingEvents().filter(function(ev) { + return ev.status === EventStatus.NOT_SENT; + }).forEach(function(event) { + module.exports.removeFromQueue(event); + }); + }, resend: function(event) { - MatrixClientPeg.get().resendEvent( - event, MatrixClientPeg.get().getRoom(event.getRoomId()) - ).done(function() { + const room = MatrixClientPeg.get().getRoom(event.getRoomId()); + MatrixClientPeg.get().resendEvent(event, room).done(function(res) { dis.dispatch({ action: 'message_sent', - event: event + event: event, }); - }, function() { + }, function(err) { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Resend got send failure: ' + err.name + '('+err+')'); + dis.dispatch({ action: 'message_send_failed', - event: event + event: event, }); }); }, - removeFromQueue: function(event) { MatrixClientPeg.get().cancelPendingEvent(event); - dis.dispatch({ - action: 'message_send_cancelled', - event: event - }); }, }; diff --git a/src/RichText.js b/src/RichText.js index 5fe920fe50..12274ee9f3 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -12,10 +12,11 @@ import { SelectionState, Entity, } from 'draft-js'; -import * as sdk from './index'; +import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; import {SelectionRange} from "./autocomplete/Autocompleter"; +import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, @@ -30,17 +31,35 @@ const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); -export const contentStateToHTML = stateToHTML; +const ZWS_CODE = 8203; +const ZWS = String.fromCharCode(ZWS_CODE); // zero width space +export function stateToMarkdown(state) { + return __stateToMarkdown(state) + .replace( + ZWS, // draft-js-export-markdown adds these + ''); // this is *not* a zero width space, trust me :) +} -export function HTMLtoContentState(html: string): ContentState { - return ContentState.createFromBlockArray(convertFromHTML(html)); +export const contentStateToHTML = (contentState: ContentState) => { + return stateToHTML(contentState, { + inlineStyles: { + UNDERLINE: { + element: 'u', + }, + }, + }); +}; + +export function htmlToContentState(html: string): ContentState { + const blockArray = convertFromHTML(html).contentBlocks; + return ContentState.createFromBlockArray(blockArray); } function unicodeToEmojiUri(str) { let replaceWith, unicode, alt; if ((!emojione.unicodeAlt) || (emojione.sprites)) { // if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames - let mappedUnicode = emojione.mapUnicodeToShort(); + const mappedUnicode = emojione.mapUnicodeToShort(); } str = str.replace(emojione.regUnicode, function(unicodeChar) { @@ -48,8 +67,14 @@ function unicodeToEmojiUri(str) { // if the unicodeChar doesnt exist just return the entire match return unicodeChar; } else { + // Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below + if (unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') { + unicodeChar = unicodeChar[0]; + } + // get the unicode codepoint from the actual char unicode = emojione.jsEscapeMap[unicodeChar]; + return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam; } }); @@ -71,14 +96,14 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb } // Workaround for https://github.com/facebook/draft-js/issues/414 -let emojiDecorator = { - strategy: (contentBlock, callback) => { +const emojiDecorator = { + strategy: (contentState, contentBlock, callback) => { findWithRegex(EMOJI_REGEX, contentBlock, callback); }, component: (props) => { - let uri = unicodeToEmojiUri(props.children[0].props.text); - let shortname = emojione.toShort(props.children[0].props.text); - let style = { + const uri = unicodeToEmojiUri(props.children[0].props.text); + const shortname = emojione.toShort(props.children[0].props.text); + const style = { display: 'inline-block', width: '1em', maxHeight: '1em', @@ -87,7 +112,7 @@ let emojiDecorator = { backgroundPosition: 'center center', overflow: 'hidden', }; - return ({props.children}); + return ({ props.children }); }, }; @@ -95,60 +120,35 @@ let emojiDecorator = { * Returns a composite decorator which has access to provided scope. */ export function getScopedRTDecorators(scope: any): CompositeDecorator { - let MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); - - let usernameDecorator = { - strategy: (contentBlock, callback) => { - findWithRegex(USERNAME_REGEX, contentBlock, callback); - }, - component: (props) => { - let member = scope.room.getMember(props.children[0].props.text); - // unused until we make these decorators immutable (autocomplete needed) - let name = member ? member.name : null; - let avatar = member ?-- ++ { modal.elem } : null; - return {avatar}{props.children}; - } - }; - - let roomDecorator = { - strategy: (contentBlock, callback) => { - findWithRegex(ROOM_REGEX, contentBlock, callback); - }, - component: (props) => { - return {props.children}; - } - }; - - // TODO Re-enable usernameDecorator and roomDecorator return [emojiDecorator]; } export function getScopedMDDecorators(scope: any): CompositeDecorator { - let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( + const markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( (style) => ({ - strategy: (contentBlock, callback) => { + strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); }, component: (props) => ( - {props.children} + { props.children } - ) + ), })); markdownDecorators.push({ - strategy: (contentBlock, callback) => { + strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback); }, component: (props) => ( - {props.children} + { props.children } - ) + ), }); - markdownDecorators.push(emojiDecorator); - - return markdownDecorators; + // markdownDecorators.push(emojiDecorator); + // TODO Consider renabling "syntax highlighting" when we can do it properly + return [emojiDecorator]; } /** @@ -167,7 +167,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection for (let currentKey = startKey; currentKey && currentKey !== endKey; currentKey = contentState.getKeyAfter(currentKey)) { - let blockText = getText(currentKey); + const blockText = getText(currentKey); text += blockText.substring(startOffset, blockText.length); // from now on, we'll take whole blocks @@ -188,7 +188,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection export function selectionStateToTextOffsets(selectionState: SelectionState, contentBlocks: Array ): {start: number, end: number} { let offset = 0, start = 0, end = 0; - for (let block of contentBlocks) { + for (const block of contentBlocks) { if (selectionState.getStartKey() === block.getKey()) { start = offset + selectionState.getStartOffset(); } @@ -208,31 +208,36 @@ export function selectionStateToTextOffsets(selectionState: SelectionState, export function textOffsetsToSelectionState({start, end}: SelectionRange, contentBlocks: Array ): SelectionState { let selectionState = SelectionState.createEmpty(); - - for (let block of contentBlocks) { - let blockLength = block.getLength(); - - if (start !== -1 && start < blockLength) { - selectionState = selectionState.merge({ - anchorKey: block.getKey(), - anchorOffset: start, - }); - start = -1; - } else { - start -= blockLength; + // Subtract block lengths from `start` and `end` until they are less than the current + // block length (accounting for the NL at the end of each block). Set them to -1 to + // indicate that the corresponding selection state has been determined. + for (const block of contentBlocks) { + const blockLength = block.getLength(); + // -1 indicating that the position start position has been found + if (start !== -1) { + if (start < blockLength + 1) { + selectionState = selectionState.merge({ + anchorKey: block.getKey(), + anchorOffset: start, + }); + start = -1; // selection state for the start calculated + } else { + start -= blockLength + 1; // +1 to account for newline between blocks + } } - - if (end !== -1 && end <= blockLength) { - selectionState = selectionState.merge({ - focusKey: block.getKey(), - focusOffset: end, - }); - end = -1; - } else { - end -= blockLength; + // -1 indicating that the position end position has been found + if (end !== -1) { + if (end < blockLength + 1) { + selectionState = selectionState.merge({ + focusKey: block.getKey(), + focusOffset: end, + }); + end = -1; // selection state for the end calculated + } else { + end -= blockLength + 1; // +1 to account for newline between blocks + } } } - return selectionState; } @@ -249,7 +254,7 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor const existingEntityKey = block.getEntityAt(start); if (existingEntityKey) { // avoid manipulation in case the emoji already has an entity - const entity = Entity.get(existingEntityKey); + const entity = newContentState.getEntity(existingEntityKey); if (entity && entity.get('type') === 'emoji') { return; } @@ -259,7 +264,10 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor .set('anchorOffset', start) .set('focusOffset', end); const emojiText = plainText.substring(start, end); - const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText }); + newContentState = newContentState.createEntity( + 'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }, + ); + const entityKey = newContentState.getLastCreatedEntityKey(); newContentState = Modifier.replaceText( newContentState, selection, @@ -286,3 +294,14 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor return editorState; } + +export function hasMultiLineSelection(editorState: EditorState): boolean { + const selectionState = editorState.getSelection(); + const anchorKey = selectionState.getAnchorKey(); + const currentContent = editorState.getCurrentContent(); + const currentContentBlock = currentContent.getBlockForKey(anchorKey); + const start = selectionState.getStartOffset(); + const end = selectionState.getEndOffset(); + const selectedText = currentContentBlock.getText().slice(start, end); + return selectedText.includes('\n'); +} diff --git a/src/Roles.js b/src/Roles.js new file mode 100644 index 0000000000..438b6c1236 --- /dev/null +++ b/src/Roles.js @@ -0,0 +1,35 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { _t } from './languageHandler'; + +export function levelRoleMap(usersDefault) { + return { + undefined: _t('Default'), + 0: _t('Restricted'), + [usersDefault]: _t('Default'), + 50: _t('Moderator'), + 100: _t('Admin'), + }; +} + +export function textualPowerLevel(level, usersDefault) { + const LEVEL_ROLE_MAP = this.levelRoleMap(usersDefault); + if (LEVEL_ROLE_MAP[level]) { + return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`); + } else { + return level; + } +} diff --git a/src/RoomInvite.js b/src/RoomInvite.js new file mode 100644 index 0000000000..31541148d9 --- /dev/null +++ b/src/RoomInvite.js @@ -0,0 +1,205 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import MatrixClientPeg from './MatrixClientPeg'; +import MultiInviter from './utils/MultiInviter'; +import Modal from './Modal'; +import { getAddressType } from './UserAddress'; +import createRoom from './createRoom'; +import sdk from './'; +import dis from './dispatcher'; +import DMRoomMap from './utils/DMRoomMap'; +import { _t } from './languageHandler'; + +export function inviteToRoom(roomId, addr) { + const addrType = getAddressType(addr); + + if (addrType == 'email') { + return MatrixClientPeg.get().inviteByEmail(roomId, addr); + } else if (addrType == 'mx-user-id') { + return MatrixClientPeg.get().invite(roomId, addr); + } else { + throw new Error('Unsupported address'); + } +} + +/** + * Invites multiple addresses to a room + * Simpler interface to utils/MultiInviter but with + * no option to cancel. + * + * @param {string} roomId The ID of the room to invite to + * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. + * @returns {Promise} Promise + */ +export function inviteMultipleToRoom(roomId, addrs) { + const inviter = new MultiInviter(roomId); + return inviter.invite(addrs); +} + +export function showStartChatInviteDialog() { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { + title: _t('Start a chat'), + description: _t("Who would you like to communicate with?"), + placeholder: _t("Email, name or matrix ID"), + button: _t("Start Chat"), + onFinished: _onStartChatFinished, + }); +} + +export function showRoomInviteDialog(roomId) { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { + title: _t('Invite new room members'), + description: _t('Who would you like to add to this room?'), + button: _t('Send Invites'), + placeholder: _t("Email, name or matrix ID"), + onFinished: (shouldInvite, addrs) => { + _onRoomInviteFinished(roomId, shouldInvite, addrs); + }, + }); +} + +function _onStartChatFinished(shouldInvite, addrs) { + if (!shouldInvite) return; + + const addrTexts = addrs.map((addr) => addr.address); + + if (_isDmChat(addrTexts)) { + const rooms = _getDirectMessageRooms(addrTexts[0]); + if (rooms.length > 0) { + // A Direct Message room already exists for this user, so select a + // room from a list that is similar to the one in MemberInfo panel + const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog"); + const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, { + userId: addrTexts[0], + onNewDMClick: () => { + dis.dispatch({ + action: 'start_chat', + user_id: addrTexts[0], + }); + close(true); + }, + onExistingRoomSelected: (roomId) => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); + close(true); + }, + }).close; + } else { + // Start a new DM chat + createRoom({dmUserId: addrTexts[0]}).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { + title: _t("Failed to invite user"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + } + } else if (addrTexts.length === 1) { + // Start a new DM chat + createRoom({dmUserId: addrTexts[0]}).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { + title: _t("Failed to invite user"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + } else { + // Start multi user chat + let room; + createRoom().then((roomId) => { + room = MatrixClientPeg.get().getRoom(roomId); + return inviteMultipleToRoom(roomId, addrTexts); + }).then((addrs) => { + return _showAnyInviteErrors(addrs, room); + }).catch((err) => { + console.error(err.stack); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + } +} + +function _onRoomInviteFinished(roomId, shouldInvite, addrs) { + if (!shouldInvite) return; + + const addrTexts = addrs.map((addr) => addr.address); + + // Invite new users to a room + inviteMultipleToRoom(roomId, addrTexts).then((addrs) => { + const room = MatrixClientPeg.get().getRoom(roomId); + return _showAnyInviteErrors(addrs, room); + }).catch((err) => { + console.error(err.stack); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); +} + +function _isDmChat(addrTexts) { + if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') { + return true; + } else { + return false; + } +} + +function _showAnyInviteErrors(addrs, room) { + // Show user any errors + const errorList = []; + for (const addr of Object.keys(addrs)) { + if (addrs[addr] === "error") { + errorList.push(addr); + } + } + + if (errorList.length > 0) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { + title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), + description: errorList.join(", "), + }); + } + return addrs; +} + +function _getDirectMessageRooms(addr) { + const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); + const dmRooms = dmRoomMap.getDMRoomsForUserId(addr); + const rooms = []; + dmRooms.forEach((dmRoom) => { + const room = MatrixClientPeg.get().getRoom(dmRoom); + if (room) { + const me = room.getMember(MatrixClientPeg.get().credentials.userId); + if (me.membership == 'join') { + rooms.push(room); + } + } + }); + return rooms; +} + diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index 09f178dd3f..c06cc60c97 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -19,18 +19,17 @@ limitations under the License. function tsOfNewestEvent(room) { if (room.timeline.length) { return room.timeline[room.timeline.length - 1].getTs(); - } - else { + } else { return Number.MAX_SAFE_INTEGER; } } function mostRecentActivityFirst(roomList) { - return roomList.sort(function(a,b) { + return roomList.sort(function(a, b) { return tsOfNewestEvent(b) - tsOfNewestEvent(a); }); } module.exports = { - mostRecentActivityFirst: mostRecentActivityFirst + mostRecentActivityFirst, }; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index d0cdd6ead7..5cc078dc59 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -16,7 +16,7 @@ limitations under the License. import MatrixClientPeg from './MatrixClientPeg'; import PushProcessor from 'matrix-js-sdk/lib/pushprocessor'; -import q from 'q'; +import Promise from 'bluebird'; export const ALL_MESSAGES_LOUD = 'all_messages_loud'; export const ALL_MESSAGES = 'all_messages'; @@ -52,7 +52,7 @@ export function getRoomNotifsState(roomId) { } export function setRoomNotifsState(roomId, newState) { - if (newState == MUTE) { + if (newState === MUTE) { return setRoomNotifsStateMuted(roomId); } else { return setRoomNotifsStateUnmuted(roomId, newState); @@ -80,14 +80,14 @@ function setRoomNotifsStateMuted(roomId) { kind: 'event_match', key: 'room_id', pattern: roomId, - } + }, ], actions: [ 'dont_notify', - ] + ], })); - return q.all(promises); + return Promise.all(promises); } function setRoomNotifsStateUnmuted(roomId, newState) { @@ -99,16 +99,16 @@ function setRoomNotifsStateUnmuted(roomId, newState) { promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id)); } - if (newState == 'all_messages') { + if (newState === 'all_messages') { const roomRule = cli.getRoomPushRule('global', roomId); if (roomRule) { promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); } - } else if (newState == 'mentions_only') { + } else if (newState === 'mentions_only') { promises.push(cli.addPushRule('global', 'room', roomId, { actions: [ 'dont_notify', - ] + ], })); // https://matrix.org/jira/browse/SPEC-400 promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); @@ -119,14 +119,14 @@ function setRoomNotifsStateUnmuted(roomId, newState) { { set_tweak: 'sound', value: 'default', - } - ] + }, + ], })); // https://matrix.org/jira/browse/SPEC-400 promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); } - return q.all(promises); + return Promise.all(promises); } function findOverrideMuteRule(roomId) { @@ -145,20 +145,10 @@ function isRuleForRoom(roomId, rule) { return false; } const cond = rule.conditions[0]; - if ( - cond.kind == 'event_match' && - cond.key == 'room_id' && - cond.pattern == roomId - ) { - return true; - } - return false; + return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId); } function isMuteRule(rule) { - return ( - rule.actions.length == 1 && - rule.actions[0] == 'dont_notify' - ); + return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify'); } diff --git a/src/Rooms.js b/src/Rooms.js index cf62f2dda0..ffa39141ff 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -15,8 +15,7 @@ limitations under the License. */ import MatrixClientPeg from './MatrixClientPeg'; -import DMRoomMap from './utils/DMRoomMap'; -import q from 'q'; +import Promise from 'bluebird'; /** * Given a room object, return the alias we should use for it, @@ -37,14 +36,14 @@ export function getOnlyOtherMember(room, me) { if (joinedMembers.length === 2) { return joinedMembers.filter(function(m) { - return m.userId !== me.userId + return m.userId !== me.userId; })[0]; } return null; } -export function isConfCallRoom(room, me, conferenceHandler) { +function _isConfCallRoom(room, me, conferenceHandler) { if (!conferenceHandler) return false; if (me.membership != "join") { @@ -59,12 +58,31 @@ export function isConfCallRoom(room, me, conferenceHandler) { if (conferenceHandler.isConferenceUser(otherMember.userId)) { return true; } + + return false; +} + +// Cache whether a room is a conference call. Assumes that rooms will always +// either will or will not be a conference call room. +const isConfCallRoomCache = { + // $roomId: bool +}; + +export function isConfCallRoom(room, me, conferenceHandler) { + if (isConfCallRoomCache[room.roomId] !== undefined) { + return isConfCallRoomCache[room.roomId]; + } + + const result = _isConfCallRoom(room, me, conferenceHandler); + + isConfCallRoomCache[room.roomId] = result; + + return result; } export function looksLikeDirectMessageRoom(room, me) { if (me.membership == "join" || me.membership === "ban" || - (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) - { + (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) { // Used to split rooms via tags const tagNames = Object.keys(room.tags); // Used for 1:1 direct chats @@ -79,6 +97,20 @@ export function looksLikeDirectMessageRoom(room, me) { return false; } +export function guessAndSetDMRoom(room, isDirect) { + let newTarget; + if (isDirect) { + const guessedTarget = guessDMRoomTarget( + room, room.getMember(MatrixClientPeg.get().credentials.userId), + ); + newTarget = guessedTarget.userId; + } else { + newTarget = null; + } + + return setDMRoom(room.roomId, newTarget); +} + /** * Marks or unmarks the given room as being as a DM room. * @param {string} roomId The ID of the room to modify @@ -89,7 +121,7 @@ export function looksLikeDirectMessageRoom(room, me) { */ export function setDMRoom(roomId, userId) { if (MatrixClientPeg.get().isGuest()) { - return q(); + return Promise.resolve(); } const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct'); @@ -131,7 +163,18 @@ export function guessDMRoomTarget(room, me) { let oldestTs; let oldestUser; - // Pick the user who's been here longest (and isn't us) + // Pick the joined user who's been here longest (and isn't us), + for (const user of room.getJoinedMembers()) { + if (user.userId == me.userId) continue; + + if (oldestTs === undefined || user.events.member.getTs() < oldestTs) { + oldestUser = user; + oldestTs = user.events.member.getTs(); + } + } + if (oldestUser) return oldestUser; + + // if there are no joined members other than us, use the oldest member for (const user of room.currentState.getMembers()) { if (user.userId == me.userId) continue; diff --git a/src/RtsClient.js b/src/RtsClient.js new file mode 100644 index 0000000000..493b19599c --- /dev/null +++ b/src/RtsClient.js @@ -0,0 +1,104 @@ +import 'whatwg-fetch'; + +let fetchFunction = fetch; + +function checkStatus(response) { + if (!response.ok) { + return response.text().then((text) => { + throw new Error(text); + }); + } + return response; +} + +function parseJson(response) { + return response.json(); +} + +function encodeQueryParams(params) { + return '?' + Object.keys(params).map((k) => { + return k + '=' + encodeURIComponent(params[k]); + }).join('&'); +} + +const request = (url, opts) => { + if (opts && opts.qs) { + url += encodeQueryParams(opts.qs); + delete opts.qs; + } + if (opts && opts.body) { + if (!opts.headers) { + opts.headers = {}; + } + opts.body = JSON.stringify(opts.body); + opts.headers['Content-Type'] = 'application/json'; + } + return fetchFunction(url, opts) + .then(checkStatus) + .then(parseJson); +}; + + +export default class RtsClient { + constructor(url) { + this._url = url; + } + + getTeamsConfig() { + return request(this._url + '/teams'); + } + + /** + * Track a referral with the Riot Team Server. This should be called once a referred + * user has been successfully registered. + * @param {string} referrer the user ID of one who referred the user to Riot. + * @param {string} sid the sign-up identity server session ID . + * @param {string} clientSecret the sign-up client secret. + * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon + * success. + */ + trackReferral(referrer, sid, clientSecret) { + return request(this._url + '/register', + { + body: { + referrer: referrer, + session_id: sid, + client_secret: clientSecret, + }, + method: 'POST', + }, + ); + } + + getTeam(teamToken) { + return request(this._url + '/teamConfiguration', + { + qs: { + team_token: teamToken, + }, + }, + ); + } + + /** + * Signal to the RTS that a login has occurred and that a user requires their team's + * token. + * @param {string} userId the user ID of the user who is a member of a team. + * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon + * success. + */ + login(userId) { + return request(this._url + '/login', + { + qs: { + user_id: userId, + }, + }, + ); + } + + // allow fetch to be replaced, for testing. + static setFetch(fn) { + fetchFunction = fn; + } +} diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index e1928e15d4..568dd6d185 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -var q = require("q"); -var request = require('browser-request'); +import Promise from 'bluebird'; +import SettingsStore from "./settings/SettingsStore"; +const request = require('browser-request'); -var SdkConfig = require('./SdkConfig'); -var MatrixClientPeg = require('./MatrixClientPeg'); +const SdkConfig = require('./SdkConfig'); +const MatrixClientPeg = require('./MatrixClientPeg'); class ScalarAuthClient { @@ -38,11 +39,53 @@ class ScalarAuthClient { // Returns a scalar_token string getScalarToken() { - var tok = window.localStorage.getItem("mx_scalar_token"); - if (tok) return q(tok); + const token = window.localStorage.getItem("mx_scalar_token"); - // No saved token, so do the dance to get one. First, we - // need an openid bearer token from the HS. + if (!token) { + return this.registerForToken(); + } else { + return this.validateToken(token).then(userId => { + const me = MatrixClientPeg.get().getUserId(); + if (userId !== me) { + throw new Error("Scalar token is owned by someone else: " + me); + } + return token; + }).catch(err => { + console.error(err); + + // Something went wrong - try to get a new token. + console.warn("Registering for new scalar token"); + return this.registerForToken(); + }) + } + } + + validateToken(token) { + let url = SdkConfig.get().integrations_rest_url + "/account"; + + const defer = Promise.defer(); + request({ + method: "GET", + uri: url, + qs: {scalar_token: token}, + json: true, + }, (err, response, body) => { + if (err) { + defer.reject(err); + } else if (response.statusCode / 100 !== 2) { + defer.reject({statusCode: response.statusCode}); + } else if (!body || !body.user_id) { + defer.reject(new Error("Missing user_id in response")); + } else { + defer.resolve(body.user_id); + } + }); + + return defer.promise; + } + + registerForToken() { + // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((token_object) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(token_object); @@ -53,9 +96,9 @@ class ScalarAuthClient { } exchangeForScalarToken(openid_token_object) { - var defer = q.defer(); + const defer = Promise.defer(); - var scalar_rest_url = SdkConfig.get().integrations_rest_url; + const scalar_rest_url = SdkConfig.get().integrations_rest_url; request({ method: 'POST', uri: scalar_rest_url+'/register', @@ -76,10 +119,46 @@ class ScalarAuthClient { return defer.promise; } - getScalarInterfaceUrlForRoom(roomId) { - var url = SdkConfig.get().integrations_ui_url; + getScalarPageTitle(url) { + const defer = Promise.defer(); + + let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup'; + scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); + scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); + request({ + method: 'GET', + uri: scalarPageLookupUrl, + json: true, + }, (err, response, body) => { + if (err) { + defer.reject(err); + } else if (response.statusCode / 100 !== 2) { + defer.reject({statusCode: response.statusCode}); + } else if (!body) { + defer.reject(new Error("Missing page title in response")); + } else { + let title = ""; + if (body.page_title_cache_item && body.page_title_cache_item.cached_title) { + title = body.page_title_cache_item.cached_title; + } + defer.resolve(title); + } + }); + + return defer.promise; + } + + getScalarInterfaceUrlForRoom(roomId, screen, id) { + let url = SdkConfig.get().integrations_ui_url; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); + url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme")); + if (id) { + url += '&integ_id=' + encodeURIComponent(id); + } + if (screen) { + url += '&screen=' + encodeURIComponent(screen); + } return url; } @@ -89,4 +168,3 @@ class ScalarAuthClient { } module.exports = ScalarAuthClient; - diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 75062daaa2..3c164c6551 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,7 +18,7 @@ limitations under the License. /* Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed: { - action: "invite" | "membership_state" | "bot_options" | "set_bot_options", + action: "invite" | "membership_state" | "bot_options" | "set_bot_options" | etc... , room_id: $ROOM_ID, user_id: $USER_ID // additional request fields @@ -94,6 +95,115 @@ Example: } } +get_membership_count +-------------------- +Get the number of joined users in the room. + +Request: + - room_id is the room to get the count in. +Response: +78 +Example: +{ + action: "get_membership_count", + room_id: "!foo:bar", + response: 78 +} + +can_send_event +-------------- +Check if the client can send the given event into the given room. If the client +is unable to do this, an error response is returned instead of 'response: false'. + +Request: + - room_id is the room to do the check in. + - event_type is the event type which will be sent. + - is_state is true if the event to be sent is a state event. +Response: +true +Example: +{ + action: "can_send_event", + is_state: false, + event_type: "m.room.message", + room_id: "!foo:bar", + response: true +} + +set_widget +---------- +Set a new widget in the room. Clobbers based on the ID. + +Request: + - `room_id` (String) is the room to set the widget in. + - `widget_id` (String) is the ID of the widget to add (or replace if it already exists). + It can be an arbitrary UTF8 string and is purely for distinguishing between widgets. + - `url` (String) is the URL that clients should load in an iframe to run the widget. + All widgets must have a valid URL. If the URL is `null` (not `undefined`), the + widget will be removed from the room. + - `type` (String) is the type of widget, which is provided as a hint for matrix clients so they + can configure/lay out the widget in different ways. All widgets must have a type. + - `name` (String) is an optional human-readable string about the widget. + - `data` (Object) is some optional data about the widget, and can contain arbitrary key/value pairs. +Response: +{ + success: true +} +Example: +{ + action: "set_widget", + room_id: "!foo:bar", + widget_id: "abc123", + url: "http://widget.url", + type: "example", + response: { + success: true + } +} + +get_widgets +----------- +Get a list of all widgets in the room. The response is an array +of state events. + +Request: + - `room_id` (String) is the room to get the widgets in. +Response: +[ + { + type: "im.vector.modular.widgets", + state_key: "wid1", + content: { + type: "grafana", + url: "https://grafanaurl", + name: "dashboard", + data: {key: "val"} + } + room_id: “!foo:bar”, + sender: "@alice:localhost" + } +] +Example: +{ + action: "get_widgets", + room_id: "!foo:bar", + response: [ + { + type: "im.vector.modular.widgets", + state_key: "wid1", + content: { + type: "grafana", + url: "https://grafanaurl", + name: "dashboard", + data: {key: "val"} + } + room_id: “!foo:bar”, + sender: "@alice:localhost" + } + ] +} + + membership_state AND bot_options -------------------------------- Get the content of the "m.room.member" or "m.room.bot.options" state event respectively. @@ -125,6 +235,7 @@ const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require("./MatrixClientPeg"); const MatrixEvent = require("matrix-js-sdk").MatrixEvent; const dis = require("./dispatcher"); +import { _t } from './languageHandler'; function sendResponse(event, res) { const data = JSON.parse(JSON.stringify(event.data)); @@ -150,7 +261,7 @@ function inviteUser(event, roomId, userId) { console.log(`Received request to invite ${userId} into room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } const room = client.getRoom(roomId); @@ -170,10 +281,107 @@ function inviteUser(event, roomId, userId) { success: true, }); }, function(err) { - sendError(event, "You need to be able to invite users to do that.", err); + sendError(event, _t('You need to be able to invite users to do that.'), err); }); } +function setWidget(event, roomId) { + const widgetId = event.data.widget_id; + const widgetType = event.data.type; + const widgetUrl = event.data.url; + const widgetName = event.data.name; // optional + const widgetData = event.data.data; // optional + + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + + // both adding/removing widgets need these checks + if (!widgetId || widgetUrl === undefined) { + sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields.")); + return; + } + + if (widgetUrl !== null) { // if url is null it is being deleted, don't need to check name/type/etc + // check types of fields + if (widgetName !== undefined && typeof widgetName !== 'string') { + sendError(event, _t("Unable to create widget."), new Error("Optional field 'name' must be a string.")); + return; + } + if (widgetData !== undefined && !(widgetData instanceof Object)) { + sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object.")); + return; + } + if (typeof widgetType !== 'string') { + sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string.")); + return; + } + if (typeof widgetUrl !== 'string') { + sendError(event, _t("Unable to create widget."), new Error("Field 'url' must be a string or null.")); + return; + } + } + + let content = { + type: widgetType, + url: widgetUrl, + name: widgetName, + data: widgetData, + }; + if (widgetUrl === null) { // widget is being deleted + content = {}; + } + + client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { + sendResponse(event, { + success: true, + }); + }, (err) => { + sendError(event, _t('Failed to send request.'), err); + }); +} + +function getWidgets(event, roomId) { + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; + } + const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + // Only return widgets which have required fields + const widgetStateEvents = []; + stateEvents.forEach((ev) => { + if (ev.getContent().type && ev.getContent().url) { + widgetStateEvents.push(ev.event); // return the raw event + } + }); + + sendResponse(event, widgetStateEvents); +} + +function getRoomEncState(event, roomId) { + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; + } + const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId); + + sendResponse(event, roomIsEncrypted); +} + function setPlumbingState(event, roomId, status) { if (typeof status !== 'string') { throw new Error('Plumbing state status should be a string'); @@ -181,15 +389,15 @@ function setPlumbingState(event, roomId, status) { console.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } - client.sendStateEvent(roomId, "m.room.plumbing", { status : status }).done(() => { + client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).done(() => { sendResponse(event, { success: true, }); }, (err) => { - sendError(event, err.message ? err.message : "Failed to send request.", err); + sendError(event, err.message ? err.message : _t('Failed to send request.'), err); }); } @@ -197,7 +405,7 @@ function setBotOptions(event, roomId, userId) { console.log(`Received request to set options for bot ${userId} in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => { @@ -205,29 +413,29 @@ function setBotOptions(event, roomId, userId) { success: true, }); }, (err) => { - sendError(event, err.message ? err.message : "Failed to send request.", err); + sendError(event, err.message ? err.message : _t('Failed to send request.'), err); }); } function setBotPower(event, roomId, userId, level) { if (!(Number.isInteger(level) && level >= 0)) { - sendError(event, "Power level must be positive integer."); + sendError(event, _t('Power level must be positive integer.')); return; } console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`); const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => { - let powerEvent = new MatrixEvent( + const powerEvent = new MatrixEvent( { type: "m.room.power_levels", content: powerLevels, - } + }, ); client.setPowerLevel(roomId, userId, level, powerEvent).done(() => { @@ -235,7 +443,7 @@ function setBotPower(event, roomId, userId, level) { success: true, }); }, (err) => { - sendError(event, err.message ? err.message : "Failed to send request.", err); + sendError(event, err.message ? err.message : _t('Failed to send request.'), err); }); }); } @@ -255,15 +463,65 @@ function botOptions(event, roomId, userId) { returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); } -function returnStateEvent(event, roomId, eventType, stateKey) { +function getMembershipCount(event, roomId) { const client = MatrixClientPeg.get(); if (!client) { - sendError(event, "You need to be logged in."); + sendError(event, _t('You need to be logged in.')); return; } const room = client.getRoom(roomId); if (!room) { - sendError(event, "This room is not recognised."); + sendError(event, _t('This room is not recognised.')); + return; + } + const count = room.getJoinedMembers().length; + sendResponse(event, count); +} + +function canSendEvent(event, roomId) { + const evType = "" + event.data.event_type; // force stringify + const isState = Boolean(event.data.is_state); + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; + } + const me = client.credentials.userId; + const member = room.getMember(me); + if (!member || member.membership !== "join") { + sendError(event, _t('You are not in this room.')); + return; + } + + let canSend = false; + if (isState) { + canSend = room.currentState.maySendStateEvent(evType, me); + } else { + canSend = room.currentState.maySendEvent(evType, me); + } + + if (!canSend) { + sendError(event, _t('You do not have permission to do that in this room.')); + return; + } + + sendResponse(event, true); +} + +function returnStateEvent(event, roomId, eventType, stateKey) { + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t('You need to be logged in.')); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); return; } const stateEvent = room.currentState.getStateEvents(eventType, stateKey); @@ -274,8 +532,8 @@ function returnStateEvent(event, roomId, eventType, stateKey) { sendResponse(event, stateEvent.getContent()); } -var currentRoomId = null; -var currentRoomAlias = null; +let currentRoomId = null; +let currentRoomAlias = null; // Listen for when a room is viewed dis.register(onAction); @@ -299,8 +557,16 @@ const onMessage = function(event) { // // All strings start with the empty string, so for sanity return if the length // of the event origin is 0. - let url = SdkConfig.get().integrations_ui_url; - if (event.origin.length === 0 || !url.startsWith(event.origin)) { + // + // TODO -- Scalar postMessage API should be namespaced with event.data.api field + // Fix following "if" statement to respond only to specific API messages. + const url = SdkConfig.get().integrations_ui_url; + if ( + event.origin.length === 0 || + !url.startsWith(event.origin) || + !event.data.action || + event.data.api // Ignore messages with specific API set + ) { return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise } @@ -313,13 +579,13 @@ const onMessage = function(event) { const roomId = event.data.room_id; const userId = event.data.user_id; if (!roomId) { - sendError(event, "Missing room_id in request"); + sendError(event, _t('Missing room_id in request')); return; } let promise = Promise.resolve(currentRoomId); if (!currentRoomId) { if (!currentRoomAlias) { - sendError(event, "Must be viewing a room"); + sendError(event, _t('Must be viewing a room')); return; } // no room ID but there is an alias, look it up. @@ -331,21 +597,36 @@ const onMessage = function(event) { promise.then((viewingRoomId) => { if (roomId !== viewingRoomId) { - sendError(event, "Room " + roomId + " not visible"); + sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId})); return; } - // Getting join rules does not require userId + // These APIs don't require userId if (event.data.action === "join_rules_state") { getJoinRules(event, roomId); return; } else if (event.data.action === "set_plumbing_state") { setPlumbingState(event, roomId, event.data.status); return; + } else if (event.data.action === "get_membership_count") { + getMembershipCount(event, roomId); + return; + } else if (event.data.action === "set_widget") { + setWidget(event, roomId); + return; + } else if (event.data.action === "get_widgets") { + getWidgets(event, roomId); + return; + } else if (event.data.action === "get_room_enc_state") { + getRoomEncState(event, roomId); + return; + } else if (event.data.action === "can_send_event") { + canSendEvent(event, roomId); + return; } if (!userId) { - sendError(event, "Missing user_id in request"); + sendError(event, _t('Missing user_id in request')); return; } switch (event.data.action) { @@ -370,16 +651,31 @@ const onMessage = function(event) { } }, (err) => { console.error(err); - sendError(event, "Failed to lookup current room."); - }) + sendError(event, _t('Failed to lookup current room') + '.'); + }); }; +let listenerCount = 0; module.exports = { startListening: function() { - window.addEventListener("message", onMessage, false); + if (listenerCount === 0) { + window.addEventListener("message", onMessage, false); + } + listenerCount += 1; }, stopListening: function() { - window.removeEventListener("message", onMessage); + listenerCount -= 1; + if (listenerCount === 0) { + window.removeEventListener("message", onMessage); + } + if (listenerCount < 0) { + // Make an error so we get a stack trace + const e = new Error( + "ScalarMessaging: mismatched startListening / stopListening detected." + + " Negative count", + ); + console.error(e); + } }, }; diff --git a/src/SdkConfig.js b/src/SdkConfig.js index 1452aaa64b..64bf21ecf8 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -14,22 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -var DEFAULTS = { +const DEFAULTS = { // URL to a page we show in an iframe to configure integrations integrations_ui_url: "https://scalar.vector.im/", // Base URL to the REST interface of the integrations server integrations_rest_url: "https://scalar.vector.im/api", + // Where to send bug reports. If not specified, bugs cannot be sent. + bug_report_endpoint_url: null, + + piwik: { + url: "https://piwik.riot.im/", + whitelistedHSUrls: ["https://matrix.org"], + whitelistedISUrls: ["https://vector.im", "https://matrix.org"], + siteId: 1, + }, }; class SdkConfig { static get() { - return global.mxReactSdkConfig; + return global.mxReactSdkConfig || {}; } static put(cfg) { - var defaultKeys = Object.keys(DEFAULTS); - for (var i = 0; i < defaultKeys.length; ++i) { + const defaultKeys = Object.keys(DEFAULTS); + for (let i = 0; i < defaultKeys.length; ++i) { if (cfg[defaultKeys[i]] === undefined) { cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]]; } @@ -43,3 +52,4 @@ class SdkConfig { } module.exports = SdkConfig; +module.exports.DEFAULTS = DEFAULTS; diff --git a/src/Signup.js b/src/Signup.js deleted file mode 100644 index a76919f34e..0000000000 --- a/src/Signup.js +++ /dev/null @@ -1,451 +0,0 @@ -"use strict"; - -import Matrix from "matrix-js-sdk"; - -var MatrixClientPeg = require("./MatrixClientPeg"); -var SignupStages = require("./SignupStages"); -var dis = require("./dispatcher"); -var q = require("q"); -var url = require("url"); - -const EMAIL_STAGE_TYPE = "m.login.email.identity"; - -/** - * A base class for common functionality between Registration and Login e.g. - * storage of HS/IS URLs. - */ -class Signup { - constructor(hsUrl, isUrl, opts) { - this._hsUrl = hsUrl; - this._isUrl = isUrl; - this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - } - - getHomeserverUrl() { - return this._hsUrl; - } - - getIdentityServerUrl() { - return this._isUrl; - } - - setHomeserverUrl(hsUrl) { - this._hsUrl = hsUrl; - } - - setIdentityServerUrl(isUrl) { - this._isUrl = isUrl; - } - - /** - * Get a temporary MatrixClient, which can be used for login or register - * requests. - */ - _createTemporaryClient() { - return Matrix.createClient({ - baseUrl: this._hsUrl, - idBaseUrl: this._isUrl, - }); - } -} - -/** - * Registration logic class - * This exists for the lifetime of a user's attempt to register an account, - * so if their registration attempt fails for whatever reason and they - * try again, call register() on the same instance again. - * - * TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It - * would be nice to make use of that rather than rolling our own version of it. - */ -class Register extends Signup { - constructor(hsUrl, isUrl, opts) { - super(hsUrl, isUrl, opts); - this.setStep("START"); - this.data = null; // from the server - // random other stuff (e.g. query params, NOT params from the server) - this.params = {}; - this.credentials = null; - this.activeStage = null; - this.registrationPromise = null; - // These values MUST be undefined else we'll send "username: null" which - // will error on Synapse rather than having the key absent. - this.username = undefined; // desired - this.email = undefined; // desired - this.password = undefined; // desired - } - - setClientSecret(secret) { - this.params.clientSecret = secret; - } - - setSessionId(sessionId) { - this.params.sessionId = sessionId; - } - - setRegistrationUrl(regUrl) { - this.params.registrationUrl = regUrl; - } - - setIdSid(idSid) { - this.params.idSid = idSid; - } - - setGuestAccessToken(token) { - this.guestAccessToken = token; - } - - getStep() { - return this._step; - } - - getCredentials() { - return this.credentials; - } - - getServerData() { - return this.data || {}; - } - - getPromise() { - return this.registrationPromise; - } - - setStep(step) { - this._step = 'Register.' + step; - // TODO: - // It's a shame this is going to the global dispatcher, we only really - // want things which have an instance of this class to be able to add - // listeners... - console.log("Dispatching 'registration_step_update' for step %s", this._step); - dis.dispatch({ - action: "registration_step_update" - }); - } - - /** - * Starts the registration process from the first stage - */ - register(formVals) { - var {username, password, email} = formVals; - this.email = email; - this.username = username; - this.password = password; - const client = this._createTemporaryClient(); - this.activeStage = null; - - // If there hasn't been a client secret set by this point, - // generate one for this session. It will only be used if - // we do email verification, but far simpler to just make - // sure we have one. - // We re-use this same secret over multiple calls to register - // so that the identity server can honour the sendAttempt - // parameter and not re-send email unless we actually want - // another mail to be sent. - if (!this.params.clientSecret) { - this.params.clientSecret = client.generateClientSecret(); - } - return this._tryRegister(client); - } - - _tryRegister(client, authDict, poll_for_success) { - var self = this; - - var bindEmail; - - if (this.username && this.password) { - // only need to bind_email when sending u/p - sending it at other - // times clobbers the u/p resulting in M_MISSING_PARAM (password) - bindEmail = true; - } - - // TODO need to figure out how to send the device display name to /register. - return client.register( - this.username, this.password, this.params.sessionId, authDict, bindEmail, - this.guestAccessToken - ).then(function(result) { - self.credentials = result; - self.setStep("COMPLETE"); - return result; // contains the credentials - }, function(error) { - if (error.httpStatus === 401) { - if (error.data && error.data.flows) { - // Remember the session ID from the server: - // Either this is our first 401 in which case we need to store the - // session ID for future calls, or it isn't in which case this - // is just a no-op since it ought to be the same (or if it isn't, - // we should use the latest one from the server in any case). - self.params.sessionId = error.data.session; - self.data = error.data || {}; - var flow = self.chooseFlow(error.data.flows); - - if (flow) { - console.log("Active flow => %s", JSON.stringify(flow)); - var flowStage = self.firstUncompletedStage(flow); - if (!self.activeStage || flowStage != self.activeStage.type) { - return self._startStage(client, flowStage).catch(function(err) { - self.setStep('START'); - throw err; - }); - } - } - } - if (poll_for_success) { - return q.delay(5000).then(function() { - return self._tryRegister(client, authDict, poll_for_success); - }); - } else { - throw new Error("Authorisation failed!"); - } - } else { - if (error.errcode === 'M_USER_IN_USE') { - throw new Error("Username in use"); - } else if (error.errcode == 'M_INVALID_USERNAME') { - throw new Error("User names may only contain alphanumeric characters, underscores or dots!"); - } else if (error.httpStatus >= 400 && error.httpStatus < 500) { - throw new Error(`Registration failed! (${error.httpStatus})`); - } else if (error.httpStatus >= 500 && error.httpStatus < 600) { - throw new Error( - `Server error during registration! (${error.httpStatus})` - ); - } else if (error.name == "M_MISSING_PARAM") { - // The HS hasn't remembered the login params from - // the first try when the login email was sent. - throw new Error( - "This home server does not support resuming registration." - ); - } - } - }); - } - - firstUncompletedStage(flow) { - for (var i = 0; i < flow.stages.length; ++i) { - if (!this.hasCompletedStage(flow.stages[i])) { - return flow.stages[i]; - } - } - } - - hasCompletedStage(stageType) { - var completed = (this.data || {}).completed || []; - return completed.indexOf(stageType) !== -1; - } - - _startStage(client, stageName) { - var self = this; - this.setStep(`STEP_${stageName}`); - var StageClass = SignupStages[stageName]; - if (!StageClass) { - // no idea how to handle this! - throw new Error("Unknown stage: " + stageName); - } - - var stage = new StageClass(client, this); - this.activeStage = stage; - return stage.complete().then(function(request) { - if (request.auth) { - console.log("Stage %s is returning an auth dict", stageName); - return self._tryRegister(client, request.auth, request.poll_for_success); - } - else { - // never resolve the promise chain. This is for things like email auth - // which display a "check your email" message and relies on the - // link in the email to actually register you. - console.log("Waiting for external action."); - return q.defer().promise; - } - }); - } - - chooseFlow(flows) { - // If the user gave us an email then we want to pick an email - // flow we can do, else any other flow. - var emailFlow = null; - var otherFlow = null; - flows.forEach(function(flow) { - var flowHasEmail = false; - for (var stageI = 0; stageI < flow.stages.length; ++stageI) { - var stage = flow.stages[stageI]; - - if (!SignupStages[stage]) { - // we can't do this flow, don't have a Stage impl. - return; - } - - if (stage === EMAIL_STAGE_TYPE) { - flowHasEmail = true; - } - } - - if (flowHasEmail) { - emailFlow = flow; - } else { - otherFlow = flow; - } - }); - - if (this.email || this.hasCompletedStage(EMAIL_STAGE_TYPE)) { - // we've been given an email or we've already done an email part - return emailFlow; - } else { - return otherFlow; - } - } - - recheckState() { - // We've been given a bunch of data from a previous register step, - // this only happens for email auth currently. It's kinda ming we need - // to know this though. A better solution would be to ask the stages if - // they are ready to do something rather than accepting that we know about - // email auth and its internals. - this.params.hasEmailInfo = ( - this.params.clientSecret && this.params.sessionId && this.params.idSid - ); - - if (this.params.hasEmailInfo) { - const client = this._createTemporaryClient(); - this.registrationPromise = this._startStage(client, EMAIL_STAGE_TYPE); - } - return this.registrationPromise; - } - - tellStage(stageName, data) { - if (this.activeStage && this.activeStage.type === stageName) { - console.log("Telling stage %s about something..", stageName); - this.activeStage.onReceiveData(data); - } - } -} - - -class Login extends Signup { - constructor(hsUrl, isUrl, fallbackHsUrl, opts) { - super(hsUrl, isUrl, opts); - this._fallbackHsUrl = fallbackHsUrl; - this._currentFlowIndex = 0; - this._flows = []; - } - - getFlows() { - var self = this; - var client = this._createTemporaryClient(); - return client.loginFlows().then(function(result) { - self._flows = result.flows; - self._currentFlowIndex = 0; - // technically the UI should display options for all flows for the - // user to then choose one, so return all the flows here. - return self._flows; - }); - } - - chooseFlow(flowIndex) { - this._currentFlowIndex = flowIndex; - } - - getCurrentFlowStep() { - // technically the flow can have multiple steps, but no one does this - // for login so we can ignore it. - var flowStep = this._flows[this._currentFlowIndex]; - return flowStep ? flowStep.type : null; - } - - loginAsGuest() { - var client = this._createTemporaryClient(); - return client.registerGuest({ - body: { - initial_device_display_name: this._defaultDeviceDisplayName, - }, - }).then((creds) => { - return { - userId: creds.user_id, - deviceId: creds.device_id, - accessToken: creds.access_token, - homeserverUrl: this._hsUrl, - identityServerUrl: this._isUrl, - guest: true - }; - }, (error) => { - if (error.httpStatus === 403) { - error.friendlyText = "Guest access is disabled on this Home Server."; - } else { - error.friendlyText = "Failed to register as guest: " + error.data; - } - throw error; - }); - } - - loginViaPassword(username, pass) { - var self = this; - var isEmail = username.indexOf("@") > 0; - var loginParams = { - password: pass, - initial_device_display_name: this._defaultDeviceDisplayName, - }; - if (isEmail) { - loginParams.medium = 'email'; - loginParams.address = username; - } else { - loginParams.user = username; - } - - var client = this._createTemporaryClient(); - return client.login('m.login.password', loginParams).then(function(data) { - return q({ - homeserverUrl: self._hsUrl, - identityServerUrl: self._isUrl, - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token - }); - }, function(error) { - if (error.httpStatus == 400 && loginParams.medium) { - error.friendlyText = ( - 'This Home Server does not support login using email address.' - ); - } - else if (error.httpStatus === 403) { - error.friendlyText = ( - 'Incorrect username and/or password.' - ); - if (self._fallbackHsUrl) { - var fbClient = Matrix.createClient({ - baseUrl: self._fallbackHsUrl, - idBaseUrl: this._isUrl, - }); - - return fbClient.login('m.login.password', loginParams).then(function(data) { - return q({ - homeserverUrl: self._fallbackHsUrl, - identityServerUrl: self._isUrl, - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token - }); - }, function(fallback_error) { - // throw the original error - throw error; - }); - } - } - else { - error.friendlyText = ( - 'There was a problem logging in. (HTTP ' + error.httpStatus + ")" - ); - } - throw error; - }); - } - - redirectToCas() { - var client = this._createTemporaryClient(); - var parsedUrl = url.parse(window.location.href, true); - parsedUrl.query["homeserver"] = client.getHomeserverUrl(); - parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); - var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); - window.location.href = casUrl; - } -} - -module.exports.Register = Register; -module.exports.Login = Login; diff --git a/src/SignupStages.js b/src/SignupStages.js deleted file mode 100644 index 283b11afef..0000000000 --- a/src/SignupStages.js +++ /dev/null @@ -1,166 +0,0 @@ -"use strict"; -var q = require("q"); - -/** - * An interface class which login types should abide by. - */ -class Stage { - constructor(type, matrixClient, signupInstance) { - this.type = type; - this.client = matrixClient; - this.signupInstance = signupInstance; - } - - complete() { - // Return a promise which is: - // RESOLVED => With an Object which has an 'auth' key which is the auth dict - // to submit. - // REJECTED => With an Error if there was a problem with this stage. - // Has a "message" string and an "isFatal" flag. - return q.reject("NOT IMPLEMENTED"); - } - - onReceiveData() { - // NOP - } -} -Stage.TYPE = "NOT IMPLEMENTED"; - - -/** - * This stage requires no auth. - */ -class DummyStage extends Stage { - constructor(matrixClient, signupInstance) { - super(DummyStage.TYPE, matrixClient, signupInstance); - } - - complete() { - return q({ - auth: { - type: DummyStage.TYPE - } - }); - } -} -DummyStage.TYPE = "m.login.dummy"; - - -/** - * This stage uses Google's Recaptcha to do auth. - */ -class RecaptchaStage extends Stage { - constructor(matrixClient, signupInstance) { - super(RecaptchaStage.TYPE, matrixClient, signupInstance); - this.defer = q.defer(); // resolved with the captcha response - } - - // called when the recaptcha has been completed. - onReceiveData(data) { - if (!data || !data.response) { - return; - } - this.defer.resolve({ - auth: { - type: 'm.login.recaptcha', - response: data.response, - } - }); - } - - complete() { - return this.defer.promise; - } -} -RecaptchaStage.TYPE = "m.login.recaptcha"; - - -/** - * This state uses the IS to verify email addresses. - */ -class EmailIdentityStage extends Stage { - constructor(matrixClient, signupInstance) { - super(EmailIdentityStage.TYPE, matrixClient, signupInstance); - } - - _completeVerify() { - // pull out the host of the IS URL by creating an anchor element - var isLocation = document.createElement('a'); - isLocation.href = this.signupInstance.getIdentityServerUrl(); - - var clientSecret = this.clientSecret || this.signupInstance.params.clientSecret; - var sid = this.sid || this.signupInstance.params.idSid; - - return q({ - auth: { - type: 'm.login.email.identity', - threepid_creds: { - sid: sid, - client_secret: clientSecret, - id_server: isLocation.host - } - } - }); - } - - /** - * Complete the email stage. - * - * This is called twice under different circumstances: - * 1) When requesting an email token from the IS - * 2) When validating query parameters received from the link in the email - */ - complete() { - // TODO: The Registration class shouldn't really know this info. - if (this.signupInstance.params.hasEmailInfo) { - return this._completeVerify(); - } - - this.clientSecret = this.signupInstance.params.clientSecret; - if (!this.clientSecret) { - return q.reject(new Error("No client secret specified by Signup class!")); - } - - var nextLink = this.signupInstance.params.registrationUrl + - '?client_secret=' + - encodeURIComponent(this.clientSecret) + - "&hs_url=" + - encodeURIComponent(this.signupInstance.getHomeserverUrl()) + - "&is_url=" + - encodeURIComponent(this.signupInstance.getIdentityServerUrl()) + - "&session_id=" + - encodeURIComponent(this.signupInstance.getServerData().session); - - var self = this; - return this.client.requestRegisterEmailToken( - this.signupInstance.email, - this.clientSecret, - 1, // TODO: Multiple send attempts? - nextLink - ).then(function(response) { - self.sid = response.sid; - return self._completeVerify(); - }).then(function(request) { - request.poll_for_success = true; - return request; - }, function(error) { - console.error(error); - var e = { - isFatal: true - }; - if (error.errcode == 'M_THREEPID_IN_USE') { - e.message = "This email address is already registered"; - } else { - e.message = 'Unable to contact the given identity server'; - } - throw e; - }); - } -} -EmailIdentityStage.TYPE = "m.login.email.identity"; - -module.exports = { - [DummyStage.TYPE]: DummyStage, - [RecaptchaStage.TYPE]: RecaptchaStage, - [EmailIdentityStage.TYPE]: EmailIdentityStage -}; diff --git a/src/Skinner.js b/src/Skinner.js index 4482f2239c..1fe12f85ab 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -23,41 +23,46 @@ class Skinner { if (this.components === null) { throw new Error( "Attempted to get a component before a skin has been loaded."+ - "This is probably because either:"+ + " This is probably because either:"+ " a) Your app has not called sdk.loadSkin(), or"+ - " b) A component has called getComponent at the root level" + " b) A component has called getComponent at the root level", ); } - var comp = this.components[name]; - if (comp) { - return comp; - } + let comp = this.components[name]; // XXX: Temporarily also try 'views.' as we're currently // leaving the 'views.' off views. - var comp = this.components['views.'+name]; - if (comp) { - return comp; + if (!comp) { + comp = this.components['views.'+name]; } - throw new Error("No such component: "+name); + + if (!comp) { + throw new Error("No such component: "+name); + } + + // components have to be functions. + const validType = typeof comp === 'function'; + if (!validType) { + throw new Error(`Not a valid component: ${name}.`); + } + return comp; } load(skinObject) { if (this.components !== null) { throw new Error( "Attempted to load a skin while a skin is already loaded"+ - "If you want to change the active skin, call resetSkin first" - ); + "If you want to change the active skin, call resetSkin first"); } this.components = {}; - var compKeys = Object.keys(skinObject.components); - for (var i = 0; i < compKeys.length; ++i) { - var comp = skinObject.components[compKeys[i]]; + const compKeys = Object.keys(skinObject.components); + for (let i = 0; i < compKeys.length; ++i) { + const comp = skinObject.components[compKeys[i]]; this.addComponent(compKeys[i], comp); } } addComponent(name, comp) { - var slot = name; + let slot = name; if (comp.replaces !== undefined) { if (comp.replaces.indexOf('.') > -1) { slot = comp.replaces; @@ -79,6 +84,9 @@ class Skinner { // behaviour with multiple copies of files etc. is erratic at best. // XXX: We can still end up with the same file twice in the resulting // JS bundle which is nonideal. +// See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/ +// or https://nodejs.org/api/modules.html#modules_module_caching_caveats +// ("Modules are cached based on their resolved filename") if (global.mxSkinner === undefined) { global.mxSkinner = new Skinner(); } diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 523d1d8f3c..d45e45e84c 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require("./MatrixClientPeg"); -var dis = require("./dispatcher"); -var Tinter = require("./Tinter"); +import MatrixClientPeg from "./MatrixClientPeg"; +import dis from "./dispatcher"; +import Tinter from "./Tinter"; import sdk from './index'; +import { _t } from './languageHandler'; import Modal from './Modal'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; class Command { @@ -41,58 +43,64 @@ class Command { } getUsage() { - return "Usage: " + this.getCommandWithArgs() + return _t('Usage') + ': ' + this.getCommandWithArgs(); } } -var reject = function(msg) { +function reject(msg) { return { - error: msg + error: msg, }; -}; +} -var success = function(promise) { +function success(promise) { return { - promise: promise + promise: promise, }; -}; +} -var commands = { +/* Disable the "unexpected this" error for these commands - all of the run + * functions are called with `this` bound to the Command instance. + */ + +/* eslint-disable babel/no-invalid-this */ + +const commands = { ddg: new Command("ddg", " ", function(roomId, args) { const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); // TODO Don't explain this away, actually show a search UI here. - Modal.createDialog(ErrorDialog, { - title: "/ddg is not a command", - description: "To use it, just wait for autocomplete results to load and tab through them.", + Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { + title: _t('/ddg is not a command'), + description: _t('To use it, just wait for autocomplete results to load and tab through them.'), }); return success(); }), // Change your nickname - nick: new Command("nick", " ", function(room_id, args) { + nick: new Command("nick", " ", function(roomId, args) { if (args) { return success( - MatrixClientPeg.get().setDisplayName(args) + MatrixClientPeg.get().setDisplayName(args), ); } return reject(this.getUsage()); }), // Changes the colorscheme of your current room - tint: new Command("tint", " [ ]", function(room_id, args) { + tint: new Command("tint", " [ ]", function(roomId, args) { if (args) { - var matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); + const matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); if (matches) { Tinter.tint(matches[1], matches[4]); - var colorScheme = {} + const colorScheme = {}; colorScheme.primary_color = matches[1]; if (matches[4]) { colorScheme.secondary_color = matches[4]; + } else { + colorScheme.secondary_color = colorScheme.primary_color; } return success( - MatrixClientPeg.get().setRoomAccountData( - room_id, "org.matrix.room.color_scheme", colorScheme - ) + SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), ); } } @@ -100,22 +108,22 @@ var commands = { }), // Change the room topic - topic: new Command("topic", " ", function(room_id, args) { + topic: new Command("topic", " ", function(roomId, args) { if (args) { return success( - MatrixClientPeg.get().setRoomTopic(room_id, args) + MatrixClientPeg.get().setRoomTopic(roomId, args), ); } return reject(this.getUsage()); }), // Invite a user - invite: new Command("invite", " ", function(room_id, args) { + invite: new Command("invite", " ", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { return success( - MatrixClientPeg.get().invite(room_id, matches[1]) + MatrixClientPeg.get().invite(roomId, matches[1]), ); } } @@ -123,21 +131,21 @@ var commands = { }), // Join a room - join: new Command("join", "#alias:domain", function(room_id, args) { + join: new Command("join", "#alias:domain", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { - var room_alias = matches[1]; - if (room_alias[0] !== '#') { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') { return reject(this.getUsage()); } - if (!room_alias.match(/:/)) { - room_alias += ':' + MatrixClientPeg.get().getDomain(); + if (!roomAlias.match(/:/)) { + roomAlias += ':' + MatrixClientPeg.get().getDomain(); } dis.dispatch({ action: 'view_room', - room_alias: room_alias, + room_alias: roomAlias, auto_join: true, }); @@ -147,29 +155,29 @@ var commands = { return reject(this.getUsage()); }), - part: new Command("part", "[#alias:domain]", function(room_id, args) { - var targetRoomId; + part: new Command("part", "[#alias:domain]", function(roomId, args) { + let targetRoomId; if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { - var room_alias = matches[1]; - if (room_alias[0] !== '#') { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') { return reject(this.getUsage()); } - if (!room_alias.match(/:/)) { - room_alias += ':' + MatrixClientPeg.get().getDomain(); + if (!roomAlias.match(/:/)) { + roomAlias += ':' + MatrixClientPeg.get().getDomain(); } // Try to find a room with this alias - var rooms = MatrixClientPeg.get().getRooms(); - for (var i = 0; i < rooms.length; i++) { - var aliasEvents = rooms[i].currentState.getStateEvents( - "m.room.aliases" + const rooms = MatrixClientPeg.get().getRooms(); + for (let i = 0; i < rooms.length; i++) { + const aliasEvents = rooms[i].currentState.getStateEvents( + "m.room.aliases", ); - for (var j = 0; j < aliasEvents.length; j++) { - var aliases = aliasEvents[j].getContent().aliases || []; - for (var k = 0; k < aliases.length; k++) { - if (aliases[k] === room_alias) { + for (let j = 0; j < aliasEvents.length; j++) { + const aliases = aliasEvents[j].getContent().aliases || []; + for (let k = 0; k < aliases.length; k++) { + if (aliases[k] === roomAlias) { targetRoomId = rooms[i].roomId; break; } @@ -178,27 +186,28 @@ var commands = { } if (targetRoomId) { break; } } - } - if (!targetRoomId) { - return reject("Unrecognised room alias: " + room_alias); + if (!targetRoomId) { + return reject(_t("Unrecognised room alias:") + ' ' + roomAlias); + } } } - if (!targetRoomId) targetRoomId = room_id; + if (!targetRoomId) targetRoomId = roomId; return success( MatrixClientPeg.get().leave(targetRoomId).then( - function() { - dis.dispatch({action: 'view_next_room'}); - }) + function() { + dis.dispatch({action: 'view_next_room'}); + }, + ), ); }), // Kick a user from the room with an optional reason - kick: new Command("kick", " [ ]", function(room_id, args) { + kick: new Command("kick", " [ ]", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+?)( +(.*))?$/); + const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { return success( - MatrixClientPeg.get().kick(room_id, matches[1], matches[3]) + MatrixClientPeg.get().kick(roomId, matches[1], matches[3]), ); } } @@ -206,12 +215,12 @@ var commands = { }), // Ban a user from the room with an optional reason - ban: new Command("ban", " [ ]", function(room_id, args) { + ban: new Command("ban", " [ ]", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+?)( +(.*))?$/); + const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { return success( - MatrixClientPeg.get().ban(room_id, matches[1], matches[3]) + MatrixClientPeg.get().ban(roomId, matches[1], matches[3]), ); } } @@ -219,13 +228,66 @@ var commands = { }), // Unban a user from the room - unban: new Command("unban", " ", function(room_id, args) { + unban: new Command("unban", " ", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { // Reset the user membership to "leave" to unban him return success( - MatrixClientPeg.get().unban(room_id, matches[1]) + MatrixClientPeg.get().unban(roomId, matches[1]), + ); + } + } + return reject(this.getUsage()); + }), + + ignore: new Command("ignore", " ", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + ignoredUsers.push(userId); // de-duped internally in the js-sdk + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { + title: _t("Ignored user"), + description: ( + ++ ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), + + unignore: new Command("unignore", "{ _t("You are now ignoring %(userId)s", {userId: userId}) }
+", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + const index = ignoredUsers.indexOf(userId); + if (index !== -1) ignoredUsers.splice(index, 1); + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { + title: _t("Unignored user"), + description: ( + ++ ), + hasCancelButton: false, + }); + }), ); } } @@ -233,27 +295,27 @@ var commands = { }), // Define the power level of a user - op: new Command("op", "{ _t("You are no longer ignoring %(userId)s", {userId: userId}) }
+[ ]", function(room_id, args) { + op: new Command("op", " [ ]", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+?)( +(\d+))?$/); - var powerLevel = 50; // default power level for op + const matches = args.match(/^(\S+?)( +(-?\d+))?$/); + let powerLevel = 50; // default power level for op if (matches) { - var user_id = matches[1]; + const userId = matches[1]; if (matches.length === 4 && undefined !== matches[3]) { powerLevel = parseInt(matches[3]); } - if (powerLevel !== NaN) { - var room = MatrixClientPeg.get().getRoom(room_id); + if (!isNaN(powerLevel)) { + const room = MatrixClientPeg.get().getRoom(roomId); if (!room) { - return reject("Bad room ID: " + room_id); + return reject("Bad room ID: " + roomId); } - var powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "" + const powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "", ); return success( MatrixClientPeg.get().setPowerLevel( - room_id, user_id, powerLevel, powerLevelEvent - ) + roomId, userId, powerLevel, powerLevelEvent, + ), ); } } @@ -262,33 +324,102 @@ var commands = { }), // Reset the power level of a user - deop: new Command("deop", " ", function(room_id, args) { + deop: new Command("deop", " ", function(roomId, args) { if (args) { - var matches = args.match(/^(\S+)$/); + const matches = args.match(/^(\S+)$/); if (matches) { - var room = MatrixClientPeg.get().getRoom(room_id); + const room = MatrixClientPeg.get().getRoom(roomId); if (!room) { - return reject("Bad room ID: " + room_id); + return reject("Bad room ID: " + roomId); } - var powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "" + const powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "", ); return success( MatrixClientPeg.get().setPowerLevel( - room_id, args, undefined, powerLevelEvent - ) + roomId, args, undefined, powerLevelEvent, + ), ); } } return reject(this.getUsage()); - }) + }), + + // Open developer tools + devtools: new Command("devtools", "", function(roomId) { + const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog"); + Modal.createDialog(DevtoolsDialog, { roomId }); + return success(); + }), + + // Verify a user, device, and pubkey tuple + verify: new Command("verify", " ", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); + if (matches) { + const userId = matches[1]; + const deviceId = matches[2]; + const fingerprint = matches[3]; + + return success( + // Promise.resolve to handle transition from static result to promise; can be removed + // in future + Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => { + if (!device) { + throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`); + } + + if (device.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new Error(_t(`Device already verified!`)); + } else { + throw new Error(_t(`WARNING: Device already verified, but keys do NOT MATCH!`)); + } + } + + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new Error( + _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' + + ' %(deviceId)s is "%(fprint)s" which does not match the provided key' + + ' "%(fingerprint)s". This could mean your communications are being intercepted!', + {deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint})); + } + + return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true); + }).then(() => { + // Tell the user we verified everything + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, { + title: _t("Verified key"), + description: ( + ++ ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), }; +/* eslint-enable babel/no-invalid-this */ + // helpful aliases -var aliases = { - j: "join" -} +const aliases = { + j: "join", +}; module.exports = { /** @@ -304,13 +435,13 @@ module.exports = { // IRC-style commands input = input.replace(/\s+$/, ""); if (input[0] === "/" && input[1] !== "/") { - var bits = input.match(/^(\S+?)( +((.|\n)*))?$/); - var cmd, args; + const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); + let cmd; + let args; if (bits) { cmd = bits[1].substring(1).toLowerCase(); args = bits[3]; - } - else { + } else { cmd = input; } if (cmd === "me") return null; @@ -319,9 +450,8 @@ module.exports = { } if (commands[cmd]) { return commands[cmd].run(roomId, args); - } - else { - return reject("Unrecognised command: " + input); + } else { + return reject(_t("Unrecognised command:") + ' ' + input); } } return null; // not a command @@ -329,12 +459,12 @@ module.exports = { getCommandList: function() { // Return all the commands plus /me and /markdown which aren't handled like normal commands - var cmds = Object.keys(commands).sort().map(function(cmdKey) { + const cmds = Object.keys(commands).sort().map(function(cmdKey) { return commands[cmdKey]; - }) - cmds.push(new Command("me", "+ { + _t("The signing key you provided matches the signing key you received " + + "from %(userId)s's device %(deviceId)s. Device marked as verified.", + {userId: userId, deviceId: deviceId}) + } +
+", function(){})); - cmds.push(new Command("markdown", " ", function(){})); + }); + cmds.push(new Command("me", " ", function() {})); + cmds.push(new Command("markdown", " ", function() {})); return cmds; - } + }, }; diff --git a/src/TabComplete.js b/src/TabComplete.js deleted file mode 100644 index a0380f36c4..0000000000 --- a/src/TabComplete.js +++ /dev/null @@ -1,391 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Entry, MemberEntry, CommandEntry } from './TabCompleteEntries'; -import SlashCommands from './SlashCommands'; -import MatrixClientPeg from './MatrixClientPeg'; - -const DELAY_TIME_MS = 1000; -const KEY_TAB = 9; -const KEY_SHIFT = 16; -const KEY_WINDOWS = 91; - -// NB: DO NOT USE \b its "words" are roman alphabet only! -// -// Capturing group containing the start -// of line or a whitespace char -// \_______________ __________Capturing group of 0 or more non-whitespace chars -// _|__ _|_ followed by the end of line -// / \/ \ -const MATCH_REGEX = /(^|\s)(\S*)$/; - -class TabComplete { - - constructor(opts) { - opts.allowLooping = opts.allowLooping || false; - opts.autoEnterTabComplete = opts.autoEnterTabComplete || false; - opts.onClickCompletes = opts.onClickCompletes || false; - this.opts = opts; - this.completing = false; - this.list = []; // full set of tab-completable things - this.matchedList = []; // subset of completable things to loop over - this.currentIndex = 0; // index in matchedList currently - this.originalText = null; // original input text when tab was first hit - this.textArea = opts.textArea; // DOMElement - this.isFirstWord = false; // true if you tab-complete on the first word - this.enterTabCompleteTimerId = null; - this.inPassiveMode = false; - - // Map tracking ordering of the room members. - // userId: integer, highest comes first. - this.memberTabOrder = {}; - - // monotonically increasing counter used for tracking ordering of members - this.memberOrderSeq = 0; - } - - /** - * Call this when a a UI element representing a tab complete entry has been clicked - * @param {entry} The entry that was clicked - */ - onEntryClick(entry) { - if (this.opts.onClickCompletes) { - this.completeTo(entry); - } - } - - loadEntries(room) { - this._makeEntries(room); - this._initSorting(room); - this._sortEntries(); - } - - onMemberSpoke(member) { - if (this.memberTabOrder[member.userId] === undefined) { - this.list.push(new MemberEntry(member)); - } - this.memberTabOrder[member.userId] = this.memberOrderSeq++; - this._sortEntries(); - } - - /** - * @param {DOMElement} - */ - setTextArea(textArea) { - this.textArea = textArea; - } - - /** - * @return {Boolean} - */ - isTabCompleting() { - // actually have things to tab over - return this.completing && this.matchedList.length > 1; - } - - stopTabCompleting() { - this.completing = false; - this.currentIndex = 0; - this._notifyStateChange(); - } - - startTabCompleting(passive) { - this.originalText = this.textArea.value; // cache starting text - - // grab the partial word from the text which we'll be tab-completing - var res = MATCH_REGEX.exec(this.originalText); - if (!res) { - this.matchedList = []; - return; - } - // ES6 destructuring; ignore first element (the complete match) - var [ , boundaryGroup, partialGroup] = res; - - if (partialGroup.length === 0 && passive) { - return; - } - - this.isFirstWord = partialGroup.length === this.originalText.length; - - this.completing = true; - this.currentIndex = 0; - - this.matchedList = [ - new Entry(partialGroup) // first entry is always the original partial - ]; - - // find matching entries in the set of entries given to us - this.list.forEach((entry) => { - if (entry.text.toLowerCase().indexOf(partialGroup.toLowerCase()) === 0) { - this.matchedList.push(entry); - } - }); - - // console.log("calculated completions => %s", JSON.stringify(this.matchedList)); - } - - /** - * Do an auto-complete with the given word. This terminates the tab-complete. - * @param {Entry} entry The tab-complete entry to complete to. - */ - completeTo(entry) { - this.textArea.value = this._replaceWith( - entry.getFillText(), true, entry.getSuffix(this.isFirstWord) - ); - this.stopTabCompleting(); - // keep focus on the text area - this.textArea.focus(); - } - - /** - * @param {Number} numAheadToPeek Return *up to* this many elements. - * @return {Entry[]} - */ - peek(numAheadToPeek) { - if (this.matchedList.length === 0) { - return []; - } - var peekList = []; - - // return the current match item and then one with an index higher, and - // so on until we've reached the requested limit. If we hit the end of - // the list of options we're done. - for (var i = 0; i < numAheadToPeek; i++) { - var nextIndex; - if (this.opts.allowLooping) { - nextIndex = (this.currentIndex + i) % this.matchedList.length; - } - else { - nextIndex = this.currentIndex + i; - if (nextIndex === this.matchedList.length) { - break; - } - } - peekList.push(this.matchedList[nextIndex]); - } - // console.log("Peek list(%s): %s", numAheadToPeek, JSON.stringify(peekList)); - return peekList; - } - - handleTabPress(passive, shiftKey) { - var wasInPassiveMode = this.inPassiveMode && !passive; - this.inPassiveMode = passive; - - if (!this.completing) { - this.startTabCompleting(passive); - } - - if (shiftKey) { - this.nextMatchedEntry(-1); - } - else { - // if we were in passive mode we got out of sync by incrementing the - // index to show the peek view but not set the text area. Therefore, - // we want to set the *current* index rather than the *next* index. - this.nextMatchedEntry(wasInPassiveMode ? 0 : 1); - } - this._notifyStateChange(); - } - - /** - * @param {DOMEvent} e - */ - onKeyDown(ev) { - if (!this.textArea) { - console.error("onKeyDown called before a