diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index ffd492d491..55eaf75e4b 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,6 +1,5 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. -src/AddThreepid.js src/async-components/views/dialogs/EncryptedEventDialog.js src/autocomplete/AutocompleteProvider.js src/autocomplete/Autocompleter.js @@ -9,8 +8,6 @@ src/autocomplete/DuckDuckGoProvider.js src/autocomplete/EmojiProvider.js src/autocomplete/RoomProvider.js src/autocomplete/UserProvider.js -src/Avatar.js -src/BasePlatform.js src/CallHandler.js src/component-index.js src/components/structures/ContextualMenu.js @@ -96,7 +93,6 @@ src/components/views/rooms/MessageComposerInput.js src/components/views/rooms/MessageComposerInputOld.js src/components/views/rooms/PresenceLabel.js src/components/views/rooms/ReadReceiptMarker.js -src/components/views/rooms/RoomHeader.js src/components/views/rooms/RoomList.js src/components/views/rooms/RoomNameEditor.js src/components/views/rooms/RoomPreviewBar.js @@ -115,16 +111,7 @@ src/components/views/settings/ChangePassword.js src/components/views/settings/DevicesPanel.js src/components/views/settings/DevicesPanelEntry.js src/components/views/settings/EnableNotificationsButton.js -src/components/views/voip/CallView.js -src/components/views/voip/IncomingCallBox.js -src/components/views/voip/VideoFeed.js -src/components/views/voip/VideoView.js src/ContentMessages.js -src/createRoom.js -src/DateUtils.js -src/email.js -src/Entities.js -src/extend.js src/HtmlUtils.js src/ImageUtils.js src/Invite.js @@ -135,30 +122,20 @@ src/Markdown.js src/MatrixClientPeg.js src/Modal.js src/Notifier.js -src/ObjectUtils.js -src/PasswordReset.js src/PlatformPeg.js src/Presence.js src/ratelimitedfunc.js -src/Resend.js src/RichText.js src/Roles.js -src/RoomListSorter.js -src/RoomNotifs.js src/Rooms.js src/ScalarAuthClient.js src/ScalarMessaging.js -src/SdkConfig.js -src/Skinner.js -src/SlashCommands.js -src/stores/LifecycleStore.js src/TabComplete.js src/TabCompleteEntries.js src/TextForEvent.js src/Tinter.js src/UiEffects.js src/Unread.js -src/UserActivity.js src/utils/DecryptFile.js src/utils/DMRoomMap.js src/utils/FormattingUtils.js diff --git a/.travis-test-riot.sh b/.travis-test-riot.sh index 4296c72e6c..87200871a5 100755 --- a/.travis-test-riot.sh +++ b/.travis-test-riot.sh @@ -22,8 +22,11 @@ git checkout "$curbranch" || git checkout develop mkdir node_modules npm install -(cd node_modules/matrix-js-sdk && npm install) +# use the version of js-sdk we just used in the react-sdk tests +rm -r node_modules/matrix-js-sdk +ln -s "$REACT_SDK_DIR/node_modules/matrix-js-sdk" node_modules/matrix-js-sdk +# ... and, of course, the version of react-sdk we just built rm -r node_modules/matrix-react-sdk ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk diff --git a/.travis.yml b/.travis.yml index 918cec696b..4137d754bf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,15 @@ +# we need trusty for the chrome addon +dist: trusty + +# we don't need sudo, so can run in a container, which makes startup much +# quicker. +sudo: false + language: node_js node_js: - node # Latest stable version of nodejs. +addons: + chrome: stable install: - npm install - (cd node_modules/matrix-js-sdk && npm install) diff --git a/karma.conf.js b/karma.conf.js index d544248332..d8a6c25cc6 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -116,11 +116,25 @@ module.exports = function (config) { browsers: [ 'Chrome', //'PhantomJS', + //'ChromeHeadless', ], + customLaunchers: { + 'ChromeHeadless': { + base: 'Chrome', + flags: [ + // See https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md + '--headless', + '--disable-gpu', + // Without a remote debugging port, Google Chrome exits immediately. + '--remote-debugging-port=9222', + ], + } + }, + // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits - singleRun: true, + // singleRun: false, // Concurrency level // how many browser should be started simultaneous diff --git a/package.json b/package.json index ed12e6a5e4..888fd9e32a 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "lintall": "eslint src/ test/", "clean": "rimraf lib", "prepublish": "npm run build && git rev-parse HEAD > git-revision.txt", - "test": "karma start $KARMAFLAGS --browsers PhantomJS", - "test-multi": "karma start $KARMAFLAGS --single-run=false" + "test": "karma start $KARMAFLAGS --single-run=true --browsers ChromeHeadless", + "test-multi": "karma start $KARMAFLAGS" }, "dependencies": { "babel-runtime": "^6.11.6", @@ -75,6 +75,7 @@ "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.11.1", "text-encoding-utf-8": "^1.0.1", + "url": "^0.11.0", "velocity-vector": "vector-im/velocity#059e3b2", "whatwg-fetch": "^1.0.0" }, @@ -106,12 +107,10 @@ "karma-cli": "^0.1.2", "karma-junit-reporter": "^0.4.1", "karma-mocha": "^0.2.2", - "karma-phantomjs-launcher": "^1.0.0", "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^1.7.0", "mocha": "^2.4.5", "parallelshell": "^1.2.0", - "phantomjs-prebuilt": "^2.1.7", "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", diff --git a/scripts/emoji-data-strip.js b/scripts/emoji-data-strip.js index d7c80e7e37..40156471fe 100644 --- a/scripts/emoji-data-strip.js +++ b/scripts/emoji-data-strip.js @@ -1,5 +1,6 @@ #!/usr/bin/env node const EMOJI_DATA = require('emojione/emoji.json'); +const EMOJI_SUPPORTED = Object.keys(require('emojione').emojioneList); const fs = require('fs'); const output = Object.keys(EMOJI_DATA).map( @@ -16,7 +17,9 @@ const output = Object.keys(EMOJI_DATA).map( } return newDatum; } -); +).filter((datum) => { + return EMOJI_SUPPORTED.includes(datum.shortname); +}); // Write to a file in src. Changes should be checked into git. This file is copied by // babel using --copy-files diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 8be7a19b13..337e38d867 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require("./MatrixClientPeg"); +import MatrixClientPeg from './MatrixClientPeg'; import { _t } from './languageHandler'; /** @@ -44,7 +44,7 @@ class AddThreepid { this.sessionId = res.sid; return res; }, function(err) { - if (err.errcode == 'M_THREEPID_IN_USE') { + if (err.errcode === 'M_THREEPID_IN_USE') { err.message = _t('This email address is already in use'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; @@ -69,7 +69,7 @@ class AddThreepid { this.sessionId = res.sid; return res; }, function(err) { - if (err.errcode == 'M_THREEPID_IN_USE') { + if (err.errcode === 'M_THREEPID_IN_USE') { err.message = _t('This phone number is already in use'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; @@ -85,16 +85,15 @@ class AddThreepid { * the request failed. */ checkEmailLinkClicked() { - var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; + const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; return MatrixClientPeg.get().addThreePid({ sid: this.sessionId, client_secret: this.clientSecret, - id_server: identityServerDomain + id_server: identityServerDomain, }, this.bind).catch(function(err) { if (err.httpStatus === 401) { err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); - } - else if (err.httpStatus) { + } else if (err.httpStatus) { err.message += ` (Status ${err.httpStatus})`; } throw err; @@ -104,6 +103,7 @@ class AddThreepid { /** * Takes a phone number verification code as entered by the user and validates * it with the ID server, then if successful, adds the phone number. + * @param {string} token phone number verification code as entered by the user * @return {Promise} Resolves if the phone number was added. Rejects with an object * with a "message" property which contains a human-readable message detailing why * the request failed. @@ -119,7 +119,7 @@ class AddThreepid { return MatrixClientPeg.get().addThreePid({ sid: this.sessionId, client_secret: this.clientSecret, - id_server: identityServerDomain + id_server: identityServerDomain, }, this.bind); }); } diff --git a/src/Avatar.js b/src/Avatar.js index c0127d49af..d41a3f6a79 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -15,18 +15,18 @@ limitations under the License. */ 'use strict'; -var ContentRepo = require("matrix-js-sdk").ContentRepo; -var MatrixClientPeg = require('./MatrixClientPeg'); +import {ContentRepo} from 'matrix-js-sdk'; +import MatrixClientPeg from './MatrixClientPeg'; module.exports = { avatarUrlForMember: function(member, width, height, resizeMethod) { - var url = member.getAvatarUrl( + let url = member.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), Math.floor(width * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio), resizeMethod, false, - false + false, ); if (!url) { // member can be null here currently since on invites, the JS SDK @@ -38,11 +38,11 @@ module.exports = { }, avatarUrlForUser: function(user, width, height, resizeMethod) { - var url = ContentRepo.getHttpUriForMxc( + const url = ContentRepo.getHttpUriForMxc( MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, Math.floor(width * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio), - resizeMethod + resizeMethod, ); if (!url || url.length === 0) { return null; @@ -51,11 +51,11 @@ module.exports = { }, defaultAvatarUrlForString: function(s) { - var images = ['76cfa6', '50e2c2', 'f4c371']; - var total = 0; - for (var i = 0; i < s.length; ++i) { + const images = ['76cfa6', '50e2c2', 'f4c371']; + let total = 0; + for (let i = 0; i < s.length; ++i) { total += s.charCodeAt(i); } return 'img/' + images[total % images.length] + '.png'; - } + }, }; diff --git a/src/BasePlatform.js b/src/BasePlatform.js index a920479823..5f8772c7aa 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -57,6 +57,7 @@ export default class BasePlatform { /** * Returns true if the platform supports displaying * notifications, otherwise false. + * @returns {boolean} whether the platform supports displaying notifications */ supportsNotifications(): boolean { return false; @@ -65,6 +66,7 @@ export default class BasePlatform { /** * Returns true if the application currently has permission * to display notifications. Otherwise false. + * @returns {boolean} whether the application has permission to display notifications */ maySendNotifications(): boolean { return false; diff --git a/src/DateUtils.js b/src/DateUtils.js index 545d92dd3b..78eef57eae 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -54,24 +54,25 @@ function pad(n) { function twelveHourTime(date) { let hours = date.getHours() % 12; const minutes = pad(date.getMinutes()); - const ampm = date.getHours() >= 12 ? 'PM' : 'AM'; - hours = pad(hours ? hours : 12); + const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); + hours = hours ? hours : 12; // convert 0 -> 12 return `${hours}:${minutes}${ampm}`; } module.exports = { formatDate: function(date, showTwelveHour=false) { - var now = new Date(); + const now = new Date(); const days = getDaysArray(); const months = getMonthsArray(); if (date.toDateString() === now.toDateString()) { return this.formatTime(date); - } - else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { + } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { // TODO: use standard date localize function provided in counterpart - return _t('%(weekDayName)s %(time)s', {weekDayName: days[date.getDay()], time: this.formatTime(date, showTwelveHour)}); - } - else if (now.getFullYear() === date.getFullYear()) { + return _t('%(weekDayName)s %(time)s', { + weekDayName: days[date.getDay()], + time: this.formatTime(date, showTwelveHour), + }); + } else if (now.getFullYear() === date.getFullYear()) { // TODO: use standard date localize function provided in counterpart return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { weekDayName: days[date.getDay()], diff --git a/src/Entities.js b/src/Entities.js index 7c3909f36f..21abd9c473 100644 --- a/src/Entities.js +++ b/src/Entities.js @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require('react'); -var sdk = require('./index'); +import sdk from './index'; function isMatch(query, name, uid) { query = query.toLowerCase(); @@ -33,8 +32,8 @@ function isMatch(query, name, uid) { } // split spaces in name and try matching constituent parts - var parts = name.split(" "); - for (var i = 0; i < parts.length; i++) { + const parts = name.split(" "); + for (let i = 0; i < parts.length; i++) { if (parts[i].indexOf(query) === 0) { return true; } @@ -67,7 +66,7 @@ class Entity { class MemberEntity extends Entity { getJsx() { - var MemberTile = sdk.getComponent("rooms.MemberTile"); + const MemberTile = sdk.getComponent("rooms.MemberTile"); return ( ); @@ -84,6 +83,7 @@ class UserEntity extends Entity { super(model); this.showInviteButton = Boolean(showInviteButton); this.inviteFn = inviteFn; + this.onClick = this.onClick.bind(this); } onClick() { @@ -93,15 +93,15 @@ class UserEntity extends Entity { } getJsx() { - var UserTile = sdk.getComponent("rooms.UserTile"); + const UserTile = sdk.getComponent("rooms.UserTile"); return ( + showInviteButton={this.showInviteButton} onClick={this.onClick} /> ); } matches(queryString) { - var name = this.model.displayName || this.model.userId; + const name = this.model.displayName || this.model.userId; return isMatch(queryString, name, this.model.userId); } } @@ -109,7 +109,7 @@ class UserEntity extends Entity { module.exports = { newEntity: function(jsx, matchFn) { - var entity = new Entity(); + const entity = new Entity(); entity.getJsx = function() { return jsx; }; @@ -137,5 +137,5 @@ module.exports = { return users.map(function(u) { return new UserEntity(u, showInviteButton, inviteFn); }); - } + }, }; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 06f5d9ef00..f64e2b3858 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -419,6 +419,8 @@ export function logout() { * listen for events while a session is logged in. */ 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 diff --git a/src/Markdown.js b/src/Markdown.js index 4a46ce4f24..5730e42a09 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -17,7 +17,7 @@ limitations under the License. import commonmark from 'commonmark'; import escape from 'lodash/escape'; -const ALLOWED_HTML_TAGS = ['del']; +const ALLOWED_HTML_TAGS = ['del', 'u']; // These types of node are definitely text const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 0676e4600f..b31cf7511e 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -77,22 +77,26 @@ class MatrixClientPeg { this._createClient(creds); } - start() { + async start() { const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow opts.pendingEventOrdering = "detached"; - let promise = this.matrixClient.store.startup(); - // log any errors when starting up the database (if one exists) - promise.catch((err) => { + try { + let 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. - promise.finally(() => { - this.get().startClient(opts); - }); + + console.log(`MatrixClientPeg: really starting MatrixClient`); + this.get().startClient(opts); + console.log(`MatrixClientPeg: MatrixClient started`); } getCredentials(): MatrixClientCreds { diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js index 5fac588a4f..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]--; @@ -68,9 +68,9 @@ module.exports.getKeyValueArrayDiffs = function(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/PasswordReset.js b/src/PasswordReset.js index 0739ca0a24..71fc4f6b31 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -14,7 +14,7 @@ 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'; /** @@ -34,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]; @@ -53,7 +53,7 @@ class PasswordReset { this.sessionId = res.sid; return res; }, function(err) { - if (err.errcode == 'M_THREEPID_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})`; @@ -75,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 = _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) { + } 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/Resend.js b/src/Resend.js index bbd980ea7f..1fee5854ea 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -14,10 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -var MatrixClientPeg = require('./MatrixClientPeg'); -var dis = require('./dispatcher'); -var sdk = require('./index'); -var Modal = require('./Modal'); +import MatrixClientPeg from './MatrixClientPeg'; +import dis from './dispatcher'; import { EventStatus } from 'matrix-js-sdk'; module.exports = { @@ -37,12 +35,10 @@ module.exports = { }, resend: function(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent( - event, room - ).done(function(res) { + MatrixClientPeg.get().resendEvent(event, room).done(function(res) { dis.dispatch({ action: 'message_sent', - event: event + event: event, }); }, function(err) { // XXX: temporary logging to try to diagnose @@ -58,7 +54,7 @@ module.exports = { dis.dispatch({ action: 'message_send_failed', - event: event + event: event, }); }); }, @@ -66,7 +62,7 @@ module.exports = { MatrixClientPeg.get().cancelPendingEvent(event); dis.dispatch({ action: 'message_send_cancelled', - event: event + event: event, }); }, }; diff --git a/src/Roles.js b/src/Roles.js index 8c1f711bbe..83d8192c67 100644 --- a/src/Roles.js +++ b/src/Roles.js @@ -19,7 +19,7 @@ export function levelRoleMap() { return { undefined: _t('Default'), 0: _t('User'), - 50: _t('Moderator'), + 50: _t('Moderator'), 100: _t('Admin'), }; } diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index 7a43c1891e..c06cc60c97 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -19,8 +19,7 @@ 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; } } @@ -32,5 +31,5 @@ function mostRecentActivityFirst(roomList) { } module.exports = { - mostRecentActivityFirst: mostRecentActivityFirst + mostRecentActivityFirst, }; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 7cb7d4b9de..88b6e56c7f 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -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,11 +80,11 @@ function setRoomNotifsStateMuted(roomId) { kind: 'event_match', key: 'room_id', pattern: roomId, - } + }, ], actions: [ 'dont_notify', - ] + ], })); return q.all(promises); @@ -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,8 +119,8 @@ function setRoomNotifsStateUnmuted(roomId, newState) { { set_tweak: 'sound', value: 'default', - } - ] + }, + ], })); // https://matrix.org/jira/browse/SPEC-400 promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); @@ -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/ScalarAuthClient.js b/src/ScalarAuthClient.js index e1928e15d4..6908a7f67d 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -76,10 +76,13 @@ class ScalarAuthClient { return defer.promise; } - getScalarInterfaceUrlForRoom(roomId) { + getScalarInterfaceUrlForRoom(roomId, screen) { var url = SdkConfig.get().integrations_ui_url; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); + if (screen) { + url += '&screen=' + encodeURIComponent(screen); + } return url; } @@ -89,4 +92,3 @@ class ScalarAuthClient { } module.exports = ScalarAuthClient; - diff --git a/src/SdkConfig.js b/src/SdkConfig.js index 8d8e93a889..48ebf011f2 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -14,7 +14,7 @@ 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 @@ -30,8 +30,8 @@ class SdkConfig { } 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]]; } diff --git a/src/Skinner.js b/src/Skinner.js index 0688c9fc26..f47572ba01 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -51,19 +51,18 @@ class Skinner { 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; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 185ea504ac..b1cd59f3a9 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -186,7 +186,7 @@ const commands = { if (targetRoomId) { break; } } if (!targetRoomId) { - return reject(_t("Unrecognised room alias:") + ' ' + roomAlias); + return reject(_t("Unrecognised room alias:") + ' ' + roomAlias); } } } @@ -344,8 +344,7 @@ const commands = { _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}) - ); + {deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint})); } } } diff --git a/src/UserActivity.js b/src/UserActivity.js index 1ae272f5df..b6fae38ed5 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -var dis = require("./dispatcher"); +import dis from './dispatcher'; -var MIN_DISPATCH_INTERVAL_MS = 500; -var CURRENTLY_ACTIVE_THRESHOLD_MS = 2000; +const MIN_DISPATCH_INTERVAL_MS = 500; +const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000; /** * This class watches for user activity (moving the mouse or pressing a key) @@ -58,16 +58,15 @@ class UserActivity { /** * Return true if there has been user activity very recently * (ie. within a few seconds) + * @returns {boolean} true if user is currently/very recently active */ userCurrentlyActive() { return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS; } _onUserActivity(event) { - if (event.screenX && event.type == "mousemove") { - if (event.screenX === this.lastScreenX && - event.screenY === this.lastScreenY) - { + if (event.screenX && event.type === "mousemove") { + if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { // mouse hasn't actually moved return; } @@ -79,28 +78,24 @@ class UserActivity { if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) { this.lastDispatchAtTs = this.lastActivityAtTs; dis.dispatch({ - action: 'user_activity' + action: 'user_activity', }); if (!this.activityEndTimer) { - this.activityEndTimer = setTimeout( - this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS - ); + this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS); } } } _onActivityEndTimer() { - var now = new Date().getTime(); - var targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS; + const now = new Date().getTime(); + const targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS; if (now >= targetTime) { dis.dispatch({ - action: 'user_activity_end' + action: 'user_activity_end', }); this.activityEndTimer = undefined; } else { - this.activityEndTimer = setTimeout( - this._onActivityEndTimer.bind(this), targetTime - now - ); + this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), targetTime - now); } } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 9ae3a7badb..6f2f68b121 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -21,6 +21,7 @@ import AutocompleteProvider from './AutocompleteProvider'; import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; +// TODO merge this with the factory mechanics of SlashCommands? // Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file const COMMANDS = [ { @@ -28,11 +29,6 @@ const COMMANDS = [ args: '', description: 'Displays action', }, - { - command: '/part', - args: '[#alias:domain]', - description: 'Leave room', - }, { command: '/ban', args: ' [reason]', @@ -43,6 +39,11 @@ const COMMANDS = [ args: '', description: 'Unbans user with given id', }, + { + command: '/op', + args: ' []', + description: 'Define the power level of a user', + }, { command: '/deop', args: '', @@ -58,6 +59,16 @@ const COMMANDS = [ args: '', description: 'Joins room with given alias', }, + { + command: '/part', + args: '[]', + description: 'Leave room', + }, + { + command: '/topic', + args: '', + description: 'Sets the room topic', + }, { command: '/kick', args: ' [reason]', @@ -74,10 +85,16 @@ const COMMANDS = [ description: 'Searches DuckDuckGo for results', }, { - command: '/op', - args: ' []', - description: 'Define the power level of a user', + command: '/tint', + args: ' []', + description: 'Changes colour scheme of current room', }, + { + command: '/verify', + args: ' ', + description: 'Verifies a user, device, and pubkey tuple', + }, + // Omitting `/markdown` as it only seems to apply to OldComposer ]; const COMMAND_RE = /(^\/\w*)/g; diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 36aad68639..35e9cc7b68 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -41,7 +41,7 @@ const CATEGORY_ORDER = [ ]; // Match for ":wink:" or ascii-style ";-)" provided by emojione -const EMOJI_REGEX = new RegExp('(:\\w*:?|' + asciiRegexp + ')', 'g'); +const EMOJI_REGEX = new RegExp('(' + asciiRegexp + '|:\\w*:?)$', 'g'); const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort( (a, b) => { if (a.category === b.category) { @@ -101,7 +101,7 @@ export default class EmojiProvider extends AutocompleteProvider { } renderCompletions(completions: [React.Component]): ?React.Component { - return
+ return
{completions}
; } diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js index 1b2ee1bc0d..07398e7a5f 100644 --- a/src/autocomplete/QueryMatcher.js +++ b/src/autocomplete/QueryMatcher.js @@ -69,6 +69,12 @@ export default class QueryMatcher { if (this.options.shouldMatchWordsOnly === undefined) { this.options.shouldMatchWordsOnly = true; } + + // By default, match anywhere in the string being searched. If enabled, only return + // matches that are prefixed with the query. + if (this.options.shouldMatchPrefix === undefined) { + this.options.shouldMatchPrefix = false; + } } setObjects(objects: Array) { @@ -80,13 +86,27 @@ export default class QueryMatcher { if (this.options.shouldMatchWordsOnly) { query = query.replace(/[^\w]/g, ''); } - const results = _sortedUniq(_sortBy(_flatMap(this.keyMap.keys, (key) => { + if (query.length === 0) { + return []; + } + const results = []; + this.keyMap.keys.forEach((key) => { let resultKey = key.toLowerCase(); if (this.options.shouldMatchWordsOnly) { resultKey = resultKey.replace(/[^\w]/g, ''); } - return resultKey.indexOf(query) !== -1 ? this.keyMap.objectMap[key] : []; - }), (candidate) => this.keyMap.priorityMap.get(candidate))); - return results; + const index = resultKey.indexOf(query); + if (index !== -1 && (!this.options.shouldMatchPrefix || index === 0)) { + results.push({key, index}); + } + }); + + return _sortedUniq(_flatMap(_sortBy(results, (candidate) => { + return candidate.index; + }).map((candidate) => { + // return an array of objects (those given to setObjects) that have the given + // key as a property. + return this.keyMap.objectMap[candidate.key]; + }))); } } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index a001f381ee..bf8495a90e 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -78,7 +78,7 @@ export default class RoomProvider extends AutocompleteProvider { } renderCompletions(completions: [React.Component]): ?React.Component { - return
+ return
{completions}
; } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 4e0c0f5ea7..26ec15e124 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -37,10 +37,11 @@ export default class UserProvider extends AutocompleteProvider { constructor() { super(USER_REGEX, { - keys: ['name', 'userId'], + keys: ['name'], }); this.matcher = new FuzzyMatcher([], { - keys: ['name', 'userId'], + keys: ['name'], + shouldMatchPrefix: true, }); } @@ -50,7 +51,7 @@ export default class UserProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection, force); if (command) { - completions = this.matcher.match(command[0]).map(user => { + completions = this.matcher.match(command[0]).slice(0, 4).map((user) => { let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done let completion = displayName; if (range.start === 0) { @@ -68,7 +69,7 @@ export default class UserProvider extends AutocompleteProvider { ), range, }; - }).slice(0, 4); + }); } return completions; } @@ -90,7 +91,9 @@ export default class UserProvider extends AutocompleteProvider { if (member.userId !== currentUserId) return true; }); - this.users = _sortBy(this.users, (user) => 1E20 - lastSpoken[user.userId] || 1E20); + this.users = _sortBy(this.users, (completion) => + 1E20 - lastSpoken[completion.user.userId] || 1E20, + ); this.matcher.setObjects(this.users); } @@ -98,9 +101,10 @@ export default class UserProvider extends AutocompleteProvider { onUserSpoke(user: RoomMember) { if(user.userId === MatrixClientPeg.get().credentials.userId) return; - // Probably unsafe to compare by reference here? - _pull(this.users, user); - this.users.splice(0, 0, user); + this.users = this.users.splice( + this.users.findIndex((user2) => user2.userId === user.userId), 1); + this.users = [user, ...this.users]; + this.matcher.setObjects(this.users); } @@ -112,7 +116,7 @@ export default class UserProvider extends AutocompleteProvider { } renderCompletions(completions: [React.Component]): ?React.Component { - return
+ return
{completions}
; } diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 0a7674f4d8..18b9340b4a 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -103,8 +103,9 @@ export default React.createClass({ this.setState({ summary: null, error: null, + }, () => { + this._loadGroupFromServer(newProps.groupId); }); - this._loadGroupFromServer(newProps.groupId); } }, diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 6f4c931ab7..9fed0e7d5b 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -18,8 +18,12 @@ limitations under the License. import React from 'react'; import MatrixClientPeg from '../../../MatrixClientPeg'; +import ScalarAuthClient from '../../../ScalarAuthClient'; +import SdkConfig from '../../../SdkConfig'; import { _t } from '../../../languageHandler'; +import url from 'url'; + export default React.createClass({ displayName: 'AppTile', @@ -36,6 +40,51 @@ export default React.createClass({ }; }, + getInitialState: function() { + return { + loading: false, + widgetUrl: this.props.url, + error: null, + }; + }, + + // Returns true if props.url is a scalar URL, typically https://scalar.vector.im/api + isScalarUrl: function() { + const scalarUrl = SdkConfig.get().integrations_rest_url; + return scalarUrl && this.props.url.startsWith(scalarUrl); + }, + + componentWillMount: function() { + if (!this.isScalarUrl()) { + return; + } + // Fetch the token before loading the iframe as we need to mangle the URL + this.setState({ + loading: true, + }); + this._scalarClient = new ScalarAuthClient(); + this._scalarClient.getScalarToken().done((token) => { + // Append scalar_token as a query param + const u = url.parse(this.props.url); + if (!u.search) { + u.search = "?scalar_token=" + encodeURIComponent(token); + } else { + u.search += "&scalar_token=" + encodeURIComponent(token); + } + + this.setState({ + error: null, + widgetUrl: u.format(), + loading: false, + }); + }, (err) => { + this.setState({ + error: err.message, + loading: false, + }); + }); + }, + _onEditClick: function() { console.log("Edit widget %s", this.props.id); }, @@ -72,6 +121,18 @@ export default React.createClass({ }, render: function() { + let appTileBody; + if (this.state.loading) { + appTileBody = ( +
Loading...
+ ); + } else { + appTileBody = ( +
+ +
+ ); + } return (
@@ -93,9 +154,7 @@ export default React.createClass({ />
-
- -
+ {appTileBody}
); }, diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 190b1341c3..2c50a94a6a 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -143,9 +143,15 @@ module.exports = React.createClass({ if (this.props.showUrlPreview && !this.state.links.length) { var links = this.findLinks(this.refs.content.children); if (links.length) { - this.setState({ links: links.map((link)=>{ - return link.getAttribute("href"); - })}); + // de-dup the links (but preserve ordering) + const seen = new Set(); + links = links.filter((link) => { + if (seen.has(link)) return false; + seen.add(link); + return true; + }); + + this.setState({ links: links }); // lazy-load the hidden state of the preview widget from localstorage if (global.localStorage) { @@ -158,12 +164,13 @@ module.exports = React.createClass({ findLinks: function(nodes) { var links = []; + for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (node.tagName === "A" && node.getAttribute("href")) { if (this.isLinkPreviewable(node)) { - links.push(node); + links.push(node.getAttribute("href")); } } else if (node.tagName === "PRE" || node.tagName === "CODE" || diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index b535b148ac..a12bd8ecac 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -176,7 +176,7 @@ module.exports = React.createClass({ const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? - this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) : + this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'add_integ') : null; Modal.createDialog(IntegrationsManager, { src: src, @@ -187,7 +187,7 @@ module.exports = React.createClass({ const apps = this.state.apps.map( (app, index, arr) => { return 0) { + if (this.state.completions.length > 0 || this.state.forceComplete) { autocompleteDelay = 0; } @@ -177,7 +177,7 @@ export default class Autocomplete extends React.Component { hide: false, }, () => { this.complete(this.props.query, this.props.selection).then(() => { - done.resolve(); + done.resolve(this.countCompletions()); }); }); return done.promise; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index c83e32d9a8..27d5e11119 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -21,7 +21,6 @@ import Modal from '../../../Modal'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import Autocomplete from './Autocomplete'; -import classNames from 'classnames'; import UserSettingsStore from '../../../UserSettingsStore'; @@ -408,14 +407,10 @@ export default class MessageComposer extends React.Component { const active = style.includes(name) || blockType === name; const suffix = active ? '-o-n' : ''; const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); - const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name; - const className = classNames("mx_MessageComposer_format_button", { - mx_MessageComposer_format_button_disabled: disabled, - mx_filterFlipColor: true, - }); + const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; return ; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index aae91620d8..3465b2ad14 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -43,6 +43,8 @@ import Markdown from '../../../Markdown'; import ComposerHistoryManager from '../../../ComposerHistoryManager'; import {onSendMessageFailed} from './MessageComposerInputOld'; +import MessageComposerStore from '../../../stores/MessageComposerStore'; + const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const ZWS_CODE = 8203; @@ -130,7 +132,10 @@ export default class MessageComposerInput extends React.Component { isRichtextEnabled, // the currently displayed editor state (note: this is always what is modified on input) - editorState: null, + editorState: this.createEditorState( + isRichtextEnabled, + MessageComposerStore.getContentState(this.props.room.roomId), + ), // the original editor state, before we started tabbing through completions originalEditorState: null, @@ -138,11 +143,10 @@ export default class MessageComposerInput extends React.Component { // the virtual state "above" the history stack, the message currently being composed that // we want to persist whilst browsing history currentlyComposedEditorState: null, - }; - // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled - /* eslint react/no-direct-mutation-state:0 */ - this.state.editorState = this.createEditorState(); + // whether there were any completions + someCompletions: null, + }; this.client = MatrixClientPeg.get(); } @@ -336,6 +340,14 @@ export default class MessageComposerInput extends React.Component { this.onFinishedTyping(); } + // Record the editor state for this room so that it can be retrieved after + // switching to another room and back + dis.dispatch({ + action: 'content_state', + room_id: this.props.room.roomId, + content_state: state.editorState.getCurrentContent(), + }); + if (!state.hasOwnProperty('originalEditorState')) { state.originalEditorState = null; } @@ -403,26 +415,59 @@ export default class MessageComposerInput extends React.Component { }); } } else { - let contentState = this.state.editorState.getCurrentContent(), - selection = this.state.editorState.getSelection(); + let contentState = this.state.editorState.getCurrentContent(); const modifyFn = { 'bold': (text) => `**${text}**`, 'italic': (text) => `*${text}*`, - 'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* + 'underline': (text) => `${text}`, 'strike': (text) => `${text}`, - 'code-block': (text) => `\`\`\`\n${text}\n\`\`\``, - 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''), + 'code-block': (text) => `\`\`\`\n${text}\n\`\`\`\n`, + 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join('') + '\n', 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''), 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''), }[command]; + const selectionAfterOffset = { + 'bold': -2, + 'italic': -1, + 'underline': -4, + 'strike': -6, + 'code-block': -5, + 'blockquote': -2, + }[command]; + + // Returns a function that collapses a selectionState to its end and moves it by offset + const collapseAndOffsetSelection = (selectionState, offset) => { + const key = selectionState.getEndKey(); + return new SelectionState({ + anchorKey: key, anchorOffset: offset, + focusKey: key, focusOffset: offset, + }); + }; + if (modifyFn) { + const previousSelection = this.state.editorState.getSelection(); + const newContentState = RichText.modifyText(contentState, previousSelection, modifyFn); newState = EditorState.push( this.state.editorState, - RichText.modifyText(contentState, selection, modifyFn), + newContentState, 'insert-characters', ); + + let newSelection = newContentState.getSelectionAfter(); + // If the selection range is 0, move the cursor inside the formatted body + if (previousSelection.getStartOffset() === previousSelection.getEndOffset() && + previousSelection.getStartKey() === previousSelection.getEndKey() && + selectionAfterOffset !== undefined + ) { + const selectedBlock = newContentState.getBlockForKey(previousSelection.getAnchorKey()); + const blockLength = selectedBlock.getText().length; + const newOffset = blockLength + selectionAfterOffset; + newSelection = collapseAndOffsetSelection(newSelection, newOffset); + } + + newState = EditorState.forceSelection(newState, newSelection); } } @@ -443,8 +488,7 @@ export default class MessageComposerInput extends React.Component { const currentContent = this.state.editorState.getCurrentContent(); let contentState = null; - - if (html) { + if (html && this.state.isRichtextEnabled) { contentState = Modifier.replaceWithFragment( currentContent, currentSelection, @@ -548,14 +592,6 @@ export default class MessageComposerInput extends React.Component { let sendHtmlFn = this.client.sendHtmlMessage; let sendTextFn = this.client.sendTextMessage; - if (contentText.startsWith('/me')) { - contentText = contentText.substring(4); - // bit of a hack, but the alternative would be quite complicated - if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, ''); - sendHtmlFn = this.client.sendHtmlEmote; - sendTextFn = this.client.sendEmoteMessage; - } - if (this.state.isRichtextEnabled) { this.historyManager.addItem( contentHTML ? contentHTML : contentText, @@ -566,6 +602,14 @@ export default class MessageComposerInput extends React.Component { this.historyManager.addItem(contentText, 'markdown'); } + if (contentText.startsWith('/me')) { + contentText = contentText.substring(4); + // bit of a hack, but the alternative would be quite complicated + if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, ''); + sendHtmlFn = this.client.sendHtmlEmote; + sendTextFn = this.client.sendEmoteMessage; + } + let sendMessagePromise; if (contentHTML) { sendMessagePromise = sendHtmlFn.call( @@ -599,6 +643,10 @@ export default class MessageComposerInput extends React.Component { }; onVerticalArrow = (e, up) => { + if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) { + return; + } + // Select history only if we are not currently auto-completing if (this.autocomplete.state.completionList.length === 0) { // Don't go back in history if we're in the middle of a multi-line message @@ -607,17 +655,16 @@ export default class MessageComposerInput extends React.Component { const firstBlock = this.state.editorState.getCurrentContent().getFirstBlock(); const lastBlock = this.state.editorState.getCurrentContent().getLastBlock(); - const selectionOffset = selection.getAnchorOffset(); let canMoveUp = false; let canMoveDown = false; if (blockKey === firstBlock.getKey()) { - const textBeforeCursor = firstBlock.getText().slice(0, selectionOffset); - canMoveUp = textBeforeCursor.indexOf('\n') === -1; + canMoveUp = selection.getStartOffset() === selection.getEndOffset() && + selection.getStartOffset() === 0; } if (blockKey === lastBlock.getKey()) { - const textAfterCursor = lastBlock.getText().slice(selectionOffset); - canMoveDown = textAfterCursor.indexOf('\n') === -1; + canMoveDown = selection.getStartOffset() === selection.getEndOffset() && + selection.getStartOffset() === lastBlock.getText().length; } if ((up && !canMoveUp) || (!up && !canMoveDown)) return; @@ -674,10 +721,16 @@ export default class MessageComposerInput extends React.Component { }; onTab = async (e) => { + this.setState({ + someCompletions: null, + }); e.preventDefault(); if (this.autocomplete.state.completionList.length === 0) { // Force completions to show for the text currently entered - await this.autocomplete.forceComplete(); + const completionCount = await this.autocomplete.forceComplete(); + this.setState({ + someCompletions: completionCount > 0, + }); // Select the first item by moving "down" await this.moveAutocompleteSelection(false); } else { @@ -798,6 +851,7 @@ export default class MessageComposerInput extends React.Component { const className = classNames('mx_MessageComposer_input', { mx_MessageComposer_input_empty: hidePlaceholder, + mx_MessageComposer_input_error: this.state.someCompletions === false, }); const content = activeEditorState.getCurrentContent(); diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 19010d8a10..85aedadf64 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -16,18 +16,18 @@ limitations under the License. 'use strict'; -var React = require('react'); -var classNames = require('classnames'); -var sdk = require('../../../index'); +import React from 'react'; +import classNames from 'classnames'; +import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -var MatrixClientPeg = require('../../../MatrixClientPeg'); -var Modal = require("../../../Modal"); -var dis = require("../../../dispatcher"); -var rate_limited_func = require('../../../ratelimitedfunc'); +import MatrixClientPeg from '../../../MatrixClientPeg'; +import Modal from "../../../Modal"; +import dis from "../../../dispatcher"; +import RateLimitedFunc from '../../../ratelimitedfunc'; -var linkify = require('linkifyjs'); -var linkifyElement = require('linkifyjs/element'); -var linkifyMatrix = require('../../../linkify-matrix'); +import * as linkify from 'linkifyjs'; +import linkifyElement from 'linkifyjs/element'; +import linkifyMatrix from '../../../linkify-matrix'; import AccessibleButton from '../elements/AccessibleButton'; import {CancelButton} from './SimpleRoomHeader'; @@ -58,7 +58,7 @@ module.exports = React.createClass({ }, componentDidMount: function() { - var cli = MatrixClientPeg.get(); + const cli = MatrixClientPeg.get(); cli.on("RoomState.events", this._onRoomStateEvents); // When a room name occurs, RoomState.events is fired *before* @@ -79,14 +79,14 @@ module.exports = React.createClass({ if (this.props.room) { this.props.room.removeListener("Room.name", this._onRoomNameChange); } - var cli = MatrixClientPeg.get(); + const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener("RoomState.events", this._onRoomStateEvents); } }, _onRoomStateEvents: function(event, state) { - if (!this.props.room || event.getRoomId() != this.props.room.roomId) { + if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { return; } @@ -94,7 +94,8 @@ module.exports = React.createClass({ this._rateLimitedUpdate(); }, - _rateLimitedUpdate: new rate_limited_func(function() { + _rateLimitedUpdate: new RateLimitedFunc(function() { + /* eslint-disable babel/no-invalid-this */ this.forceUpdate(); }, 500), @@ -109,15 +110,14 @@ module.exports = React.createClass({ }, onAvatarSelected: function(ev) { - var self = this; - var changeAvatar = this.refs.changeAvatar; + const changeAvatar = this.refs.changeAvatar; if (!changeAvatar) { console.error("No ChangeAvatar found to upload image to!"); return; } changeAvatar.onFileSelected(ev).catch(function(err) { - var errMsg = (typeof err === "string") ? err : (err.error || ""); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const errMsg = (typeof err === "string") ? err : (err.error || ""); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set avatar: " + errMsg); Modal.createDialog(ErrorDialog, { title: _t("Error"), @@ -133,10 +133,10 @@ module.exports = React.createClass({ /** * After editing the settings, get the new name for the room * - * Returns undefined if we didn't let the user edit the room name + * @return {?string} newName or undefined if we didn't let the user edit the room name */ getEditedName: function() { - var newName; + let newName; if (this.refs.nameEditor) { newName = this.refs.nameEditor.getRoomName(); } @@ -146,10 +146,10 @@ module.exports = React.createClass({ /** * After editing the settings, get the new topic for the room * - * Returns undefined if we didn't let the user edit the room topic + * @return {?string} newTopic or undefined if we didn't let the user edit the room topic */ getEditedTopic: function() { - var newTopic; + let newTopic; if (this.refs.topicEditor) { newTopic = this.refs.topicEditor.getTopic(); } @@ -157,38 +157,31 @@ module.exports = React.createClass({ }, render: function() { - var RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); - var ChangeAvatar = sdk.getComponent("settings.ChangeAvatar"); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + const RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); + const ChangeAvatar = sdk.getComponent("settings.ChangeAvatar"); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); const EmojiText = sdk.getComponent('elements.EmojiText'); - var header; - var name = null; - var searchStatus = null; - var topic_el = null; - var cancel_button = null; - var spinner = null; - var save_button = null; - var settings_button = null; + let name = null; + let searchStatus = null; + let topicElement = null; + let cancelButton = null; + let spinner = null; + let saveButton = null; + let settingsButton = null; + + let canSetRoomName; + let canSetRoomAvatar; + let canSetRoomTopic; if (this.props.editing) { - // calculate permissions. XXX: this should be done on mount or something - var user_id = MatrixClientPeg.get().credentials.userId; + const userId = MatrixClientPeg.get().credentials.userId; - var can_set_room_name = this.props.room.currentState.maySendStateEvent( - 'm.room.name', user_id - ); - var can_set_room_avatar = this.props.room.currentState.maySendStateEvent( - 'm.room.avatar', user_id - ); - var can_set_room_topic = this.props.room.currentState.maySendStateEvent( - 'm.room.topic', user_id - ); - var can_set_room_name = this.props.room.currentState.maySendStateEvent( - 'm.room.name', user_id - ); + canSetRoomName = this.props.room.currentState.maySendStateEvent('m.room.name', userId); + canSetRoomAvatar = this.props.room.currentState.maySendStateEvent('m.room.avatar', userId); + canSetRoomTopic = this.props.room.currentState.maySendStateEvent('m.room.topic', userId); - save_button = ( + saveButton = ( {_t("Save")} @@ -196,39 +189,41 @@ module.exports = React.createClass({ } if (this.props.onCancelClick) { - cancel_button = ; + cancelButton = ; } if (this.props.saving) { - var Spinner = sdk.getComponent("elements.Spinner"); + const Spinner = sdk.getComponent("elements.Spinner"); spinner =
; } - if (can_set_room_name) { - var RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor"); + if (canSetRoomName) { + const RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor"); name = ; - } - else { - var searchStatus; + } else { // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. - if (this.props.searchInfo && this.props.searchInfo.searchCount !== undefined && this.props.searchInfo.searchCount !== null) { - searchStatus =
 { _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }
; + if (this.props.searchInfo && + this.props.searchInfo.searchCount !== undefined && + this.props.searchInfo.searchCount !== null) { + searchStatus =
  + { _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) } +
; } // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'... - var settingsHint = false; - var members = this.props.room ? this.props.room.getJoinedMembers() : undefined; + let settingsHint = false; + const members = this.props.room ? this.props.room.getJoinedMembers() : undefined; if (members) { if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { - var name = this.props.room.currentState.getStateEvents('m.room.name', ''); - if (!name || !name.getContent().name) { + const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', ''); + if (!nameEvent || !nameEvent.getContent().name) { settingsHint = true; } } } - var roomName = _t("Join Room"); + let roomName = _t("Join Room"); if (this.props.oobData && this.props.oobData.name) { roomName = this.props.oobData.name; } else if (this.props.room) { @@ -243,24 +238,25 @@ module.exports = React.createClass({
; } - if (can_set_room_topic) { - var RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor"); - topic_el = ; + if (canSetRoomTopic) { + const RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor"); + topicElement = ; } else { - var topic; + let topic; if (this.props.room) { - var ev = this.props.room.currentState.getStateEvents('m.room.topic', ''); + const ev = this.props.room.currentState.getStateEvents('m.room.topic', ''); if (ev) { topic = ev.getContent().topic; } } if (topic) { - topic_el =
{ topic }
; + topicElement = +
{ topic }
; } } - var roomAvatar = null; - if (can_set_room_avatar) { + let roomAvatar = null; + if (canSetRoomAvatar) { roomAvatar = (
@@ -276,8 +272,7 @@ module.exports = React.createClass({
); - } - else if (this.props.room || (this.props.oobData && this.props.oobData.name)) { + } else if (this.props.room || (this.props.oobData && this.props.oobData.name)) { roomAvatar = (
@@ -285,9 +280,8 @@ module.exports = React.createClass({ ); } - var settings_button; if (this.props.onSettingsClick) { - settings_button = + settingsButton = ; @@ -301,61 +295,58 @@ module.exports = React.createClass({ //
; // } - var forget_button; + let forgetButton; if (this.props.onForgetClick) { - forget_button = + forgetButton = ; } - let search_button; + let searchButton; if (this.props.onSearchClick && this.props.inRoom) { - search_button = + searchButton = ; } - var rightPanel_buttons; + let rightPanelButtons; if (this.props.collapsedRhs) { - rightPanel_buttons = + rightPanelButtons = ; } - var right_row; + let rightRow; if (!this.props.editing) { - right_row = + rightRow =
- { settings_button } - { forget_button } - { search_button } - { rightPanel_buttons } + { settingsButton } + { forgetButton } + { searchButton } + { rightPanelButtons }
; } - header = -
-
-
- { roomAvatar } -
-
- { name } - { topic_el } -
-
- {spinner} - {save_button} - {cancel_button} - {right_row} -
; - return (
- { header } +
+
+
+ { roomAvatar } +
+
+ { name } + { topicElement } +
+
+ {spinner} + {saveButton} + {cancelButton} + {rightRow} +
); }, diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 171af4764b..d255670a52 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -39,6 +39,7 @@ function parseIntWithDefault(val, def) { const BannedUser = React.createClass({ propTypes: { + canUnban: React.PropTypes.bool, member: React.PropTypes.object.isRequired, // js-sdk RoomMember reason: React.PropTypes.string, }, @@ -67,13 +68,17 @@ const BannedUser = React.createClass({ }, render: function() { + let unbanButton; + + if (this.props.canUnban) { + unbanButton = + { _t('Unban') } + ; + } + return (
  • - - { _t('Unban') } - + { unbanButton } {this.props.member.name} {this.props.member.userId} {this.props.reason ? " " +_t('Reason') + ": " + this.props.reason : ""}
  • @@ -667,6 +672,7 @@ module.exports = React.createClass({ const banned = this.props.room.getMembersWithMembership("ban"); let bannedUsersSection; if (banned.length) { + const canBanUsers = current_user_level >= ban_level; bannedUsersSection =

    { _t('Banned users') }

    @@ -674,7 +680,7 @@ module.exports = React.createClass({ {banned.map(function(member) { const banEvent = member.events.member.getContent(); return ( - + ); })} diff --git a/src/components/views/voip/CallView.js b/src/components/views/voip/CallView.js index b53794637f..e669f7e0a6 100644 --- a/src/components/views/voip/CallView.js +++ b/src/components/views/voip/CallView.js @@ -13,11 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); -var dis = require("../../../dispatcher"); -var CallHandler = require("../../../CallHandler"); -var sdk = require('../../../index'); -var MatrixClientPeg = require("../../../MatrixClientPeg"); +import React from 'react'; +import dis from '../../../dispatcher'; +import CallHandler from '../../../CallHandler'; +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; module.exports = React.createClass({ @@ -73,10 +73,10 @@ module.exports = React.createClass({ }, showCall: function() { - var call; + let call; if (this.props.room) { - var roomId = this.props.room.roomId; + const roomId = this.props.room.roomId; call = CallHandler.getCallForRoom(roomId) || (this.props.ConferenceHandler ? this.props.ConferenceHandler.getConferenceCallForRoom(roomId) : @@ -86,9 +86,7 @@ module.exports = React.createClass({ if (this.call) { this.setState({ call: call }); } - - } - else { + } else { call = CallHandler.getAnyActiveCall(); this.setState({ call: call }); } @@ -109,8 +107,7 @@ module.exports = React.createClass({ call.confUserId ? "none" : "block" ); this.getVideoView().getRemoteVideoElement().style.display = "block"; - } - else { + } else { this.getVideoView().getLocalVideoElement().style.display = "none"; this.getVideoView().getRemoteVideoElement().style.display = "none"; dis.dispatch({action: 'video_fullscreen', fullscreen: false}); @@ -126,11 +123,11 @@ module.exports = React.createClass({ }, render: function() { - var VideoView = sdk.getComponent('voip.VideoView'); + const VideoView = sdk.getComponent('voip.VideoView'); - var voice; + let voice; if (this.state.call && this.state.call.type === "voice" && this.props.showVoice) { - var callRoom = MatrixClientPeg.get().getRoom(this.state.call.roomId); + const callRoom = MatrixClientPeg.get().getRoom(this.state.call.roomId); voice = (
    {_t("Active call (%(roomName)s)", {roomName: callRoom.name})} @@ -147,6 +144,6 @@ module.exports = React.createClass({ { voice }
    ); - } + }, }); diff --git a/src/components/views/voip/IncomingCallBox.js b/src/components/views/voip/IncomingCallBox.js index 1b806fc5b3..c5934b74dc 100644 --- a/src/components/views/voip/IncomingCallBox.js +++ b/src/components/views/voip/IncomingCallBox.js @@ -13,10 +13,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -var React = require('react'); -var MatrixClientPeg = require('../../../MatrixClientPeg'); -var dis = require("../../../dispatcher"); -var CallHandler = require("../../../CallHandler"); +import React from 'react'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import dis from '../../../dispatcher'; import { _t } from '../../../languageHandler'; module.exports = React.createClass({ @@ -29,34 +28,32 @@ module.exports = React.createClass({ onAnswerClick: function() { dis.dispatch({ action: 'answer', - room_id: this.props.incomingCall.roomId + room_id: this.props.incomingCall.roomId, }); }, onRejectClick: function() { dis.dispatch({ action: 'hangup', - room_id: this.props.incomingCall.roomId + room_id: this.props.incomingCall.roomId, }); }, render: function() { - var room = null; + let room = null; if (this.props.incomingCall) { room = MatrixClientPeg.get().getRoom(this.props.incomingCall.roomId); } - var caller = room ? room.name : _t("unknown caller"); + const caller = room ? room.name : _t("unknown caller"); let incomingCallText = null; if (this.props.incomingCall) { if (this.props.incomingCall.type === "voice") { incomingCallText = _t("Incoming voice call from %(name)s", {name: caller}); - } - else if (this.props.incomingCall.type === "video") { + } else if (this.props.incomingCall.type === "video") { incomingCallText = _t("Incoming video call from %(name)s", {name: caller}); - } - else { + } else { incomingCallText = _t("Incoming call from %(name)s", {name: caller}); } } @@ -81,6 +78,6 @@ module.exports = React.createClass({
    ); - } + }, }); diff --git a/src/components/views/voip/VideoFeed.js b/src/components/views/voip/VideoFeed.js index 0b8d0b20fc..953dbc866f 100644 --- a/src/components/views/voip/VideoFeed.js +++ b/src/components/views/voip/VideoFeed.js @@ -16,7 +16,7 @@ limitations under the License. 'use strict'; -var React = require('react'); +import React from 'react'; module.exports = React.createClass({ displayName: 'VideoFeed', diff --git a/src/components/views/voip/VideoView.js b/src/components/views/voip/VideoView.js index ea37579237..6ebf2078c1 100644 --- a/src/components/views/voip/VideoView.js +++ b/src/components/views/voip/VideoView.js @@ -16,11 +16,11 @@ limitations under the License. 'use strict'; -var React = require('react'); -var ReactDOM = require('react-dom'); +import React from 'react'; +import ReactDOM from 'react-dom'; -var sdk = require('../../../index'); -var dis = require('../../../dispatcher'); +import sdk from '../../../index'; +import dis from '../../../dispatcher'; module.exports = React.createClass({ displayName: 'VideoView', @@ -53,9 +53,10 @@ module.exports = React.createClass({ // this needs to be somewhere at the top of the DOM which // always exists to avoid audio interruptions. // Might as well just use DOM. - var remoteAudioElement = document.getElementById("remoteAudio"); + const remoteAudioElement = document.getElementById("remoteAudio"); if (!remoteAudioElement) { - console.error("Failed to find remoteAudio element - cannot play audio! You need to add an