Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/markdown_allow_u

This commit is contained in:
Michael Telatynski 2017-07-06 13:56:20 +01:00
commit ff7ae5b995
No known key found for this signature in database
GPG key ID: 0435A1D4BBD34D64
46 changed files with 587 additions and 407 deletions

View file

@ -1,6 +1,5 @@
# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. # autogenerated file: run scripts/generate-eslint-error-ignore-file to update.
src/AddThreepid.js
src/async-components/views/dialogs/EncryptedEventDialog.js src/async-components/views/dialogs/EncryptedEventDialog.js
src/autocomplete/AutocompleteProvider.js src/autocomplete/AutocompleteProvider.js
src/autocomplete/Autocompleter.js src/autocomplete/Autocompleter.js
@ -9,8 +8,6 @@ src/autocomplete/DuckDuckGoProvider.js
src/autocomplete/EmojiProvider.js src/autocomplete/EmojiProvider.js
src/autocomplete/RoomProvider.js src/autocomplete/RoomProvider.js
src/autocomplete/UserProvider.js src/autocomplete/UserProvider.js
src/Avatar.js
src/BasePlatform.js
src/CallHandler.js src/CallHandler.js
src/component-index.js src/component-index.js
src/components/structures/ContextualMenu.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/MessageComposerInputOld.js
src/components/views/rooms/PresenceLabel.js src/components/views/rooms/PresenceLabel.js
src/components/views/rooms/ReadReceiptMarker.js src/components/views/rooms/ReadReceiptMarker.js
src/components/views/rooms/RoomHeader.js
src/components/views/rooms/RoomList.js src/components/views/rooms/RoomList.js
src/components/views/rooms/RoomNameEditor.js src/components/views/rooms/RoomNameEditor.js
src/components/views/rooms/RoomPreviewBar.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/DevicesPanel.js
src/components/views/settings/DevicesPanelEntry.js src/components/views/settings/DevicesPanelEntry.js
src/components/views/settings/EnableNotificationsButton.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/ContentMessages.js
src/createRoom.js
src/DateUtils.js
src/email.js
src/Entities.js
src/extend.js
src/HtmlUtils.js src/HtmlUtils.js
src/ImageUtils.js src/ImageUtils.js
src/Invite.js src/Invite.js
@ -135,30 +122,20 @@ src/Markdown.js
src/MatrixClientPeg.js src/MatrixClientPeg.js
src/Modal.js src/Modal.js
src/Notifier.js src/Notifier.js
src/ObjectUtils.js
src/PasswordReset.js
src/PlatformPeg.js src/PlatformPeg.js
src/Presence.js src/Presence.js
src/ratelimitedfunc.js src/ratelimitedfunc.js
src/Resend.js
src/RichText.js src/RichText.js
src/Roles.js src/Roles.js
src/RoomListSorter.js
src/RoomNotifs.js
src/Rooms.js src/Rooms.js
src/ScalarAuthClient.js src/ScalarAuthClient.js
src/ScalarMessaging.js src/ScalarMessaging.js
src/SdkConfig.js
src/Skinner.js
src/SlashCommands.js
src/stores/LifecycleStore.js
src/TabComplete.js src/TabComplete.js
src/TabCompleteEntries.js src/TabCompleteEntries.js
src/TextForEvent.js src/TextForEvent.js
src/Tinter.js src/Tinter.js
src/UiEffects.js src/UiEffects.js
src/Unread.js src/Unread.js
src/UserActivity.js
src/utils/DecryptFile.js src/utils/DecryptFile.js
src/utils/DMRoomMap.js src/utils/DMRoomMap.js
src/utils/FormattingUtils.js src/utils/FormattingUtils.js

View file

@ -22,8 +22,11 @@ git checkout "$curbranch" || git checkout develop
mkdir node_modules mkdir node_modules
npm install 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 rm -r node_modules/matrix-react-sdk
ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk

View file

@ -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 language: node_js
node_js: node_js:
- node # Latest stable version of nodejs. - node # Latest stable version of nodejs.
addons:
chrome: stable
install: install:
- npm install - npm install
- (cd node_modules/matrix-js-sdk && npm install) - (cd node_modules/matrix-js-sdk && npm install)

View file

@ -116,11 +116,25 @@ module.exports = function (config) {
browsers: [ browsers: [
'Chrome', 'Chrome',
//'PhantomJS', //'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 // Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits // if true, Karma captures browsers, runs the tests and exits
singleRun: true, // singleRun: false,
// Concurrency level // Concurrency level
// how many browser should be started simultaneous // how many browser should be started simultaneous

View file

@ -41,8 +41,8 @@
"lintall": "eslint src/ test/", "lintall": "eslint src/ test/",
"clean": "rimraf lib", "clean": "rimraf lib",
"prepublish": "npm run build && git rev-parse HEAD > git-revision.txt", "prepublish": "npm run build && git rev-parse HEAD > git-revision.txt",
"test": "karma start $KARMAFLAGS --browsers PhantomJS", "test": "karma start $KARMAFLAGS --single-run=true --browsers ChromeHeadless",
"test-multi": "karma start $KARMAFLAGS --single-run=false" "test-multi": "karma start $KARMAFLAGS"
}, },
"dependencies": { "dependencies": {
"babel-runtime": "^6.11.6", "babel-runtime": "^6.11.6",
@ -75,6 +75,7 @@
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",
"text-encoding-utf-8": "^1.0.1", "text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0",
"velocity-vector": "vector-im/velocity#059e3b2", "velocity-vector": "vector-im/velocity#059e3b2",
"whatwg-fetch": "^1.0.0" "whatwg-fetch": "^1.0.0"
}, },
@ -106,12 +107,10 @@
"karma-cli": "^0.1.2", "karma-cli": "^0.1.2",
"karma-junit-reporter": "^0.4.1", "karma-junit-reporter": "^0.4.1",
"karma-mocha": "^0.2.2", "karma-mocha": "^0.2.2",
"karma-phantomjs-launcher": "^1.0.0",
"karma-sourcemap-loader": "^0.3.7", "karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^1.7.0", "karma-webpack": "^1.7.0",
"mocha": "^2.4.5", "mocha": "^2.4.5",
"parallelshell": "^1.2.0", "parallelshell": "^1.2.0",
"phantomjs-prebuilt": "^2.1.7",
"react-addons-test-utils": "^15.4.0", "react-addons-test-utils": "^15.4.0",
"require-json": "0.0.1", "require-json": "0.0.1",
"rimraf": "^2.4.3", "rimraf": "^2.4.3",

View file

@ -1,5 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
const EMOJI_DATA = require('emojione/emoji.json'); const EMOJI_DATA = require('emojione/emoji.json');
const EMOJI_SUPPORTED = Object.keys(require('emojione').emojioneList);
const fs = require('fs'); const fs = require('fs');
const output = Object.keys(EMOJI_DATA).map( const output = Object.keys(EMOJI_DATA).map(
@ -16,7 +17,9 @@ const output = Object.keys(EMOJI_DATA).map(
} }
return newDatum; 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 // Write to a file in src. Changes should be checked into git. This file is copied by
// babel using --copy-files // babel using --copy-files

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var MatrixClientPeg = require("./MatrixClientPeg"); import MatrixClientPeg from './MatrixClientPeg';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
/** /**
@ -44,7 +44,7 @@ class AddThreepid {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, 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'); err.message = _t('This email address is already in use');
} else if (err.httpStatus) { } else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
@ -69,7 +69,7 @@ class AddThreepid {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, 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'); err.message = _t('This phone number is already in use');
} else if (err.httpStatus) { } else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
@ -85,16 +85,15 @@ class AddThreepid {
* the request failed. * the request failed.
*/ */
checkEmailLinkClicked() { checkEmailLinkClicked() {
var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
return MatrixClientPeg.get().addThreePid({ return MatrixClientPeg.get().addThreePid({
sid: this.sessionId, sid: this.sessionId,
client_secret: this.clientSecret, client_secret: this.clientSecret,
id_server: identityServerDomain id_server: identityServerDomain,
}, this.bind).catch(function(err) { }, this.bind).catch(function(err) {
if (err.httpStatus === 401) { if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); 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})`; err.message += ` (Status ${err.httpStatus})`;
} }
throw err; throw err;
@ -104,6 +103,7 @@ class AddThreepid {
/** /**
* Takes a phone number verification code as entered by the user and validates * 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. * 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 * @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 * with a "message" property which contains a human-readable message detailing why
* the request failed. * the request failed.
@ -119,7 +119,7 @@ class AddThreepid {
return MatrixClientPeg.get().addThreePid({ return MatrixClientPeg.get().addThreePid({
sid: this.sessionId, sid: this.sessionId,
client_secret: this.clientSecret, client_secret: this.clientSecret,
id_server: identityServerDomain id_server: identityServerDomain,
}, this.bind); }, this.bind);
}); });
} }

View file

@ -15,18 +15,18 @@ limitations under the License.
*/ */
'use strict'; 'use strict';
var ContentRepo = require("matrix-js-sdk").ContentRepo; import {ContentRepo} from 'matrix-js-sdk';
var MatrixClientPeg = require('./MatrixClientPeg'); import MatrixClientPeg from './MatrixClientPeg';
module.exports = { module.exports = {
avatarUrlForMember: function(member, width, height, resizeMethod) { avatarUrlForMember: function(member, width, height, resizeMethod) {
var url = member.getAvatarUrl( let url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(width * window.devicePixelRatio), Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio),
resizeMethod, resizeMethod,
false, false,
false false,
); );
if (!url) { if (!url) {
// member can be null here currently since on invites, the JS SDK // member can be null here currently since on invites, the JS SDK
@ -38,11 +38,11 @@ module.exports = {
}, },
avatarUrlForUser: function(user, width, height, resizeMethod) { avatarUrlForUser: function(user, width, height, resizeMethod) {
var url = ContentRepo.getHttpUriForMxc( const url = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio), Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio),
resizeMethod resizeMethod,
); );
if (!url || url.length === 0) { if (!url || url.length === 0) {
return null; return null;
@ -51,11 +51,11 @@ module.exports = {
}, },
defaultAvatarUrlForString: function(s) { defaultAvatarUrlForString: function(s) {
var images = ['76cfa6', '50e2c2', 'f4c371']; const images = ['76cfa6', '50e2c2', 'f4c371'];
var total = 0; let total = 0;
for (var i = 0; i < s.length; ++i) { for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i); total += s.charCodeAt(i);
} }
return 'img/' + images[total % images.length] + '.png'; return 'img/' + images[total % images.length] + '.png';
} },
}; };

View file

@ -57,6 +57,7 @@ export default class BasePlatform {
/** /**
* Returns true if the platform supports displaying * Returns true if the platform supports displaying
* notifications, otherwise false. * notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/ */
supportsNotifications(): boolean { supportsNotifications(): boolean {
return false; return false;
@ -65,6 +66,7 @@ export default class BasePlatform {
/** /**
* Returns true if the application currently has permission * Returns true if the application currently has permission
* to display notifications. Otherwise false. * to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/ */
maySendNotifications(): boolean { maySendNotifications(): boolean {
return false; return false;

View file

@ -61,17 +61,18 @@ function twelveHourTime(date) {
module.exports = { module.exports = {
formatDate: function(date, showTwelveHour=false) { formatDate: function(date, showTwelveHour=false) {
var now = new Date(); const now = new Date();
const days = getDaysArray(); const days = getDaysArray();
const months = getMonthsArray(); const months = getMonthsArray();
if (date.toDateString() === now.toDateString()) { if (date.toDateString() === now.toDateString()) {
return this.formatTime(date); 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 // TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s %(time)s', {weekDayName: days[date.getDay()], time: this.formatTime(date, showTwelveHour)}); return _t('%(weekDayName)s %(time)s', {
} weekDayName: days[date.getDay()],
else if (now.getFullYear() === date.getFullYear()) { time: this.formatTime(date, showTwelveHour),
});
} else if (now.getFullYear() === date.getFullYear()) {
// TODO: use standard date localize function provided in counterpart // TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', {
weekDayName: days[date.getDay()], weekDayName: days[date.getDay()],

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require('react'); import sdk from './index';
var sdk = require('./index');
function isMatch(query, name, uid) { function isMatch(query, name, uid) {
query = query.toLowerCase(); query = query.toLowerCase();
@ -33,8 +32,8 @@ function isMatch(query, name, uid) {
} }
// split spaces in name and try matching constituent parts // split spaces in name and try matching constituent parts
var parts = name.split(" "); const parts = name.split(" ");
for (var i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
if (parts[i].indexOf(query) === 0) { if (parts[i].indexOf(query) === 0) {
return true; return true;
} }
@ -67,7 +66,7 @@ class Entity {
class MemberEntity extends Entity { class MemberEntity extends Entity {
getJsx() { getJsx() {
var MemberTile = sdk.getComponent("rooms.MemberTile"); const MemberTile = sdk.getComponent("rooms.MemberTile");
return ( return (
<MemberTile key={this.model.userId} member={this.model} /> <MemberTile key={this.model.userId} member={this.model} />
); );
@ -84,6 +83,7 @@ class UserEntity extends Entity {
super(model); super(model);
this.showInviteButton = Boolean(showInviteButton); this.showInviteButton = Boolean(showInviteButton);
this.inviteFn = inviteFn; this.inviteFn = inviteFn;
this.onClick = this.onClick.bind(this);
} }
onClick() { onClick() {
@ -93,15 +93,15 @@ class UserEntity extends Entity {
} }
getJsx() { getJsx() {
var UserTile = sdk.getComponent("rooms.UserTile"); const UserTile = sdk.getComponent("rooms.UserTile");
return ( return (
<UserTile key={this.model.userId} user={this.model} <UserTile key={this.model.userId} user={this.model}
showInviteButton={this.showInviteButton} onClick={this.onClick.bind(this)} /> showInviteButton={this.showInviteButton} onClick={this.onClick} />
); );
} }
matches(queryString) { 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); return isMatch(queryString, name, this.model.userId);
} }
} }
@ -109,7 +109,7 @@ class UserEntity extends Entity {
module.exports = { module.exports = {
newEntity: function(jsx, matchFn) { newEntity: function(jsx, matchFn) {
var entity = new Entity(); const entity = new Entity();
entity.getJsx = function() { entity.getJsx = function() {
return jsx; return jsx;
}; };
@ -137,5 +137,5 @@ module.exports = {
return users.map(function(u) { return users.map(function(u) {
return new UserEntity(u, showInviteButton, inviteFn); return new UserEntity(u, showInviteButton, inviteFn);
}); });
} },
}; };

View file

@ -419,6 +419,8 @@ export function logout() {
* listen for events while a session is logged in. * listen for events while a session is logged in.
*/ */
function startMatrixClient() { function startMatrixClient() {
console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used // dispatch this before starting the matrix client: it's used
// to add listeners for the 'sync' event so otherwise we'd have // to add listeners for the 'sync' event so otherwise we'd have
// a race condition (and we need to dispatch synchronously for this // a race condition (and we need to dispatch synchronously for this

View file

@ -77,22 +77,26 @@ class MatrixClientPeg {
this._createClient(creds); this._createClient(creds);
} }
start() { async start() {
const opts = utils.deepCopy(this.opts); const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow // the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached"; opts.pendingEventOrdering = "detached";
let promise = this.matrixClient.store.startup(); try {
// log any errors when starting up the database (if one exists) let promise = this.matrixClient.store.startup();
promise.catch((err) => { 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}`); console.error(`Error starting matrixclient store: ${err}`);
}); }
// regardless of errors, start the client. If we did error out, we'll // regardless of errors, start the client. If we did error out, we'll
// just end up doing a full initial /sync. // 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 { getCredentials(): MatrixClientCreds {

View file

@ -23,8 +23,8 @@ limitations under the License.
* { key: $KEY, val: $VALUE, place: "add|del" } * { key: $KEY, val: $VALUE, place: "add|del" }
*/ */
module.exports.getKeyValueArrayDiffs = function(before, after) { module.exports.getKeyValueArrayDiffs = function(before, after) {
var results = []; const results = [];
var delta = {}; const delta = {};
Object.keys(before).forEach(function(beforeKey) { Object.keys(before).forEach(function(beforeKey) {
delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially
delta[beforeKey]--; // keys present in the past have -ve values 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 }); results.push({ place: "del", key: muxedKey, val: beforeVal });
}); });
break; break;
case 0: // A mix of added/removed keys case 0: {// A mix of added/removed keys
// compare old & new vals // compare old & new vals
var itemDelta = {}; const itemDelta = {};
before[muxedKey].forEach(function(beforeVal) { before[muxedKey].forEach(function(beforeVal) {
itemDelta[beforeVal] = itemDelta[beforeVal] || 0; itemDelta[beforeVal] = itemDelta[beforeVal] || 0;
itemDelta[beforeVal]--; itemDelta[beforeVal]--;
@ -68,9 +68,9 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
} }
}); });
break; break;
}
default: default:
console.error("Calculated key delta of " + delta[muxedKey] + console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!");
" - this should never happen!");
break; break;
} }
}); });
@ -79,8 +79,10 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
}; };
/** /**
* Shallow-compare two objects for equality: each key and value must be * Shallow-compare two objects for equality: each key and value must be identical
* 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) { module.exports.shallowEqual = function(objA, objB) {
if (objA === objB) { if (objA === objB) {
@ -92,15 +94,15 @@ module.exports.shallowEqual = function(objA, objB) {
return false; return false;
} }
var keysA = Object.keys(objA); const keysA = Object.keys(objA);
var keysB = Object.keys(objB); const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) { if (keysA.length !== keysB.length) {
return false; return false;
} }
for (var i = 0; i < keysA.length; i++) { for (let i = 0; i < keysA.length; i++) {
var key = keysA[i]; const key = keysA[i];
if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) { if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) {
return false; return false;
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var Matrix = require("matrix-js-sdk"); import * as Matrix from 'matrix-js-sdk';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
/** /**
@ -34,7 +34,7 @@ class PasswordReset {
constructor(homeserverUrl, identityUrl) { constructor(homeserverUrl, identityUrl) {
this.client = Matrix.createClient({ this.client = Matrix.createClient({
baseUrl: homeserverUrl, baseUrl: homeserverUrl,
idBaseUrl: identityUrl idBaseUrl: identityUrl,
}); });
this.clientSecret = this.client.generateClientSecret(); this.clientSecret = this.client.generateClientSecret();
this.identityServerDomain = identityUrl.split("://")[1]; this.identityServerDomain = identityUrl.split("://")[1];
@ -53,7 +53,7 @@ class PasswordReset {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, 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'); err.message = _t('This email address was not found');
} else if (err.httpStatus) { } else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
@ -75,16 +75,15 @@ class PasswordReset {
threepid_creds: { threepid_creds: {
sid: this.sessionId, sid: this.sessionId,
client_secret: this.clientSecret, client_secret: this.clientSecret,
id_server: this.identityServerDomain id_server: this.identityServerDomain,
} },
}, this.password).catch(function(err) { }, this.password).catch(function(err) {
if (err.httpStatus === 401) { if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
} } else if (err.httpStatus === 404) {
else if (err.httpStatus === 404) { err.message =
err.message = _t('Your email address does not appear to be associated with a Matrix ID on this Homeserver.'); _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) {
err.message += ` (Status ${err.httpStatus})`; err.message += ` (Status ${err.httpStatus})`;
} }
throw err; throw err;

View file

@ -14,10 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var MatrixClientPeg = require('./MatrixClientPeg'); import MatrixClientPeg from './MatrixClientPeg';
var dis = require('./dispatcher'); import dis from './dispatcher';
var sdk = require('./index');
var Modal = require('./Modal');
import { EventStatus } from 'matrix-js-sdk'; import { EventStatus } from 'matrix-js-sdk';
module.exports = { module.exports = {
@ -37,12 +35,10 @@ module.exports = {
}, },
resend: function(event) { resend: function(event) {
const room = MatrixClientPeg.get().getRoom(event.getRoomId()); const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent( MatrixClientPeg.get().resendEvent(event, room).done(function(res) {
event, room
).done(function(res) {
dis.dispatch({ dis.dispatch({
action: 'message_sent', action: 'message_sent',
event: event event: event,
}); });
}, function(err) { }, function(err) {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
@ -58,7 +54,7 @@ module.exports = {
dis.dispatch({ dis.dispatch({
action: 'message_send_failed', action: 'message_send_failed',
event: event event: event,
}); });
}); });
}, },
@ -66,7 +62,7 @@ module.exports = {
MatrixClientPeg.get().cancelPendingEvent(event); MatrixClientPeg.get().cancelPendingEvent(event);
dis.dispatch({ dis.dispatch({
action: 'message_send_cancelled', action: 'message_send_cancelled',
event: event event: event,
}); });
}, },
}; };

View file

@ -19,7 +19,7 @@ export function levelRoleMap() {
return { return {
undefined: _t('Default'), undefined: _t('Default'),
0: _t('User'), 0: _t('User'),
50: _t('Moderator'), 50: _t('Moderator'),
100: _t('Admin'), 100: _t('Admin'),
}; };
} }

View file

@ -19,8 +19,7 @@ limitations under the License.
function tsOfNewestEvent(room) { function tsOfNewestEvent(room) {
if (room.timeline.length) { if (room.timeline.length) {
return room.timeline[room.timeline.length - 1].getTs(); return room.timeline[room.timeline.length - 1].getTs();
} } else {
else {
return Number.MAX_SAFE_INTEGER; return Number.MAX_SAFE_INTEGER;
} }
} }
@ -32,5 +31,5 @@ function mostRecentActivityFirst(roomList) {
} }
module.exports = { module.exports = {
mostRecentActivityFirst: mostRecentActivityFirst mostRecentActivityFirst,
}; };

View file

@ -52,7 +52,7 @@ export function getRoomNotifsState(roomId) {
} }
export function setRoomNotifsState(roomId, newState) { export function setRoomNotifsState(roomId, newState) {
if (newState == MUTE) { if (newState === MUTE) {
return setRoomNotifsStateMuted(roomId); return setRoomNotifsStateMuted(roomId);
} else { } else {
return setRoomNotifsStateUnmuted(roomId, newState); return setRoomNotifsStateUnmuted(roomId, newState);
@ -80,11 +80,11 @@ function setRoomNotifsStateMuted(roomId) {
kind: 'event_match', kind: 'event_match',
key: 'room_id', key: 'room_id',
pattern: roomId, pattern: roomId,
} },
], ],
actions: [ actions: [
'dont_notify', 'dont_notify',
] ],
})); }));
return q.all(promises); return q.all(promises);
@ -99,16 +99,16 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id)); promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id));
} }
if (newState == 'all_messages') { if (newState === 'all_messages') {
const roomRule = cli.getRoomPushRule('global', roomId); const roomRule = cli.getRoomPushRule('global', roomId);
if (roomRule) { if (roomRule) {
promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); 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, { promises.push(cli.addPushRule('global', 'room', roomId, {
actions: [ actions: [
'dont_notify', 'dont_notify',
] ],
})); }));
// https://matrix.org/jira/browse/SPEC-400 // https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
@ -119,8 +119,8 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
{ {
set_tweak: 'sound', set_tweak: 'sound',
value: 'default', value: 'default',
} },
] ],
})); }));
// https://matrix.org/jira/browse/SPEC-400 // https://matrix.org/jira/browse/SPEC-400
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
@ -145,20 +145,10 @@ function isRuleForRoom(roomId, rule) {
return false; return false;
} }
const cond = rule.conditions[0]; const cond = rule.conditions[0];
if ( return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId);
cond.kind == 'event_match' &&
cond.key == 'room_id' &&
cond.pattern == roomId
) {
return true;
}
return false;
} }
function isMuteRule(rule) { function isMuteRule(rule) {
return ( return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify');
rule.actions.length == 1 &&
rule.actions[0] == 'dont_notify'
);
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var DEFAULTS = { const DEFAULTS = {
// URL to a page we show in an iframe to configure integrations // URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/", integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server // Base URL to the REST interface of the integrations server
@ -30,8 +30,8 @@ class SdkConfig {
} }
static put(cfg) { static put(cfg) {
var defaultKeys = Object.keys(DEFAULTS); const defaultKeys = Object.keys(DEFAULTS);
for (var i = 0; i < defaultKeys.length; ++i) { for (let i = 0; i < defaultKeys.length; ++i) {
if (cfg[defaultKeys[i]] === undefined) { if (cfg[defaultKeys[i]] === undefined) {
cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]]; cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]];
} }

View file

@ -51,19 +51,18 @@ class Skinner {
if (this.components !== null) { if (this.components !== null) {
throw new Error( throw new Error(
"Attempted to load a skin while a skin is already loaded"+ "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 = {}; this.components = {};
var compKeys = Object.keys(skinObject.components); const compKeys = Object.keys(skinObject.components);
for (var i = 0; i < compKeys.length; ++i) { for (let i = 0; i < compKeys.length; ++i) {
var comp = skinObject.components[compKeys[i]]; const comp = skinObject.components[compKeys[i]];
this.addComponent(compKeys[i], comp); this.addComponent(compKeys[i], comp);
} }
} }
addComponent(name, comp) { addComponent(name, comp) {
var slot = name; let slot = name;
if (comp.replaces !== undefined) { if (comp.replaces !== undefined) {
if (comp.replaces.indexOf('.') > -1) { if (comp.replaces.indexOf('.') > -1) {
slot = comp.replaces; slot = comp.replaces;

View file

@ -186,7 +186,7 @@ const commands = {
if (targetRoomId) { break; } if (targetRoomId) { break; }
} }
if (!targetRoomId) { 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' + _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' + ' %(deviceId)s is "%(fprint)s" which does not match the provided key' +
' "%(fingerprint)s". This could mean your communications are being intercepted!', ' "%(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}));
);
} }
} }
} }

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var dis = require("./dispatcher"); import dis from './dispatcher';
var MIN_DISPATCH_INTERVAL_MS = 500; const MIN_DISPATCH_INTERVAL_MS = 500;
var CURRENTLY_ACTIVE_THRESHOLD_MS = 2000; const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000;
/** /**
* This class watches for user activity (moving the mouse or pressing a key) * 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 * Return true if there has been user activity very recently
* (ie. within a few seconds) * (ie. within a few seconds)
* @returns {boolean} true if user is currently/very recently active
*/ */
userCurrentlyActive() { userCurrentlyActive() {
return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS; return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS;
} }
_onUserActivity(event) { _onUserActivity(event) {
if (event.screenX && event.type == "mousemove") { if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
event.screenY === this.lastScreenY)
{
// mouse hasn't actually moved // mouse hasn't actually moved
return; return;
} }
@ -79,28 +78,24 @@ class UserActivity {
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) { if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) {
this.lastDispatchAtTs = this.lastActivityAtTs; this.lastDispatchAtTs = this.lastActivityAtTs;
dis.dispatch({ dis.dispatch({
action: 'user_activity' action: 'user_activity',
}); });
if (!this.activityEndTimer) { if (!this.activityEndTimer) {
this.activityEndTimer = setTimeout( this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS);
this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS
);
} }
} }
} }
_onActivityEndTimer() { _onActivityEndTimer() {
var now = new Date().getTime(); const now = new Date().getTime();
var targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS; const targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS;
if (now >= targetTime) { if (now >= targetTime) {
dis.dispatch({ dis.dispatch({
action: 'user_activity_end' action: 'user_activity_end',
}); });
this.activityEndTimer = undefined; this.activityEndTimer = undefined;
} else { } else {
this.activityEndTimer = setTimeout( this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), targetTime - now);
this._onActivityEndTimer.bind(this), targetTime - now
);
} }
} }
} }

View file

@ -21,6 +21,7 @@ import AutocompleteProvider from './AutocompleteProvider';
import FuzzyMatcher from './FuzzyMatcher'; import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components'; 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 // 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 = [ const COMMANDS = [
{ {
@ -28,11 +29,6 @@ const COMMANDS = [
args: '<message>', args: '<message>',
description: 'Displays action', description: 'Displays action',
}, },
{
command: '/part',
args: '[#alias:domain]',
description: 'Leave room',
},
{ {
command: '/ban', command: '/ban',
args: '<user-id> [reason]', args: '<user-id> [reason]',
@ -43,6 +39,11 @@ const COMMANDS = [
args: '<user-id>', args: '<user-id>',
description: 'Unbans user with given id', description: 'Unbans user with given id',
}, },
{
command: '/op',
args: '<user-id> [<power-level>]',
description: 'Define the power level of a user',
},
{ {
command: '/deop', command: '/deop',
args: '<user-id>', args: '<user-id>',
@ -58,6 +59,16 @@ const COMMANDS = [
args: '<room-alias>', args: '<room-alias>',
description: 'Joins room with given alias', description: 'Joins room with given alias',
}, },
{
command: '/part',
args: '[<room-alias>]',
description: 'Leave room',
},
{
command: '/topic',
args: '<topic>',
description: 'Sets the room topic',
},
{ {
command: '/kick', command: '/kick',
args: '<user-id> [reason]', args: '<user-id> [reason]',
@ -74,10 +85,16 @@ const COMMANDS = [
description: 'Searches DuckDuckGo for results', description: 'Searches DuckDuckGo for results',
}, },
{ {
command: '/op', command: '/tint',
args: '<userId> [<power level>]', args: '<color1> [<color2>]',
description: 'Define the power level of a user', description: 'Changes colour scheme of current room',
}, },
{
command: '/verify',
args: '<user-id> <device-id> <device-signing-key>',
description: 'Verifies a user, device, and pubkey tuple',
},
// Omitting `/markdown` as it only seems to apply to OldComposer
]; ];
const COMMAND_RE = /(^\/\w*)/g; const COMMAND_RE = /(^\/\w*)/g;

View file

@ -101,7 +101,7 @@ export default class EmojiProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{completions} {completions}
</div>; </div>;
} }

View file

@ -69,6 +69,12 @@ export default class QueryMatcher {
if (this.options.shouldMatchWordsOnly === undefined) { if (this.options.shouldMatchWordsOnly === undefined) {
this.options.shouldMatchWordsOnly = true; 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<Object>) { setObjects(objects: Array<Object>) {
@ -80,13 +86,27 @@ export default class QueryMatcher {
if (this.options.shouldMatchWordsOnly) { if (this.options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, ''); 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(); let resultKey = key.toLowerCase();
if (this.options.shouldMatchWordsOnly) { if (this.options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, ''); resultKey = resultKey.replace(/[^\w]/g, '');
} }
return resultKey.indexOf(query) !== -1 ? this.keyMap.objectMap[key] : []; const index = resultKey.indexOf(query);
}), (candidate) => this.keyMap.priorityMap.get(candidate))); if (index !== -1 && (!this.options.shouldMatchPrefix || index === 0)) {
return results; 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];
})));
} }
} }

View file

@ -78,7 +78,7 @@ export default class RoomProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{completions} {completions}
</div>; </div>;
} }

View file

@ -37,10 +37,11 @@ export default class UserProvider extends AutocompleteProvider {
constructor() { constructor() {
super(USER_REGEX, { super(USER_REGEX, {
keys: ['name', 'userId'], keys: ['name'],
}); });
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name'],
shouldMatchPrefix: true,
}); });
} }
@ -50,7 +51,7 @@ export default class UserProvider extends AutocompleteProvider {
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection, force); let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) { 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 displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let completion = displayName; let completion = displayName;
if (range.start === 0) { if (range.start === 0) {
@ -68,7 +69,7 @@ export default class UserProvider extends AutocompleteProvider {
), ),
range, range,
}; };
}).slice(0, 4); });
} }
return completions; return completions;
} }
@ -90,7 +91,9 @@ export default class UserProvider extends AutocompleteProvider {
if (member.userId !== currentUserId) return true; 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); this.matcher.setObjects(this.users);
} }
@ -98,9 +101,10 @@ export default class UserProvider extends AutocompleteProvider {
onUserSpoke(user: RoomMember) { onUserSpoke(user: RoomMember) {
if(user.userId === MatrixClientPeg.get().credentials.userId) return; if(user.userId === MatrixClientPeg.get().credentials.userId) return;
// Probably unsafe to compare by reference here? this.users = this.users.splice(
_pull(this.users, user); this.users.findIndex((user2) => user2.userId === user.userId), 1);
this.users.splice(0, 0, user); this.users = [user, ...this.users];
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }
@ -112,7 +116,7 @@ export default class UserProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{completions} {completions}
</div>; </div>;
} }

View file

@ -18,8 +18,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import ScalarAuthClient from '../../../ScalarAuthClient';
import SdkConfig from '../../../SdkConfig';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import url from 'url';
export default React.createClass({ export default React.createClass({
displayName: 'AppTile', 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() { _onEditClick: function() {
console.log("Edit widget %s", this.props.id); console.log("Edit widget %s", this.props.id);
}, },
@ -72,6 +121,18 @@ export default React.createClass({
}, },
render: function() { render: function() {
let appTileBody;
if (this.state.loading) {
appTileBody = (
<div> Loading... </div>
);
} else {
appTileBody = (
<div className="mx_AppTileBody">
<iframe ref="appFrame" src={this.state.widgetUrl} allowFullScreen="true"></iframe>
</div>
);
}
return ( return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}> <div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
<div className="mx_AppTileMenuBar"> <div className="mx_AppTileMenuBar">
@ -93,9 +154,7 @@ export default React.createClass({
/> />
</span> </span>
</div> </div>
<div className="mx_AppTileBody"> {appTileBody}
<iframe ref="appFrame" src={this.props.url} allowFullScreen="true"></iframe>
</div>
</div> </div>
); );
}, },

View file

@ -143,9 +143,15 @@ module.exports = React.createClass({
if (this.props.showUrlPreview && !this.state.links.length) { if (this.props.showUrlPreview && !this.state.links.length) {
var links = this.findLinks(this.refs.content.children); var links = this.findLinks(this.refs.content.children);
if (links.length) { if (links.length) {
this.setState({ links: links.map((link)=>{ // de-dup the links (but preserve ordering)
return link.getAttribute("href"); 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 // lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) { if (global.localStorage) {
@ -158,12 +164,13 @@ module.exports = React.createClass({
findLinks: function(nodes) { findLinks: function(nodes) {
var links = []; var links = [];
for (var i = 0; i < nodes.length; i++) { for (var i = 0; i < nodes.length; i++) {
var node = nodes[i]; var node = nodes[i];
if (node.tagName === "A" && node.getAttribute("href")) if (node.tagName === "A" && node.getAttribute("href"))
{ {
if (this.isLinkPreviewable(node)) { if (this.isLinkPreviewable(node)) {
links.push(node); links.push(node.getAttribute("href"));
} }
} }
else if (node.tagName === "PRE" || node.tagName === "CODE" || else if (node.tagName === "PRE" || node.tagName === "CODE" ||

View file

@ -68,7 +68,7 @@ export default class Autocomplete extends React.Component {
let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200); let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200);
// Don't debounce if we are already showing completions // Don't debounce if we are already showing completions
if (this.state.completions.length > 0) { if (this.state.completions.length > 0 || this.state.forceComplete) {
autocompleteDelay = 0; autocompleteDelay = 0;
} }
@ -177,7 +177,7 @@ export default class Autocomplete extends React.Component {
hide: false, hide: false,
}, () => { }, () => {
this.complete(this.props.query, this.props.selection).then(() => { this.complete(this.props.query, this.props.selection).then(() => {
done.resolve(); done.resolve(this.countCompletions());
}); });
}); });
return done.promise; return done.promise;

View file

@ -43,6 +43,8 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager'; import ComposerHistoryManager from '../../../ComposerHistoryManager';
import {onSendMessageFailed} from './MessageComposerInputOld'; import {onSendMessageFailed} from './MessageComposerInputOld';
import MessageComposerStore from '../../../stores/MessageComposerStore';
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
const ZWS_CODE = 8203; const ZWS_CODE = 8203;
@ -130,7 +132,10 @@ export default class MessageComposerInput extends React.Component {
isRichtextEnabled, isRichtextEnabled,
// the currently displayed editor state (note: this is always what is modified on input) // 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 // the original editor state, before we started tabbing through completions
originalEditorState: null, 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 // the virtual state "above" the history stack, the message currently being composed that
// we want to persist whilst browsing history // we want to persist whilst browsing history
currentlyComposedEditorState: null, currentlyComposedEditorState: null,
};
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled // whether there were any completions
/* eslint react/no-direct-mutation-state:0 */ someCompletions: null,
this.state.editorState = this.createEditorState(); };
this.client = MatrixClientPeg.get(); this.client = MatrixClientPeg.get();
} }
@ -336,6 +340,14 @@ export default class MessageComposerInput extends React.Component {
this.onFinishedTyping(); 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')) { if (!state.hasOwnProperty('originalEditorState')) {
state.originalEditorState = null; state.originalEditorState = null;
} }
@ -632,6 +644,10 @@ export default class MessageComposerInput extends React.Component {
}; };
onVerticalArrow = (e, up) => { onVerticalArrow = (e, up) => {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
// Select history only if we are not currently auto-completing // Select history only if we are not currently auto-completing
if (this.autocomplete.state.completionList.length === 0) { if (this.autocomplete.state.completionList.length === 0) {
// Don't go back in history if we're in the middle of a multi-line message // Don't go back in history if we're in the middle of a multi-line message
@ -640,17 +656,16 @@ export default class MessageComposerInput extends React.Component {
const firstBlock = this.state.editorState.getCurrentContent().getFirstBlock(); const firstBlock = this.state.editorState.getCurrentContent().getFirstBlock();
const lastBlock = this.state.editorState.getCurrentContent().getLastBlock(); const lastBlock = this.state.editorState.getCurrentContent().getLastBlock();
const selectionOffset = selection.getAnchorOffset();
let canMoveUp = false; let canMoveUp = false;
let canMoveDown = false; let canMoveDown = false;
if (blockKey === firstBlock.getKey()) { if (blockKey === firstBlock.getKey()) {
const textBeforeCursor = firstBlock.getText().slice(0, selectionOffset); canMoveUp = selection.getStartOffset() === selection.getEndOffset() &&
canMoveUp = textBeforeCursor.indexOf('\n') === -1; selection.getStartOffset() === 0;
} }
if (blockKey === lastBlock.getKey()) { if (blockKey === lastBlock.getKey()) {
const textAfterCursor = lastBlock.getText().slice(selectionOffset); canMoveDown = selection.getStartOffset() === selection.getEndOffset() &&
canMoveDown = textAfterCursor.indexOf('\n') === -1; selection.getStartOffset() === lastBlock.getText().length;
} }
if ((up && !canMoveUp) || (!up && !canMoveDown)) return; if ((up && !canMoveUp) || (!up && !canMoveDown)) return;
@ -707,10 +722,16 @@ export default class MessageComposerInput extends React.Component {
}; };
onTab = async (e) => { onTab = async (e) => {
this.setState({
someCompletions: null,
});
e.preventDefault(); e.preventDefault();
if (this.autocomplete.state.completionList.length === 0) { if (this.autocomplete.state.completionList.length === 0) {
// Force completions to show for the text currently entered // 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" // Select the first item by moving "down"
await this.moveAutocompleteSelection(false); await this.moveAutocompleteSelection(false);
} else { } else {
@ -831,6 +852,7 @@ export default class MessageComposerInput extends React.Component {
const className = classNames('mx_MessageComposer_input', { const className = classNames('mx_MessageComposer_input', {
mx_MessageComposer_input_empty: hidePlaceholder, mx_MessageComposer_input_empty: hidePlaceholder,
mx_MessageComposer_input_error: this.state.someCompletions === false,
}); });
const content = activeEditorState.getCurrentContent(); const content = activeEditorState.getCurrentContent();

View file

@ -16,18 +16,18 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
var classNames = require('classnames'); import classNames from 'classnames';
var sdk = require('../../../index'); import sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
var MatrixClientPeg = require('../../../MatrixClientPeg'); import MatrixClientPeg from '../../../MatrixClientPeg';
var Modal = require("../../../Modal"); import Modal from "../../../Modal";
var dis = require("../../../dispatcher"); import dis from "../../../dispatcher";
var rate_limited_func = require('../../../ratelimitedfunc'); import RateLimitedFunc from '../../../ratelimitedfunc';
var linkify = require('linkifyjs'); import * as linkify from 'linkifyjs';
var linkifyElement = require('linkifyjs/element'); import linkifyElement from 'linkifyjs/element';
var linkifyMatrix = require('../../../linkify-matrix'); import linkifyMatrix from '../../../linkify-matrix';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import {CancelButton} from './SimpleRoomHeader'; import {CancelButton} from './SimpleRoomHeader';
@ -58,7 +58,7 @@ module.exports = React.createClass({
}, },
componentDidMount: function() { componentDidMount: function() {
var cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents); cli.on("RoomState.events", this._onRoomStateEvents);
// When a room name occurs, RoomState.events is fired *before* // When a room name occurs, RoomState.events is fired *before*
@ -79,14 +79,14 @@ module.exports = React.createClass({
if (this.props.room) { if (this.props.room) {
this.props.room.removeListener("Room.name", this._onRoomNameChange); this.props.room.removeListener("Room.name", this._onRoomNameChange);
} }
var cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents); cli.removeListener("RoomState.events", this._onRoomStateEvents);
} }
}, },
_onRoomStateEvents: function(event, state) { _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; return;
} }
@ -94,7 +94,8 @@ module.exports = React.createClass({
this._rateLimitedUpdate(); this._rateLimitedUpdate();
}, },
_rateLimitedUpdate: new rate_limited_func(function() { _rateLimitedUpdate: new RateLimitedFunc(function() {
/* eslint-disable babel/no-invalid-this */
this.forceUpdate(); this.forceUpdate();
}, 500), }, 500),
@ -109,15 +110,14 @@ module.exports = React.createClass({
}, },
onAvatarSelected: function(ev) { onAvatarSelected: function(ev) {
var self = this; const changeAvatar = this.refs.changeAvatar;
var changeAvatar = this.refs.changeAvatar;
if (!changeAvatar) { if (!changeAvatar) {
console.error("No ChangeAvatar found to upload image to!"); console.error("No ChangeAvatar found to upload image to!");
return; return;
} }
changeAvatar.onFileSelected(ev).catch(function(err) { changeAvatar.onFileSelected(ev).catch(function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || ""); const errMsg = (typeof err === "string") ? err : (err.error || "");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set avatar: " + errMsg); console.error("Failed to set avatar: " + errMsg);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: _t("Error"), title: _t("Error"),
@ -133,10 +133,10 @@ module.exports = React.createClass({
/** /**
* After editing the settings, get the new name for the room * 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() { getEditedName: function() {
var newName; let newName;
if (this.refs.nameEditor) { if (this.refs.nameEditor) {
newName = this.refs.nameEditor.getRoomName(); newName = this.refs.nameEditor.getRoomName();
} }
@ -146,10 +146,10 @@ module.exports = React.createClass({
/** /**
* After editing the settings, get the new topic for the room * 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() { getEditedTopic: function() {
var newTopic; let newTopic;
if (this.refs.topicEditor) { if (this.refs.topicEditor) {
newTopic = this.refs.topicEditor.getTopic(); newTopic = this.refs.topicEditor.getTopic();
} }
@ -157,38 +157,31 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
var ChangeAvatar = sdk.getComponent("settings.ChangeAvatar"); const ChangeAvatar = sdk.getComponent("settings.ChangeAvatar");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
var header; let name = null;
var name = null; let searchStatus = null;
var searchStatus = null; let topicElement = null;
var topic_el = null; let cancelButton = null;
var cancel_button = null; let spinner = null;
var spinner = null; let saveButton = null;
var save_button = null; let settingsButton = null;
var settings_button = null;
let canSetRoomName;
let canSetRoomAvatar;
let canSetRoomTopic;
if (this.props.editing) { if (this.props.editing) {
// calculate permissions. XXX: this should be done on mount or something // 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( canSetRoomName = this.props.room.currentState.maySendStateEvent('m.room.name', userId);
'm.room.name', user_id canSetRoomAvatar = this.props.room.currentState.maySendStateEvent('m.room.avatar', userId);
); canSetRoomTopic = this.props.room.currentState.maySendStateEvent('m.room.topic', userId);
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
);
save_button = ( saveButton = (
<AccessibleButton className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}> <AccessibleButton className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>
{_t("Save")} {_t("Save")}
</AccessibleButton> </AccessibleButton>
@ -196,39 +189,41 @@ module.exports = React.createClass({
} }
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
cancel_button = <CancelButton onClick={this.props.onCancelClick}/>; cancelButton = <CancelButton onClick={this.props.onCancelClick}/>;
} }
if (this.props.saving) { if (this.props.saving) {
var Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
spinner = <div className="mx_RoomHeader_spinner"><Spinner/></div>; spinner = <div className="mx_RoomHeader_spinner"><Spinner/></div>;
} }
if (can_set_room_name) { if (canSetRoomName) {
var RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor"); const RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor");
name = <RoomNameEditor ref="nameEditor" room={this.props.room} />; name = <RoomNameEditor ref="nameEditor" room={this.props.room} />;
} } else {
else {
var searchStatus;
// don't display the search count until the search completes and // don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount. // gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo && this.props.searchInfo.searchCount !== undefined && this.props.searchInfo.searchCount !== null) { if (this.props.searchInfo &&
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;{ _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }</div>; this.props.searchInfo.searchCount !== undefined &&
this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;
{ _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }
</div>;
} }
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'... // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
var settingsHint = false; let settingsHint = false;
var members = this.props.room ? this.props.room.getJoinedMembers() : undefined; const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) { if (members) {
if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
var name = this.props.room.currentState.getStateEvents('m.room.name', ''); const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', '');
if (!name || !name.getContent().name) { if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true; settingsHint = true;
} }
} }
} }
var roomName = _t("Join Room"); let roomName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) { if (this.props.oobData && this.props.oobData.name) {
roomName = this.props.oobData.name; roomName = this.props.oobData.name;
} else if (this.props.room) { } else if (this.props.room) {
@ -243,24 +238,25 @@ module.exports = React.createClass({
</div>; </div>;
} }
if (can_set_room_topic) { if (canSetRoomTopic) {
var RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor"); const RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor");
topic_el = <RoomTopicEditor ref="topicEditor" room={this.props.room} />; topicElement = <RoomTopicEditor ref="topicEditor" room={this.props.room} />;
} else { } else {
var topic; let topic;
if (this.props.room) { 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) { if (ev) {
topic = ev.getContent().topic; topic = ev.getContent().topic;
} }
} }
if (topic) { if (topic) {
topic_el = <EmojiText dir="auto" element="div" className="mx_RoomHeader_topic" ref="topic" title={topic}>{ topic }</EmojiText>; topicElement =
<div className="mx_RoomHeader_topic" ref="topic" title={ topic } dir="auto">{ topic }</div>;
} }
} }
var roomAvatar = null; let roomAvatar = null;
if (can_set_room_avatar) { if (canSetRoomAvatar) {
roomAvatar = ( roomAvatar = (
<div className="mx_RoomHeader_avatarPicker"> <div className="mx_RoomHeader_avatarPicker">
<div onClick={ this.onAvatarPickerClick }> <div onClick={ this.onAvatarPickerClick }>
@ -276,8 +272,7 @@ module.exports = React.createClass({
</div> </div>
</div> </div>
); );
} } 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 = ( roomAvatar = (
<div onClick={this.props.onSettingsClick}> <div onClick={this.props.onSettingsClick}>
<RoomAvatar room={this.props.room} width={48} height={48} oobData={this.props.oobData} /> <RoomAvatar room={this.props.room} width={48} height={48} oobData={this.props.oobData} />
@ -285,9 +280,8 @@ module.exports = React.createClass({
); );
} }
var settings_button;
if (this.props.onSettingsClick) { if (this.props.onSettingsClick) {
settings_button = settingsButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title={_t("Settings")}> <AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title={_t("Settings")}>
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/> <TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/>
</AccessibleButton>; </AccessibleButton>;
@ -301,61 +295,58 @@ module.exports = React.createClass({
// </div>; // </div>;
// } // }
var forget_button; let forgetButton;
if (this.props.onForgetClick) { if (this.props.onForgetClick) {
forget_button = forgetButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onForgetClick} title={ _t("Forget room") }> <AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onForgetClick} title={ _t("Forget room") }>
<TintableSvg src="img/leave.svg" width="26" height="20"/> <TintableSvg src="img/leave.svg" width="26" height="20"/>
</AccessibleButton>; </AccessibleButton>;
} }
let search_button; let searchButton;
if (this.props.onSearchClick && this.props.inRoom) { if (this.props.onSearchClick && this.props.inRoom) {
search_button = searchButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title={ _t("Search") }> <AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title={ _t("Search") }>
<TintableSvg src="img/icons-search.svg" width="35" height="35"/> <TintableSvg src="img/icons-search.svg" width="35" height="35"/>
</AccessibleButton>; </AccessibleButton>;
} }
var rightPanel_buttons; let rightPanelButtons;
if (this.props.collapsedRhs) { if (this.props.collapsedRhs) {
rightPanel_buttons = rightPanelButtons =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title={ _t('Show panel') }> <AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title={ _t('Show panel') }>
<TintableSvg src="img/maximise.svg" width="10" height="16"/> <TintableSvg src="img/maximise.svg" width="10" height="16"/>
</AccessibleButton>; </AccessibleButton>;
} }
var right_row; let rightRow;
if (!this.props.editing) { if (!this.props.editing) {
right_row = rightRow =
<div className="mx_RoomHeader_rightRow"> <div className="mx_RoomHeader_rightRow">
{ settings_button } { settingsButton }
{ forget_button } { forgetButton }
{ search_button } { searchButton }
{ rightPanel_buttons } { rightPanelButtons }
</div>; </div>;
} }
header =
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_leftRow">
<div className="mx_RoomHeader_avatar">
{ roomAvatar }
</div>
<div className="mx_RoomHeader_info">
{ name }
{ topic_el }
</div>
</div>
{spinner}
{save_button}
{cancel_button}
{right_row}
</div>;
return ( return (
<div className={ "mx_RoomHeader " + (this.props.editing ? "mx_RoomHeader_editing" : "") }> <div className={ "mx_RoomHeader " + (this.props.editing ? "mx_RoomHeader_editing" : "") }>
{ header } <div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_leftRow">
<div className="mx_RoomHeader_avatar">
{ roomAvatar }
</div>
<div className="mx_RoomHeader_info">
{ name }
{ topicElement }
</div>
</div>
{spinner}
{saveButton}
{cancelButton}
{rightRow}
</div>
</div> </div>
); );
}, },

View file

@ -39,6 +39,7 @@ function parseIntWithDefault(val, def) {
const BannedUser = React.createClass({ const BannedUser = React.createClass({
propTypes: { propTypes: {
canUnban: React.PropTypes.bool,
member: React.PropTypes.object.isRequired, // js-sdk RoomMember member: React.PropTypes.object.isRequired, // js-sdk RoomMember
reason: React.PropTypes.string, reason: React.PropTypes.string,
}, },
@ -67,13 +68,17 @@ const BannedUser = React.createClass({
}, },
render: function() { render: function() {
let unbanButton;
if (this.props.canUnban) {
unbanButton = <AccessibleButton className="mx_RoomSettings_unbanButton" onClick={this._onUnbanClick}>
{ _t('Unban') }
</AccessibleButton>;
}
return ( return (
<li> <li>
<AccessibleButton className="mx_RoomSettings_unbanButton" { unbanButton }
onClick={this._onUnbanClick}
>
{ _t('Unban') }
</AccessibleButton>
<strong>{this.props.member.name}</strong> {this.props.member.userId} <strong>{this.props.member.name}</strong> {this.props.member.userId}
{this.props.reason ? " " +_t('Reason') + ": " + this.props.reason : ""} {this.props.reason ? " " +_t('Reason') + ": " + this.props.reason : ""}
</li> </li>
@ -667,6 +672,7 @@ module.exports = React.createClass({
const banned = this.props.room.getMembersWithMembership("ban"); const banned = this.props.room.getMembersWithMembership("ban");
let bannedUsersSection; let bannedUsersSection;
if (banned.length) { if (banned.length) {
const canBanUsers = current_user_level >= ban_level;
bannedUsersSection = bannedUsersSection =
<div> <div>
<h3>{ _t('Banned users') }</h3> <h3>{ _t('Banned users') }</h3>
@ -674,7 +680,7 @@ module.exports = React.createClass({
{banned.map(function(member) { {banned.map(function(member) {
const banEvent = member.events.member.getContent(); const banEvent = member.events.member.getContent();
return ( return (
<BannedUser key={member.userId} member={member} reason={banEvent.reason} /> <BannedUser key={member.userId} canUnban={canBanUsers} member={member} reason={banEvent.reason} />
); );
})} })}
</ul> </ul>

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require("react"); import React from 'react';
var dis = require("../../../dispatcher"); import dis from '../../../dispatcher';
var CallHandler = require("../../../CallHandler"); import CallHandler from '../../../CallHandler';
var sdk = require('../../../index'); import sdk from '../../../index';
var MatrixClientPeg = require("../../../MatrixClientPeg"); import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
module.exports = React.createClass({ module.exports = React.createClass({
@ -73,10 +73,10 @@ module.exports = React.createClass({
}, },
showCall: function() { showCall: function() {
var call; let call;
if (this.props.room) { if (this.props.room) {
var roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
call = CallHandler.getCallForRoom(roomId) || call = CallHandler.getCallForRoom(roomId) ||
(this.props.ConferenceHandler ? (this.props.ConferenceHandler ?
this.props.ConferenceHandler.getConferenceCallForRoom(roomId) : this.props.ConferenceHandler.getConferenceCallForRoom(roomId) :
@ -86,9 +86,7 @@ module.exports = React.createClass({
if (this.call) { if (this.call) {
this.setState({ call: call }); this.setState({ call: call });
} }
} else {
}
else {
call = CallHandler.getAnyActiveCall(); call = CallHandler.getAnyActiveCall();
this.setState({ call: call }); this.setState({ call: call });
} }
@ -109,8 +107,7 @@ module.exports = React.createClass({
call.confUserId ? "none" : "block" call.confUserId ? "none" : "block"
); );
this.getVideoView().getRemoteVideoElement().style.display = "block"; this.getVideoView().getRemoteVideoElement().style.display = "block";
} } else {
else {
this.getVideoView().getLocalVideoElement().style.display = "none"; this.getVideoView().getLocalVideoElement().style.display = "none";
this.getVideoView().getRemoteVideoElement().style.display = "none"; this.getVideoView().getRemoteVideoElement().style.display = "none";
dis.dispatch({action: 'video_fullscreen', fullscreen: false}); dis.dispatch({action: 'video_fullscreen', fullscreen: false});
@ -126,11 +123,11 @@ module.exports = React.createClass({
}, },
render: function() { 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) { 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 = ( voice = (
<div className="mx_CallView_voice" onClick={ this.props.onClick }> <div className="mx_CallView_voice" onClick={ this.props.onClick }>
{_t("Active call (%(roomName)s)", {roomName: callRoom.name})} {_t("Active call (%(roomName)s)", {roomName: callRoom.name})}
@ -147,6 +144,6 @@ module.exports = React.createClass({
{ voice } { voice }
</div> </div>
); );
} },
}); });

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require('react'); import React from 'react';
var MatrixClientPeg = require('../../../MatrixClientPeg'); import MatrixClientPeg from '../../../MatrixClientPeg';
var dis = require("../../../dispatcher"); import dis from '../../../dispatcher';
var CallHandler = require("../../../CallHandler");
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
module.exports = React.createClass({ module.exports = React.createClass({
@ -29,34 +28,32 @@ module.exports = React.createClass({
onAnswerClick: function() { onAnswerClick: function() {
dis.dispatch({ dis.dispatch({
action: 'answer', action: 'answer',
room_id: this.props.incomingCall.roomId room_id: this.props.incomingCall.roomId,
}); });
}, },
onRejectClick: function() { onRejectClick: function() {
dis.dispatch({ dis.dispatch({
action: 'hangup', action: 'hangup',
room_id: this.props.incomingCall.roomId room_id: this.props.incomingCall.roomId,
}); });
}, },
render: function() { render: function() {
var room = null; let room = null;
if (this.props.incomingCall) { if (this.props.incomingCall) {
room = MatrixClientPeg.get().getRoom(this.props.incomingCall.roomId); 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; let incomingCallText = null;
if (this.props.incomingCall) { if (this.props.incomingCall) {
if (this.props.incomingCall.type === "voice") { if (this.props.incomingCall.type === "voice") {
incomingCallText = _t("Incoming voice call from %(name)s", {name: caller}); 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}); incomingCallText = _t("Incoming video call from %(name)s", {name: caller});
} } else {
else {
incomingCallText = _t("Incoming call from %(name)s", {name: caller}); incomingCallText = _t("Incoming call from %(name)s", {name: caller});
} }
} }
@ -81,6 +78,6 @@ module.exports = React.createClass({
</div> </div>
</div> </div>
); );
} },
}); });

View file

@ -16,7 +16,7 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'VideoFeed', displayName: 'VideoFeed',

View file

@ -16,11 +16,11 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
var ReactDOM = require('react-dom'); import ReactDOM from 'react-dom';
var sdk = require('../../../index'); import sdk from '../../../index';
var dis = require('../../../dispatcher'); import dis from '../../../dispatcher';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'VideoView', displayName: 'VideoView',
@ -53,9 +53,10 @@ module.exports = React.createClass({
// this needs to be somewhere at the top of the DOM which // this needs to be somewhere at the top of the DOM which
// always exists to avoid audio interruptions. // always exists to avoid audio interruptions.
// Might as well just use DOM. // Might as well just use DOM.
var remoteAudioElement = document.getElementById("remoteAudio"); const remoteAudioElement = document.getElementById("remoteAudio");
if (!remoteAudioElement) { if (!remoteAudioElement) {
console.error("Failed to find remoteAudio element - cannot play audio! You need to add an <audio/> to the DOM."); console.error("Failed to find remoteAudio element - cannot play audio!"
+ "You need to add an <audio/> to the DOM.");
} }
return remoteAudioElement; return remoteAudioElement;
}, },
@ -70,22 +71,21 @@ module.exports = React.createClass({
onAction: function(payload) { onAction: function(payload) {
switch (payload.action) { switch (payload.action) {
case 'video_fullscreen': case 'video_fullscreen': {
if (!this.container) { if (!this.container) {
return; return;
} }
var element = this.container; const element = this.container;
if (payload.fullscreen) { if (payload.fullscreen) {
var requestMethod = ( const requestMethod = (
element.requestFullScreen || element.requestFullScreen ||
element.webkitRequestFullScreen || element.webkitRequestFullScreen ||
element.mozRequestFullScreen || element.mozRequestFullScreen ||
element.msRequestFullscreen element.msRequestFullscreen
); );
requestMethod.call(element); requestMethod.call(element);
} } else {
else { const exitMethod = (
var exitMethod = (
document.exitFullscreen || document.exitFullscreen ||
document.mozCancelFullScreen || document.mozCancelFullScreen ||
document.webkitExitFullscreen || document.webkitExitFullscreen ||
@ -96,17 +96,18 @@ module.exports = React.createClass({
} }
} }
break; break;
}
} }
}, },
render: function() { render: function() {
var VideoFeed = sdk.getComponent('voip.VideoFeed'); const VideoFeed = sdk.getComponent('voip.VideoFeed');
// if we're fullscreen, we don't want to set a maxHeight on the video element. // if we're fullscreen, we don't want to set a maxHeight on the video element.
var fullscreenElement = (document.fullscreenElement || const fullscreenElement = (document.fullscreenElement ||
document.mozFullScreenElement || document.mozFullScreenElement ||
document.webkitFullscreenElement); document.webkitFullscreenElement);
var maxVideoHeight = fullscreenElement ? null : this.props.maxHeight; const maxVideoHeight = fullscreenElement ? null : this.props.maxHeight;
return ( return (
<div className="mx_VideoView" ref={this.setContainer} onClick={ this.props.onClick }> <div className="mx_VideoView" ref={this.setContainer} onClick={ this.props.onClick }>
@ -119,5 +120,5 @@ module.exports = React.createClass({
</div> </div>
</div> </div>
); );
} },
}); });

View file

@ -14,24 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var MatrixClientPeg = require('./MatrixClientPeg'); import MatrixClientPeg from './MatrixClientPeg';
var Modal = require('./Modal'); import Modal from './Modal';
var sdk = require('./index'); import sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
var dis = require("./dispatcher"); import dis from "./dispatcher";
var Rooms = require("./Rooms"); import * as Rooms from "./Rooms";
var q = require('q'); import q from 'q';
/** /**
* Create a new room, and switch to it. * Create a new room, and switch to it.
* *
* Returns a promise which resolves to the room id, or null if the
* action was aborted or failed.
*
* @param {object=} opts parameters for creating the room * @param {object=} opts parameters for creating the room
* @param {string=} opts.dmUserId If specified, make this a DM room for this user and invite them * @param {string=} opts.dmUserId If specified, make this a DM room for this user and invite them
* @param {object=} opts.createOpts set of options to pass to createRoom call. * @param {object=} opts.createOpts set of options to pass to createRoom call.
*
* @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed.
*/ */
function createRoom(opts) { function createRoom(opts) {
opts = opts || {}; opts = opts || {};
@ -69,11 +69,11 @@ function createRoom(opts) {
createOpts.initial_state = createOpts.initial_state || [ createOpts.initial_state = createOpts.initial_state || [
{ {
content: { content: {
guest_access: 'can_join' guest_access: 'can_join',
}, },
type: 'm.room.guest_access', type: 'm.room.guest_access',
state_key: '', state_key: '',
} },
]; ];
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var EMAIL_ADDRESS_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; const EMAIL_ADDRESS_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
module.exports = { module.exports = {
looksValid: function(email) { looksValid: function(email) {
return EMAIL_ADDRESS_REGEX.test(email); return EMAIL_ADDRESS_REGEX.test(email);
} },
}; };

View file

@ -17,7 +17,7 @@ limitations under the License.
'use strict'; 'use strict';
module.exports = function(dest, src) { module.exports = function(dest, src) {
for (var i in src) { for (const i in src) {
if (src.hasOwnProperty(i)) { if (src.hasOwnProperty(i)) {
dest[i] = src[i]; dest[i] = src[i];
} }

View file

@ -196,6 +196,7 @@
"%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".", "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".",
"Changes to who can read history will only apply to future messages in this room": "Changes to who can read history will only apply to future messages in this room", "Changes to who can read history will only apply to future messages in this room": "Changes to who can read history will only apply to future messages in this room",
"Changes your display nickname": "Changes your display nickname", "Changes your display nickname": "Changes your display nickname",
"Changes colour scheme of current room": "Changes colour scheme of current room",
"changing room on a RoomView is not supported": "changing room on a RoomView is not supported", "changing room on a RoomView is not supported": "changing room on a RoomView is not supported",
"Changing password will currently reset any end-to-end encryption keys on all devices, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Changing password will currently reset any end-to-end encryption keys on all devices, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.", "Changing password will currently reset any end-to-end encryption keys on all devices, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Changing password will currently reset any end-to-end encryption keys on all devices, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.",
"Claimed Ed25519 fingerprint key": "Claimed Ed25519 fingerprint key", "Claimed Ed25519 fingerprint key": "Claimed Ed25519 fingerprint key",
@ -514,6 +515,7 @@
"%(senderName)s set their display name to %(displayName)s.": "%(senderName)s set their display name to %(displayName)s.", "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s set their display name to %(displayName)s.",
"Set": "Set", "Set": "Set",
"Settings": "Settings", "Settings": "Settings",
"Sets the room topic": "Sets the room topic",
"Show Apps": "Show Apps", "Show Apps": "Show Apps",
"Show panel": "Show panel", "Show panel": "Show panel",
"Show Text Formatting Toolbar": "Show Text Formatting Toolbar", "Show Text Formatting Toolbar": "Show Text Formatting Toolbar",
@ -845,6 +847,7 @@
"If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.": "If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.", "If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.": "If it matches, press the verify button below. If it doesn't, then someone else is intercepting this device and you probably want to press the blacklist button instead.",
"In future this verification process will be more sophisticated.": "In future this verification process will be more sophisticated.", "In future this verification process will be more sophisticated.": "In future this verification process will be more sophisticated.",
"Verify device": "Verify device", "Verify device": "Verify device",
"Verifies a user, device, and pubkey tuple": "Verifies a user, device, and pubkey tuple",
"I verify that the keys match": "I verify that the keys match", "I verify that the keys match": "I verify that the keys match",
"We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.": "We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.", "We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.": "We encountered an error trying to restore your previous session. If you continue, you will need to log in again, and encrypted chat history will be unreadable.",
"Unable to restore session": "Unable to restore session", "Unable to restore session": "Unable to restore session",

View file

@ -50,7 +50,7 @@ class LifecycleStore extends Store {
deferred_action: null, deferred_action: null,
}); });
break; break;
case 'sync_state': case 'sync_state': {
if (payload.state !== 'PREPARED') { if (payload.state !== 'PREPARED') {
break; break;
} }
@ -61,6 +61,7 @@ class LifecycleStore extends Store {
}); });
dis.dispatch(deferredAction); dis.dispatch(deferredAction);
break; break;
}
case 'on_logged_out': case 'on_logged_out':
this.reset(); this.reset();
break; break;

View file

@ -0,0 +1,77 @@
/*
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 dis from '../dispatcher';
import {Store} from 'flux/utils';
import {convertToRaw, convertFromRaw} from 'draft-js';
const INITIAL_STATE = {
editorStateMap: localStorage.getItem('content_state') ?
JSON.parse(localStorage.getItem('content_state')) : {},
};
/**
* A class for storing application state to do with the message composer. This is a simple
* flux store that listens for actions and updates its state accordingly, informing any
* listeners (views) of state changes.
*/
class MessageComposerStore extends Store {
constructor() {
super(dis);
// Initialise state
this._state = Object.assign({}, INITIAL_STATE);
}
_setState(newState) {
this._state = Object.assign(this._state, newState);
this.__emitChange();
}
__onDispatch(payload) {
switch (payload.action) {
case 'content_state':
this._contentState(payload);
break;
case 'on_logged_out':
this.reset();
break;
}
}
_contentState(payload) {
const editorStateMap = this._state.editorStateMap;
editorStateMap[payload.room_id] = convertToRaw(payload.content_state);
localStorage.setItem('content_state', JSON.stringify(editorStateMap));
this._setState({
editorStateMap: editorStateMap,
});
}
getContentState(roomId) {
return this._state.editorStateMap[roomId] ?
convertFromRaw(this._state.editorStateMap[roomId]) : null;
}
reset() {
this._state = Object.assign({}, INITIAL_STATE);
}
}
let singletonMessageComposerStore = null;
if (!singletonMessageComposerStore) {
singletonMessageComposerStore = new MessageComposerStore();
}
module.exports = singletonMessageComposerStore;

File diff suppressed because one or more lines are too long

View file

@ -192,52 +192,37 @@ describe('ScrollPanel', function() {
} }
}); });
it('should handle scrollEvent strangeness', function(done) { it('should handle scrollEvent strangeness', function() {
var events = []; const events = [];
q().then(() => { return q().then(() => {
// initialise with a few events // initialise with a load of events
for (var i = 0; i < 10; i++) { for (let i = 0; i < 20; i++) {
events.push(i+90); events.push(i+80);
} }
tester.setTileKeys(events); tester.setTileKeys(events);
expect(tester.fillCounts.b).toEqual(1); expect(scrollingDiv.scrollHeight).toEqual(3050); // 20*150 + 50
expect(tester.fillCounts.f).toEqual(2); expect(scrollingDiv.scrollTop).toEqual(3050 - 600);
expect(scrollingDiv.scrollHeight).toEqual(1550) // 10*150 + 50
expect(scrollingDiv.scrollTop).toEqual(1550 - 600);
return tester.awaitScroll(); return tester.awaitScroll();
}).then(() => { }).then(() => {
expect(tester.lastScrollEvent).toBe(950); expect(tester.lastScrollEvent).toBe(3050 - 600);
// we want to simulate back-filling as we scroll up tester.scrollPanel().scrollToToken("92", 0);
tester.addFillHandler('b', function() {
var newEvents = [];
for (var i = 0; i < 10; i++) {
newEvents.push(i+80);
}
events.unshift.apply(events, newEvents);
tester.setTileKeys(events);
return q(true);
});
// simulate scrolling up; this should trigger the backfill
scrollingDiv.scrollTop = 200;
return tester.awaitFill('b');
}).then(() => {
console.log('filled');
// at this point, ScrollPanel will have updated scrollTop, but // at this point, ScrollPanel will have updated scrollTop, but
// the event hasn't fired. Stamp over the scrollTop. // the event hasn't fired.
expect(tester.lastScrollEvent).toEqual(200); expect(tester.lastScrollEvent).toEqual(3050 - 600);
expect(scrollingDiv.scrollTop).toEqual(10*150 + 200); expect(scrollingDiv.scrollTop).toEqual(1950);
// now stamp over the scrollTop.
console.log('faking #528');
scrollingDiv.scrollTop = 500; scrollingDiv.scrollTop = 500;
return tester.awaitScroll(); return tester.awaitScroll();
}).then(() => { }).then(() => {
expect(tester.lastScrollEvent).toBe(10*150 + 200); expect(tester.lastScrollEvent).toBe(1950);
expect(scrollingDiv.scrollTop).toEqual(10*150 + 200); expect(scrollingDiv.scrollTop).toEqual(1950);
}).done(done); });
}); });
it('should not get stuck in #528 workaround', function(done) { it('should not get stuck in #528 workaround', function(done) {
@ -250,7 +235,7 @@ describe('ScrollPanel', function() {
tester.setTileKeys(events); tester.setTileKeys(events);
expect(tester.fillCounts.b).toEqual(1); expect(tester.fillCounts.b).toEqual(1);
expect(tester.fillCounts.f).toEqual(2); expect(tester.fillCounts.f).toEqual(2);
expect(scrollingDiv.scrollHeight).toEqual(6050) // 40*150 + 50 expect(scrollingDiv.scrollHeight).toEqual(6050); // 40*150 + 50
expect(scrollingDiv.scrollTop).toEqual(6050 - 600); expect(scrollingDiv.scrollTop).toEqual(6050 - 600);
// try to scroll up, to a non-integer offset. // try to scroll up, to a non-integer offset.