Merge branch 'develop' into erikj/group_server

This commit is contained in:
Luke Barnard 2017-07-07 10:08:49 +01:00 committed by GitHub
commit 32a01b54b8
61 changed files with 2577 additions and 1482 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

@ -33,15 +33,16 @@
"scripts": { "scripts": {
"reskindex": "node scripts/reskindex.js -h header", "reskindex": "node scripts/reskindex.js -h header",
"reskindex:watch": "node scripts/reskindex.js -h header -w", "reskindex:watch": "node scripts/reskindex.js -h header -w",
"build": "npm run reskindex && babel src -d lib --source-maps", "build": "npm run reskindex && babel src -d lib --source-maps --copy-files",
"build:watch": "babel src -w -d lib --source-maps", "build:watch": "babel src -w -d lib --source-maps --copy-files",
"emoji-data-strip": "node scripts/emoji-data-strip.js",
"start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"", "start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"",
"lint": "eslint src/", "lint": "eslint src/",
"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",
@ -74,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"
}, },
@ -105,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

@ -0,0 +1,26 @@
#!/usr/bin/env node
const EMOJI_DATA = require('emojione/emoji.json');
const EMOJI_SUPPORTED = Object.keys(require('emojione').emojioneList);
const fs = require('fs');
const output = Object.keys(EMOJI_DATA).map(
(key) => {
const datum = EMOJI_DATA[key];
const newDatum = {
name: datum.name,
shortname: datum.shortname,
category: datum.category,
emoji_order: datum.emoji_order,
};
if (datum.aliases_ascii.length > 0) {
newDatum.aliases_ascii = datum.aliases_ascii;
}
return newDatum;
}
).filter((datum) => {
return EMOJI_SUPPORTED.includes(datum.shortname);
});
// Write to a file in src. Changes should be checked into git. This file is copied by
// babel using --copy-files
fs.writeFileSync('./src/stripped-emoji.json', JSON.stringify(output));

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

@ -52,21 +52,19 @@ export default class ComposerHistoryManager {
history: Array<HistoryItem> = []; history: Array<HistoryItem> = [];
prefix: string; prefix: string;
lastIndex: number = 0; lastIndex: number = 0;
currentIndex: number = -1; currentIndex: number = 0;
constructor(roomId: string, prefix: string = 'mx_composer_history_') { constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId; this.prefix = prefix + roomId;
// TODO: Performance issues? // TODO: Performance issues?
for(; sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`); this.lastIndex++, this.currentIndex++) { let item;
for(; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
this.history.push( this.history.push(
Object.assign( Object.assign(new HistoryItem(), JSON.parse(item)),
new HistoryItem(),
JSON.parse(sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`)),
),
); );
} }
this.currentIndex--; this.lastIndex = this.currentIndex;
} }
addItem(message: string, format: MessageFormat) { addItem(message: string, format: MessageFormat) {

View file

@ -54,24 +54,25 @@ function pad(n) {
function twelveHourTime(date) { function twelveHourTime(date) {
let hours = date.getHours() % 12; let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes()); const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? 'PM' : 'AM'; const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
hours = pad(hours ? hours : 12); hours = hours ? hours : 12; // convert 0 -> 12
return `${hours}:${minutes}${ampm}`; return `${hours}:${minutes}${ampm}`;
} }
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

@ -84,7 +84,7 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
} }
export function stripParagraphs(html: string): string { export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div'); const contentDiv = document.createElement('div');
contentDiv.innerHTML = html; contentDiv.innerHTML = html;
@ -93,10 +93,21 @@ export function stripParagraphs(html: string): string {
} }
let contentHTML = ""; let contentHTML = "";
for (let i=0; i<contentDiv.children.length; i++) { for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i]; const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') { if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML + '<br />'; contentHTML += element.innerHTML;
// Don't add a <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> is
// redundant. This is a workaround for a bug in draft-js-export-html:
// https://github.com/sstur/draft-js-export-html/issues/62
contentHTML += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} else { } else {
const temp = document.createElement('div'); const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true)); temp.appendChild(element.cloneNode(true));
@ -134,6 +145,7 @@ const sanitizeHtmlParams = {
// would make sense if we did // would make sense if we did
img: ['src'], img: ['src'],
ol: ['start'], ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
}, },
// Lots of these won't come up by default because we don't allow them // Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
@ -175,6 +187,19 @@ const sanitizeHtmlParams = {
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName: tagName, attribs : attribs }; return { tagName: tagName, attribs : attribs };
}, },
'code': function(tagName, attribs) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
let classes = attribs.class.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-');
});
attribs.class = classes.join(' ');
}
return {
tagName: tagName,
attribs: attribs,
};
},
'*': function(tagName, attribs) { '*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span // Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming // because attributes are stripped after transforming

View file

@ -30,7 +30,30 @@ module.exports = {
RIGHT: 39, RIGHT: 39,
DOWN: 40, DOWN: 40,
DELETE: 46, DELETE: 46,
KEY_A: 65,
KEY_B: 66,
KEY_C: 67,
KEY_D: 68, KEY_D: 68,
KEY_E: 69, KEY_E: 69,
KEY_F: 70,
KEY_G: 71,
KEY_H: 72,
KEY_I: 73,
KEY_J: 74,
KEY_K: 75,
KEY_L: 76,
KEY_M: 77, KEY_M: 77,
KEY_N: 78,
KEY_O: 79,
KEY_P: 80,
KEY_Q: 81,
KEY_R: 82,
KEY_S: 83,
KEY_T: 84,
KEY_U: 85,
KEY_V: 86,
KEY_W: 87,
KEY_X: 88,
KEY_Y: 89,
KEY_Z: 90,
}; };

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

@ -17,7 +17,7 @@ limitations under the License.
import commonmark from 'commonmark'; import commonmark from 'commonmark';
import escape from 'lodash/escape'; import escape from 'lodash/escape';
const ALLOWED_HTML_TAGS = ['del']; const ALLOWED_HTML_TAGS = ['del', 'u'];
// These types of node are definitely text // These types of node are definitely text
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];

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

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -109,6 +110,76 @@ Example:
response: 78 response: 78
} }
set_widget
----------
Set a new widget in the room. Clobbers based on the ID.
Request:
- `room_id` (String) is the room to set the widget in.
- `widget_id` (String) is the ID of the widget to add (or replace if it already exists).
It can be an arbitrary UTF8 string and is purely for distinguishing between widgets.
- `url` (String) is the URL that clients should load in an iframe to run the widget.
All widgets must have a valid URL. If the URL is `null` (not `undefined`), the
widget will be removed from the room.
- `type` (String) is the type of widget, which is provided as a hint for matrix clients so they
can configure/lay out the widget in different ways. All widgets must have a type.
- `name` (String) is an optional human-readable string about the widget.
- `data` (Object) is some optional data about the widget, and can contain arbitrary key/value pairs.
Response:
{
success: true
}
Example:
{
action: "set_widget",
room_id: "!foo:bar",
widget_id: "abc123",
url: "http://widget.url",
type: "example",
response: {
success: true
}
}
get_widgets
-----------
Get a list of all widgets in the room. The response is the `content` field
of the state event.
Request:
- `room_id` (String) is the room to get the widgets in.
Response:
{
$widget_id: {
type: "example",
url: "http://widget.url",
name: "Example Widget",
data: {
key: "val"
}
},
$widget_id: { ... }
}
Example:
{
action: "get_widgets",
room_id: "!foo:bar",
widget_id: "abc123",
url: "http://widget.url",
type: "example",
response: {
$widget_id: {
type: "example",
url: "http://widget.url",
name: "Example Widget",
data: {
key: "val"
}
},
$widget_id: { ... }
}
}
membership_state AND bot_options membership_state AND bot_options
-------------------------------- --------------------------------
@ -191,6 +262,84 @@ function inviteUser(event, roomId, userId) {
}); });
} }
function setWidget(event, roomId) {
const widgetId = event.data.widget_id;
const widgetType = event.data.type;
const widgetUrl = event.data.url;
const widgetName = event.data.name; // optional
const widgetData = event.data.data; // optional
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
// both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields."));
return;
}
if (widgetUrl !== null) { // if url is null it is being deleted, don't need to check name/type/etc
// check types of fields
if (widgetName !== undefined && typeof widgetName !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'name' must be a string."));
return;
}
if (widgetData !== undefined && !(widgetData instanceof Object)) {
sendError(event, _t("Unable to create widget."), new Error("Optional field 'data' must be an Object."));
return;
}
if (typeof widgetType !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'type' must be a string."));
return;
}
if (typeof widgetUrl !== 'string') {
sendError(event, _t("Unable to create widget."), new Error("Field 'url' must be a string or null."));
return;
}
}
// TODO: same dance we do for power levels. It'd be nice if the JS SDK had helper methods to do this.
client.getStateEvent(roomId, "im.vector.modular.widgets", "").then((widgets) => {
if (widgetUrl === null) {
delete widgets[widgetId];
}
else {
widgets[widgetId] = {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
};
}
return client.sendStateEvent(roomId, "im.vector.modular.widgets", widgets);
}, (err) => {
if (err.errcode === "M_NOT_FOUND") {
return client.sendStateEvent(roomId, "im.vector.modular.widgets", {
[widgetId]: {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
}
});
}
throw err;
}).done(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, _t('Failed to send request.'), err);
});
}
function getWidgets(event, roomId) {
returnStateEvent(event, roomId, "im.vector.modular.widgets", "");
}
function setPlumbingState(event, roomId, status) { function setPlumbingState(event, roomId, status) {
if (typeof status !== 'string') { if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string'); throw new Error('Plumbing state status should be a string');
@ -367,7 +516,7 @@ const onMessage = function(event) {
return; return;
} }
// Getting join rules does not require userId // These APIs don't require userId
if (event.data.action === "join_rules_state") { if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId); getJoinRules(event, roomId);
return; return;
@ -377,6 +526,12 @@ const onMessage = function(event) {
} else if (event.data.action === "get_membership_count") { } else if (event.data.action === "get_membership_count") {
getMembershipCount(event, roomId); getMembershipCount(event, roomId);
return; return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
} else if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} }
if (!userId) { if (!userId) {
@ -409,12 +564,27 @@ const onMessage = function(event) {
}); });
}; };
let listenerCount = 0;
module.exports = { module.exports = {
startListening: function() { startListening: function() {
window.addEventListener("message", onMessage, false); if (listenerCount === 0) {
window.addEventListener("message", onMessage, false);
}
listenerCount += 1;
}, },
stopListening: function() { stopListening: function() {
window.removeEventListener("message", onMessage); listenerCount -= 1;
if (listenerCount === 0) {
window.removeEventListener("message", onMessage);
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"ScalarMessaging: mismatched startListening / stopListening detected." +
" Negative count"
);
console.error(e);
}
}, },
}; };

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

@ -30,11 +30,17 @@ export default {
id: 'rich_text_editor', id: 'rich_text_editor',
default: false, default: false,
}, },
{
name: "-",
id: 'matrix_apps',
default: false,
},
], ],
// horrible but it works. The locality makes this somewhat more palatable. // horrible but it works. The locality makes this somewhat more palatable.
doTranslations: function() { doTranslations: function() {
this.LABS_FEATURES[0].name = _t("New Composer & Autocomplete"); this.LABS_FEATURES[0].name = _t("New Composer & Autocomplete");
this.LABS_FEATURES[1].name = _t("Matrix Apps");
}, },
loadProfileInfo: function() { loadProfileInfo: function() {

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

@ -18,16 +18,42 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import {emojioneList, shortnameToImage, shortnameToUnicode, asciiRegexp} from 'emojione';
import FuzzyMatcher from './FuzzyMatcher'; import FuzzyMatcher from './FuzzyMatcher';
import sdk from '../index'; import sdk from '../index';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter'; import type {SelectionRange, Completion} from './Autocompleter';
const EMOJI_REGEX = /:\w*:?/g; import EmojiData from '../stripped-emoji.json';
const EMOJI_SHORTNAMES = Object.keys(emojioneList).map(shortname => {
const LIMIT = 20;
const CATEGORY_ORDER = [
'people',
'food',
'objects',
'activity',
'nature',
'travel',
'flags',
'symbols',
'unicode9',
'modifier',
];
// Match for ":wink:" or ascii-style ";-)" provided by emojione
const EMOJI_REGEX = new RegExp('(' + asciiRegexp + '|:\\w*:?)$', 'g');
const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort(
(a, b) => {
if (a.category === b.category) {
return a.emoji_order - b.emoji_order;
}
return CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
},
).map((a) => {
return { return {
shortname, name: a.name,
shortname: a.shortname,
aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '',
}; };
}); });
@ -37,7 +63,9 @@ export default class EmojiProvider extends AutocompleteProvider {
constructor() { constructor() {
super(EMOJI_REGEX); super(EMOJI_REGEX);
this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, { this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: 'shortname', keys: ['aliases_ascii', 'shortname', 'name'],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
}); });
} }
@ -57,7 +85,7 @@ export default class EmojiProvider extends AutocompleteProvider {
), ),
range, range,
}; };
}).slice(0, 8); }).slice(0, LIMIT);
} }
return completions; return completions;
} }
@ -73,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

@ -63,6 +63,18 @@ export default class QueryMatcher {
this.options = options; this.options = options;
this.keys = options.keys; this.keys = options.keys;
this.setObjects(objects); this.setObjects(objects);
// By default, we remove any non-alphanumeric characters ([^A-Za-z0-9_]) from the
// query and the value being queried before matching
if (this.options.shouldMatchWordsOnly === undefined) {
this.options.shouldMatchWordsOnly = true;
}
// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this.options.shouldMatchPrefix === undefined) {
this.options.shouldMatchPrefix = false;
}
} }
setObjects(objects: Array<Object>) { setObjects(objects: Array<Object>) {
@ -70,10 +82,31 @@ export default class QueryMatcher {
} }
match(query: String): Array<Object> { match(query: String): Array<Object> {
query = query.toLowerCase().replace(/[^\w]/g, ''); query = query.toLowerCase();
const results = _sortedUniq(_sortBy(_flatMap(this.keyMap.keys, (key) => { if (this.options.shouldMatchWordsOnly) {
return key.toLowerCase().replace(/[^\w]/g, '').indexOf(query) >= 0 ? this.keyMap.objectMap[key] : []; query = query.replace(/[^\w]/g, '');
}), (candidate) => this.keyMap.priorityMap.get(candidate))); }
return results; if (query.length === 0) {
return [];
}
const results = [];
this.keyMap.keys.forEach((key) => {
let resultKey = key.toLowerCase();
if (this.options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this.options.shouldMatchPrefix || index === 0)) {
results.push({key, index});
}
});
return _sortedUniq(_flatMap(_sortBy(results, (candidate) => {
return candidate.index;
}).map((candidate) => {
// return an array of objects (those given to setObjects) that have the given
// key as a property.
return this.keyMap.objectMap[candidate.key];
})));
} }
} }

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

@ -47,13 +47,12 @@ import UserProvider from '../../autocomplete/UserProvider';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
var DEBUG = false; let DEBUG = false;
let debuglog = function() {};
if (DEBUG) { if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console); debuglog = console.log.bind(console);
} else {
var debuglog = function() {};
} }
module.exports = React.createClass({ module.exports = React.createClass({
@ -113,6 +112,7 @@ module.exports = React.createClass({
callState: null, callState: null,
guestsCanJoin: false, guestsCanJoin: false,
canPeek: false, canPeek: false,
showApps: false,
// error object, as from the matrix client/server API // error object, as from the matrix client/server API
// If we failed to load information about the room, // If we failed to load information about the room,
@ -236,6 +236,7 @@ module.exports = React.createClass({
if (room) { if (room) {
this.setState({ this.setState({
unsentMessageError: this._getUnsentMessageError(room), unsentMessageError: this._getUnsentMessageError(room),
showApps: this._shouldShowApps(room),
}); });
this._onRoomLoaded(room); this._onRoomLoaded(room);
} }
@ -273,6 +274,11 @@ module.exports = React.createClass({
} }
}, },
_shouldShowApps: function(room) {
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets', '');
return appsStateEvents && Object.keys(appsStateEvents.getContent()).length > 0;
},
componentDidMount: function() { componentDidMount: function() {
var call = this._getCallForRoom(); var call = this._getCallForRoom();
var callState = call ? call.call_state : "ended"; var callState = call ? call.call_state : "ended";
@ -453,9 +459,14 @@ module.exports = React.createClass({
this._updateConfCallNotification(); this._updateConfCallNotification();
this.setState({ this.setState({
callState: callState callState: callState,
}); });
break;
case 'appsDrawer':
this.setState({
showApps: payload.show,
});
break; break;
} }
}, },
@ -1604,11 +1615,13 @@ module.exports = React.createClass({
var auxPanel = ( var auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room} <AuxPanel ref="auxPanel" room={this.state.room}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler} conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile} draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification} displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight} maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize} > onResize={this.onChildResize}
showApps={this.state.showApps && !this.state.editingRoomSettings} >
{ aux } { aux }
</AuxPanel> </AuxPanel>
); );
@ -1621,8 +1634,14 @@ module.exports = React.createClass({
if (canSpeak) { if (canSpeak) {
messageComposer = messageComposer =
<MessageComposer <MessageComposer
room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile} room={this.state.room}
callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>; onResize={this.onChildResize}
uploadFile={this.uploadFile}
callState={this.state.callState}
tabComplete={this.tabComplete}
opacity={ this.props.opacity }
showApps={ this.state.showApps }
/>;
} }
// TODO: Why aren't we storing the term/scope/count in this format // TODO: Why aren't we storing the term/scope/count in this format

View file

@ -93,6 +93,10 @@ const SETTINGS_LABELS = [
id: 'disableMarkdown', id: 'disableMarkdown',
label: 'Disable markdown formatting', label: 'Disable markdown formatting',
}, },
{
id: 'enableSyntaxHighlightLanguageDetection',
label: 'Enable automatic language detection for syntax highlighting',
},
/* /*
{ {
id: 'useFixedWidthFont', id: 'useFixedWidthFont',

View file

@ -0,0 +1,161 @@
/*
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.
*/
'use strict';
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import ScalarAuthClient from '../../../ScalarAuthClient';
import SdkConfig from '../../../SdkConfig';
import { _t } from '../../../languageHandler';
import url from 'url';
export default React.createClass({
displayName: 'AppTile',
propTypes: {
id: React.PropTypes.string.isRequired,
url: React.PropTypes.string.isRequired,
name: React.PropTypes.string.isRequired,
room: React.PropTypes.object.isRequired,
},
getDefaultProps: function() {
return {
url: "",
};
},
getInitialState: function() {
return {
loading: false,
widgetUrl: this.props.url,
error: null,
};
},
// Returns true if props.url is a scalar URL, typically https://scalar.vector.im/api
isScalarUrl: function() {
const scalarUrl = SdkConfig.get().integrations_rest_url;
return scalarUrl && this.props.url.startsWith(scalarUrl);
},
componentWillMount: function() {
if (!this.isScalarUrl()) {
return;
}
// Fetch the token before loading the iframe as we need to mangle the URL
this.setState({
loading: true,
});
this._scalarClient = new ScalarAuthClient();
this._scalarClient.getScalarToken().done((token) => {
// Append scalar_token as a query param
const u = url.parse(this.props.url);
if (!u.search) {
u.search = "?scalar_token=" + encodeURIComponent(token);
} else {
u.search += "&scalar_token=" + encodeURIComponent(token);
}
this.setState({
error: null,
widgetUrl: u.format(),
loading: false,
});
}, (err) => {
this.setState({
error: err.message,
loading: false,
});
});
},
_onEditClick: function() {
console.log("Edit widget %s", this.props.id);
},
_onDeleteClick: function() {
console.log("Delete widget %s", this.props.id);
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
if (!appsStateEvents) {
return;
}
const appsStateEvent = appsStateEvents.getContent();
if (appsStateEvent[this.props.id]) {
delete appsStateEvent[this.props.id];
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
appsStateEvent,
'',
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
});
}
},
formatAppTileName: function() {
let appTileName = "No name";
if(this.props.name && this.props.name.trim()) {
appTileName = this.props.name.trim();
appTileName = appTileName[0].toUpperCase() + appTileName.slice(1).toLowerCase();
}
return appTileName;
},
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 (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
<div className="mx_AppTileMenuBar">
{this.formatAppTileName()}
<span className="mx_AppTileMenuBarWidgets">
{/* Edit widget */}
{/* <img
src="img/edit.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8" alt="Edit"
onClick={this._onEditClick}
/> */}
{/* Delete widget */}
<img src="img/cancel.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget"
width="8" height="8" alt={_t("Cancel")}
onClick={this._onDeleteClick}
/>
</span>
</div>
{appTileBody}
</div>
);
},
});

View file

@ -79,7 +79,7 @@ module.exports = React.createClass({
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} else if (content.info.thumbnail_url) { } else if (content.info && content.info.thumbnail_url) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url);
} else { } else {
return null; return null;

View file

@ -29,6 +29,7 @@ import Modal from '../../../Modal';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import UserSettingsStore from "../../../UserSettingsStore";
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -90,7 +91,18 @@ module.exports = React.createClass({
setTimeout(() => { setTimeout(() => {
if (this._unmounted) return; if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
highlight.highlightBlock(blocks[i]); if (UserSettingsStore.getSyncedSetting("enableSyntaxHighlightLanguageDetection", false)) {
highlight.highlightBlock(blocks[i])
} else {
// Only syntax highlight if there's a class starting with language-
let classes = blocks[i].className.split(/\s+/).filter(function (cl) {
return cl.startsWith('language-');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]);
}
}
} }
}, 10); }, 10);
} }
@ -131,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) {
@ -146,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

@ -0,0 +1,218 @@
/*
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.
*/
'use strict';
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'AppsDrawer',
propTypes: {
room: React.PropTypes.object.isRequired,
},
getInitialState: function() {
return {
apps: this._getApps(),
};
},
componentWillMount: function() {
ScalarMessaging.startListening();
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
componentDidMount: function() {
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
if (this.state.apps && this.state.apps.length < 1) {
this.onClickAddWidget();
}
// TODO -- Handle Scalar errors
// },
// (err) => {
// this.setState({
// scalar_error: err,
// });
});
}
},
componentWillUnmount: function() {
ScalarMessaging.stopListening();
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
},
/**
* Encodes a URI according to a set of template variables. Variables will be
* passed through encodeURIComponent.
* @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'.
* @param {Object} variables The key/value pairs to replace the template
* variables with. E.g. { "$bar": "baz" }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
encodeUri: function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
},
_initAppConfig: function(appId, app) {
const user = MatrixClientPeg.get().getUser(this.props.userId);
const params = {
'$matrix_user_id': this.props.userId,
'$matrix_room_id': this.props.room.roomId,
'$matrix_display_name': user ? user.displayName : this.props.userId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
};
if(app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
}
app.id = appId;
app.name = app.name || app.type;
app.url = this.encodeUri(app.url, params);
// switch(app.type) {
// case 'etherpad':
// app.queryParams = '?userName=' + this.props.userId +
// '&padId=' + this.props.room.roomId;
// break;
// case 'jitsi': {
//
// app.queryParams = '?confId=' + app.data.confId +
// '&displayName=' + encodeURIComponent(user.displayName) +
// '&avatarUrl=' + encodeURIComponent(MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl)) +
// '&email=' + encodeURIComponent(this.props.userId) +
// '&isAudioConf=' + app.data.isAudioConf;
//
// break;
// }
// case 'vrdemo':
// app.queryParams = '?roomAlias=' + encodeURIComponent(app.data.roomAlias);
// break;
// }
return app;
},
onRoomStateEvents: function(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') {
return;
}
this._updateApps();
},
_getApps: function() {
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
if (!appsStateEvents) {
return [];
}
const appsStateEvent = appsStateEvents.getContent();
if (Object.keys(appsStateEvent).length < 1) {
return [];
}
return Object.keys(appsStateEvent).map((appId) => {
return this._initAppConfig(appId, appsStateEvent[appId]);
});
},
_updateApps: function() {
const apps = this._getApps();
if (apps.length < 1) {
dis.dispatch({
action: 'appsDrawer',
show: false,
});
}
this.setState({
apps: apps,
});
},
onClickAddWidget: function(e) {
if (e) {
e.preventDefault();
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) :
null;
Modal.createDialog(IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
},
render: function() {
const apps = this.state.apps.map(
(app, index, arr) => {
return <AppTile
key={app.name}
id={app.id}
url={app.url}
name={app.name}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
/>;
});
const addWidget = this.state.apps && this.state.apps.length < 2 &&
(<div onClick={this.onClickAddWidget}
role="button"
tabIndex="0"
className="mx_AddWidget_button"
title={_t('Add a widget')}>
[+] {_t('Add a widget')}
</div>);
return (
<div className="mx_AppsDrawer">
<div id="apps" className="mx_AppsContainer">
{apps}
</div>
{addWidget}
</div>
);
},
});

View file

@ -40,25 +40,51 @@ export default class Autocomplete extends React.Component {
}; };
} }
async componentWillReceiveProps(props, state) { componentWillReceiveProps(newProps, state) {
if (props.query === this.props.query) { // Query hasn't changed so don't try to complete it
return null; if (newProps.query === this.props.query) {
}
return await this.complete(props.query, props.selection);
}
async complete(query, selection) {
let forceComplete = this.state.forceComplete;
const completionPromise = getCompletions(query, selection, forceComplete);
this.completionPromise = completionPromise;
const completions = await this.completionPromise;
// There's a newer completion request, so ignore results.
if (completionPromise !== this.completionPromise) {
return; return;
} }
this.complete(newProps.query, newProps.selection);
}
complete(query, selection) {
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
}
if (query === "") {
this.setState({
// Clear displayed completions
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: COMPOSER_SELECTED,
// Hide the autocomplete box
hide: true,
});
return Q(null);
}
let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200);
// Don't debounce if we are already showing completions
if (this.state.completions.length > 0 || this.state.forceComplete) {
autocompleteDelay = 0;
}
const deferred = Q.defer();
this.debounceCompletionsRequest = setTimeout(() => {
getCompletions(
query, selection, this.state.forceComplete,
).then((completions) => {
this.processCompletions(completions);
deferred.resolve();
});
}, autocompleteDelay);
return deferred.promise;
}
processCompletions(completions) {
const completionList = flatMap(completions, (provider) => provider.completions); const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty. // Reset selection when completion list becomes empty.
@ -88,23 +114,13 @@ export default class Autocomplete extends React.Component {
hide = false; hide = false;
} }
const autocompleteDelay = UserSettingsStore.getSyncedSetting('autocompleteDelay', 200);
// We had no completions before, but do now, so we should apply our display delay here
if (this.state.completionList.length === 0 && completionList.length > 0 &&
!forceComplete && autocompleteDelay > 0) {
await Q.delay(autocompleteDelay);
}
// Force complete is turned off each time since we can't edit the query in that case
forceComplete = false;
this.setState({ this.setState({
completions, completions,
completionList, completionList,
selectionOffset, selectionOffset,
hide, hide,
forceComplete, // Force complete is turned off each time since we can't edit the query in that case
forceComplete: false,
}); });
} }
@ -161,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

@ -19,7 +19,9 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import sdk from '../../../index'; import sdk from '../../../index';
import dis from "../../../dispatcher"; import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils'; import ObjectUtils from '../../../ObjectUtils';
import { _t, _tJsx} from '../../../languageHandler'; import AppsDrawer from './AppsDrawer';
import { _t, _tJsx} from '../../../languageHandler';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({ module.exports = React.createClass({
@ -28,6 +30,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// js-sdk room object // js-sdk room object
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
userId: React.PropTypes.string.isRequired,
showApps: React.PropTypes.bool,
// Conference Handler implementation // Conference Handler implementation
conferenceHandler: React.PropTypes.object, conferenceHandler: React.PropTypes.object,
@ -70,10 +74,10 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var CallView = sdk.getComponent("voip.CallView"); const CallView = sdk.getComponent("voip.CallView");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
var fileDropTarget = null; let fileDropTarget = null;
if (this.props.draggingFile) { if (this.props.draggingFile) {
fileDropTarget = ( fileDropTarget = (
<div className="mx_RoomView_fileDropTarget"> <div className="mx_RoomView_fileDropTarget">
@ -87,14 +91,13 @@ module.exports = React.createClass({
); );
} }
var conferenceCallNotification = null; let conferenceCallNotification = null;
if (this.props.displayConfCallNotification) { if (this.props.displayConfCallNotification) {
let supportedText = ''; let supportedText = '';
let joinNode; let joinNode;
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
supportedText = _t(" (unsupported)"); supportedText = _t(" (unsupported)");
} } else {
else {
joinNode = (<span> joinNode = (<span>
{_tJsx( {_tJsx(
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.", "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
@ -105,7 +108,6 @@ module.exports = React.createClass({
] ]
)} )}
</span>); </span>);
} }
// XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages, // XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages,
// but there are translations for this in the languages we do have so I'm leaving it for now. // but there are translations for this in the languages we do have so I'm leaving it for now.
@ -118,7 +120,7 @@ module.exports = React.createClass({
); );
} }
var callView = ( const callView = (
<CallView ref="callView" room={this.props.room} <CallView ref="callView" room={this.props.room}
ConferenceHandler={this.props.conferenceHandler} ConferenceHandler={this.props.conferenceHandler}
onResize={this.props.onResize} onResize={this.props.onResize}
@ -126,8 +128,17 @@ module.exports = React.createClass({
/> />
); );
let appsDrawer = null;
if(UserSettingsStore.isFeatureEnabled('matrix_apps') && this.props.showApps) {
appsDrawer = <AppsDrawer ref="appsDrawer"
room={this.props.room}
userId={this.props.userId}
maxHeight={this.props.maxHeight}/>;
}
return ( return (
<div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} > <div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} >
{ appsDrawer }
{ fileDropTarget } { fileDropTarget }
{ callView } { callView }
{ conferenceCallNotification } { conferenceCallNotification }

View file

@ -13,16 +13,14 @@ 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';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
var CallHandler = require('../../../CallHandler'); import CallHandler from '../../../CallHandler';
var MatrixClientPeg = require('../../../MatrixClientPeg'); import MatrixClientPeg from '../../../MatrixClientPeg';
var Modal = require('../../../Modal'); import Modal from '../../../Modal';
var sdk = require('../../../index'); import sdk from '../../../index';
var dis = require('../../../dispatcher'); import dis from '../../../dispatcher';
import Autocomplete from './Autocomplete'; import Autocomplete from './Autocomplete';
import classNames from 'classnames';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
@ -32,6 +30,8 @@ export default class MessageComposer extends React.Component {
this.onCallClick = this.onCallClick.bind(this); this.onCallClick = this.onCallClick.bind(this);
this.onHangupClick = this.onHangupClick.bind(this); this.onHangupClick = this.onHangupClick.bind(this);
this.onUploadClick = this.onUploadClick.bind(this); this.onUploadClick = this.onUploadClick.bind(this);
this.onShowAppsClick = this.onShowAppsClick.bind(this);
this.onHideAppsClick = this.onHideAppsClick.bind(this);
this.onUploadFileSelected = this.onUploadFileSelected.bind(this); this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.uploadFiles = this.uploadFiles.bind(this); this.uploadFiles = this.uploadFiles.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this); this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
@ -57,7 +57,6 @@ export default class MessageComposer extends React.Component {
}, },
showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false), showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
}; };
} }
componentDidMount() { componentDidMount() {
@ -127,7 +126,7 @@ export default class MessageComposer extends React.Component {
if(shouldUpload) { if(shouldUpload) {
// MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file // MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file
if (files) { if (files) {
for(var i=0; i<files.length; i++) { for(let i=0; i<files.length; i++) {
this.props.uploadFile(files[i]); this.props.uploadFile(files[i]);
} }
} }
@ -139,7 +138,7 @@ export default class MessageComposer extends React.Component {
} }
onHangupClick() { onHangupClick() {
var call = CallHandler.getCallForRoom(this.props.room.roomId); const call = CallHandler.getCallForRoom(this.props.room.roomId);
//var call = CallHandler.getAnyActiveCall(); //var call = CallHandler.getAnyActiveCall();
if (!call) { if (!call) {
return; return;
@ -152,20 +151,68 @@ export default class MessageComposer extends React.Component {
}); });
} }
// _startCallApp(isAudioConf) {
// dis.dispatch({
// action: 'appsDrawer',
// show: true,
// });
// const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
// let appsStateEvent = {};
// if (appsStateEvents) {
// appsStateEvent = appsStateEvents.getContent();
// }
// if (!appsStateEvent.videoConf) {
// appsStateEvent.videoConf = {
// type: 'jitsi',
// // FIXME -- This should not be localhost
// url: 'http://localhost:8000/jitsi.html',
// data: {
// confId: this.props.room.roomId.replace(/[^A-Za-z0-9]/g, '_') + Date.now(),
// isAudioConf: isAudioConf,
// },
// };
// MatrixClientPeg.get().sendStateEvent(
// this.props.room.roomId,
// 'im.vector.modular.widgets',
// appsStateEvent,
// '',
// ).then(() => console.log('Sent state'), (e) => console.error(e));
// }
// }
onCallClick(ev) { onCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video", type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
// this._startCallApp(false);
} }
onVoiceCallClick(ev) { onVoiceCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: 'voice', type: "voice",
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
// this._startCallApp(true);
}
onShowAppsClick(ev) {
dis.dispatch({
action: 'appsDrawer',
show: true,
});
}
onHideAppsClick(ev) {
dis.dispatch({
action: 'appsDrawer',
show: false,
});
} }
onInputContentChanged(content: string, selection: {start: number, end: number}) { onInputContentChanged(content: string, selection: {start: number, end: number}) {
@ -216,19 +263,19 @@ export default class MessageComposer extends React.Component {
} }
render() { render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); const me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'}; const uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" + const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" +
(UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old")); (UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old"));
var controls = []; const controls = [];
controls.push( controls.push(
<div key="controls_avatar" className="mx_MessageComposer_avatar"> <div key="controls_avatar" className="mx_MessageComposer_avatar">
<MemberAvatar member={me} width={24} height={24} /> <MemberAvatar member={me} width={24} height={24} />
</div> </div>,
); );
let e2eImg, e2eTitle, e2eClass; let e2eImg, e2eTitle, e2eClass;
@ -247,16 +294,15 @@ export default class MessageComposer extends React.Component {
controls.push( controls.push(
<img key="e2eIcon" className={e2eClass} src={e2eImg} width="12" height="12" <img key="e2eIcon" className={e2eClass} src={e2eImg} width="12" height="12"
alt={e2eTitle} title={e2eTitle} alt={e2eTitle} title={e2eTitle}
/> />,
); );
var callButton, videoCallButton, hangupButton; let callButton, videoCallButton, hangupButton, showAppsButton, hideAppsButton;
if (this.props.callState && this.props.callState !== 'ended') { if (this.props.callState && this.props.callState !== 'ended') {
hangupButton = hangupButton =
<div key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}> <div key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
<img src="img/hangup.svg" alt={ _t('Hangup') } title={ _t('Hangup') } width="25" height="26"/> <img src="img/hangup.svg" alt={ _t('Hangup') } title={ _t('Hangup') } width="25" height="26"/>
</div>; </div>;
} } else {
else {
callButton = callButton =
<div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={ _t('Voice call') }> <div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={ _t('Voice call') }>
<TintableSvg src="img/icon-call.svg" width="35" height="35"/> <TintableSvg src="img/icon-call.svg" width="35" height="35"/>
@ -267,14 +313,29 @@ export default class MessageComposer extends React.Component {
</div>; </div>;
} }
var canSendMessages = this.props.room.currentState.maySendMessage( // Apps
if (UserSettingsStore.isFeatureEnabled('matrix_apps')) {
if (this.props.showApps) {
hideAppsButton =
<div key="controls_hide_apps" className="mx_MessageComposer_apps" onClick={this.onHideAppsClick} title={_t("Hide Apps")}>
<TintableSvg src="img/icons-apps-active.svg" width="35" height="35"/>
</div>;
} else {
showAppsButton =
<div key="show_apps" className="mx_MessageComposer_apps" onClick={this.onShowAppsClick} title={_t("Show Apps")}>
<TintableSvg src="img/icons-apps.svg" width="35" height="35"/>
</div>;
}
}
const canSendMessages = this.props.room.currentState.maySendMessage(
MatrixClientPeg.get().credentials.userId); MatrixClientPeg.get().credentials.userId);
if (canSendMessages) { if (canSendMessages) {
// This also currently includes the call buttons. Really we should // This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly // check separately for whether we can call, but this is slightly
// complex because of conference calls. // complex because of conference calls.
var uploadButton = ( const uploadButton = (
<div key="controls_upload" className="mx_MessageComposer_upload" <div key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title={ _t('Upload file') }> onClick={this.onUploadClick} title={ _t('Upload file') }>
<TintableSvg src="img/icons-upload.svg" width="35" height="35"/> <TintableSvg src="img/icons-upload.svg" width="35" height="35"/>
@ -300,7 +361,7 @@ export default class MessageComposer extends React.Component {
controls.push( controls.push(
<MessageComposerInput <MessageComposerInput
ref={c => this.messageComposerInput = c} ref={(c) => this.messageComposerInput = c}
key="controls_input" key="controls_input"
onResize={this.props.onResize} onResize={this.props.onResize}
room={this.props.room} room={this.props.room}
@ -316,13 +377,15 @@ export default class MessageComposer extends React.Component {
uploadButton, uploadButton,
hangupButton, hangupButton,
callButton, callButton,
videoCallButton videoCallButton,
showAppsButton,
hideAppsButton,
); );
} else { } else {
controls.push( controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error"> <div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') } { _t('You do not have permission to post to this room') }
</div> </div>,
); );
} }
@ -340,18 +403,14 @@ export default class MessageComposer extends React.Component {
const {style, blockType} = this.state.inputState; const {style, blockType} = this.state.inputState;
const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map( const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map(
name => { (name) => {
const active = style.includes(name) || blockType === name; const active = style.includes(name) || blockType === name;
const suffix = active ? '-o-n' : ''; const suffix = active ? '-o-n' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name; const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
const className = classNames("mx_MessageComposer_format_button", {
mx_MessageComposer_format_button_disabled: disabled,
mx_filterFlipColor: true,
});
return <img className={className} return <img className={className}
title={ _t(name) } title={ _t(name) }
onMouseDown={disabled ? null : onFormatButtonClicked} onMouseDown={onFormatButtonClicked}
key={name} key={name}
src={`img/button-text-${name}${suffix}.svg`} src={`img/button-text-${name}${suffix}.svg`}
height="17" />; height="17" />;
@ -403,5 +462,8 @@ MessageComposer.propTypes = {
uploadFile: React.PropTypes.func.isRequired, uploadFile: React.PropTypes.func.isRequired,
// opacity for dynamic UI fading effects // opacity for dynamic UI fading effects
opacity: React.PropTypes.number opacity: React.PropTypes.number,
// string representing the current room app drawer state
showApps: React.PropTypes.bool,
}; };

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;
@ -87,6 +89,13 @@ export default class MessageComposerInput extends React.Component {
return 'toggle-mode'; return 'toggle-mode';
} }
// Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I
if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) {
// When null is returned, draft-js will NOT preventDefault, allowing dev tools
// to be toggled when the editor is focussed
return null;
}
return getDefaultKeyBinding(e); return getDefaultKeyBinding(e);
} }
@ -114,6 +123,7 @@ export default class MessageComposerInput extends React.Component {
this.onEscape = this.onEscape.bind(this); this.onEscape = this.onEscape.bind(this);
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this); this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
this.onTextPasted = this.onTextPasted.bind(this);
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false);
@ -122,15 +132,21 @@ 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,
};
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled // the virtual state "above" the history stack, the message currently being composed that
/* eslint react/no-direct-mutation-state:0 */ // we want to persist whilst browsing history
this.state.editorState = this.createEditorState(); currentlyComposedEditorState: null,
// whether there were any completions
someCompletions: null,
};
this.client = MatrixClientPeg.get(); this.client = MatrixClientPeg.get();
} }
@ -217,7 +233,8 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) { if (this.state.isRichtextEnabled) {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
} }
const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.moveSelectionToEnd(editorState);
this.onEditorContentChanged(editorState); this.onEditorContentChanged(editorState);
editor.focus(); editor.focus();
} }
@ -323,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;
} }
@ -390,26 +415,59 @@ export default class MessageComposerInput extends React.Component {
}); });
} }
} else { } else {
let contentState = this.state.editorState.getCurrentContent(), let contentState = this.state.editorState.getCurrentContent();
selection = this.state.editorState.getSelection();
const modifyFn = { const modifyFn = {
'bold': (text) => `**${text}**`, 'bold': (text) => `**${text}**`,
'italic': (text) => `*${text}*`, 'italic': (text) => `*${text}*`,
'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* 'underline': (text) => `<u>${text}</u>`,
'strike': (text) => `<del>${text}</del>`, 'strike': (text) => `<del>${text}</del>`,
'code-block': (text) => `\`\`\`\n${text}\n\`\`\``, 'code-block': (text) => `\`\`\`\n${text}\n\`\`\`\n`,
'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''), 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join('') + '\n',
'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''), 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''),
'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''), 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''),
}[command]; }[command];
const selectionAfterOffset = {
'bold': -2,
'italic': -1,
'underline': -4,
'strike': -6,
'code-block': -5,
'blockquote': -2,
}[command];
// Returns a function that collapses a selectionState to its end and moves it by offset
const collapseAndOffsetSelection = (selectionState, offset) => {
const key = selectionState.getEndKey();
return new SelectionState({
anchorKey: key, anchorOffset: offset,
focusKey: key, focusOffset: offset,
});
};
if (modifyFn) { if (modifyFn) {
const previousSelection = this.state.editorState.getSelection();
const newContentState = RichText.modifyText(contentState, previousSelection, modifyFn);
newState = EditorState.push( newState = EditorState.push(
this.state.editorState, this.state.editorState,
RichText.modifyText(contentState, selection, modifyFn), newContentState,
'insert-characters', 'insert-characters',
); );
let newSelection = newContentState.getSelectionAfter();
// If the selection range is 0, move the cursor inside the formatted body
if (previousSelection.getStartOffset() === previousSelection.getEndOffset() &&
previousSelection.getStartKey() === previousSelection.getEndKey() &&
selectionAfterOffset !== undefined
) {
const selectedBlock = newContentState.getBlockForKey(previousSelection.getAnchorKey());
const blockLength = selectedBlock.getText().length;
const newOffset = blockLength + selectionAfterOffset;
newSelection = collapseAndOffsetSelection(newSelection, newOffset);
}
newState = EditorState.forceSelection(newState, newSelection);
} }
} }
@ -425,6 +483,28 @@ export default class MessageComposerInput extends React.Component {
return false; return false;
} }
onTextPasted(text: string, html?: string) {
const currentSelection = this.state.editorState.getSelection();
const currentContent = this.state.editorState.getCurrentContent();
let contentState = null;
if (html && this.state.isRichtextEnabled) {
contentState = Modifier.replaceWithFragment(
currentContent,
currentSelection,
RichText.htmlToContentState(html).getBlockMap(),
);
} else {
contentState = Modifier.replaceText(currentContent, currentSelection, text);
}
let newEditorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
newEditorState = EditorState.forceSelection(newEditorState, contentState.getSelectionAfter());
this.onEditorContentChanged(newEditorState);
return true;
}
handleReturn(ev) { handleReturn(ev) {
if (ev.shiftKey) { if (ev.shiftKey) {
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
@ -476,9 +556,30 @@ export default class MessageComposerInput extends React.Component {
} }
if (this.state.isRichtextEnabled) { if (this.state.isRichtextEnabled) {
contentHTML = HtmlUtils.stripParagraphs( // We should only send HTML if any block is styled or contains inline style
RichText.contentStateToHTML(contentState), let shouldSendHTML = false;
); const blocks = contentState.getBlocksAsArray();
if (blocks.some((block) => block.getType() !== 'unstyled')) {
shouldSendHTML = true;
} else {
const characterLists = blocks.map((block) => block.getCharacterList());
// For each block of characters, determine if any inline styles are applied
// and if yes, send HTML
characterLists.forEach((characters) => {
const numberOfStylesForCharacters = characters.map(
(character) => character.getStyle().toArray().length,
).toArray();
// If any character has more than 0 inline styles applied, send HTML
if (numberOfStylesForCharacters.some((styles) => styles > 0)) {
shouldSendHTML = true;
}
});
}
if (shouldSendHTML) {
contentHTML = HtmlUtils.processHtmlForSending(
RichText.contentStateToHTML(contentState),
);
}
} else { } else {
const md = new Markdown(contentText); const md = new Markdown(contentText);
if (md.isPlainText()) { if (md.isPlainText()) {
@ -491,6 +592,16 @@ export default class MessageComposerInput extends React.Component {
let sendHtmlFn = this.client.sendHtmlMessage; let sendHtmlFn = this.client.sendHtmlMessage;
let sendTextFn = this.client.sendTextMessage; let sendTextFn = this.client.sendTextMessage;
if (this.state.isRichtextEnabled) {
this.historyManager.addItem(
contentHTML ? contentHTML : contentText,
contentHTML ? 'html' : 'markdown',
);
} else {
// Always store MD input as input history
this.historyManager.addItem(contentText, 'markdown');
}
if (contentText.startsWith('/me')) { if (contentText.startsWith('/me')) {
contentText = contentText.substring(4); contentText = contentText.substring(4);
// bit of a hack, but the alternative would be quite complicated // bit of a hack, but the alternative would be quite complicated
@ -499,10 +610,6 @@ export default class MessageComposerInput extends React.Component {
sendTextFn = this.client.sendEmoteMessage; sendTextFn = this.client.sendEmoteMessage;
} }
this.historyManager.addItem(
this.state.isRichtextEnabled ? contentHTML : contentState.getPlainText(),
this.state.isRichtextEnabled ? 'html' : 'markdown');
let sendMessagePromise; let sendMessagePromise;
if (contentHTML) { if (contentHTML) {
sendMessagePromise = sendHtmlFn.call( sendMessagePromise = sendHtmlFn.call(
@ -525,49 +632,117 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete.hide(); this.autocomplete.hide();
return true; return true;
}
onUpArrow = (e) => {
this.onVerticalArrow(e, true);
}; };
onUpArrow = async (e) => { onDownArrow = (e) => {
const completion = this.autocomplete.onUpArrow(); this.onVerticalArrow(e, false);
if (completion == null) { };
const newContent = this.historyManager.getItem(-1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false; onVerticalArrow = (e, up) => {
const editorState = EditorState.push(this.state.editorState, if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
newContent, return;
'insert-characters');
this.setState({editorState});
return true;
} }
e.preventDefault();
return await this.setDisplayedCompletion(completion);
};
onDownArrow = async (e) => { // Select history only if we are not currently auto-completing
const completion = this.autocomplete.onDownArrow();
if (completion == null) {
const newContent = this.historyManager.getItem(+1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
e.preventDefault();
return await this.setDisplayedCompletion(completion);
};
// tab and shift-tab are mapped to down and up arrow respectively
onTab = async (e) => {
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes
if (this.autocomplete.state.completionList.length === 0) { if (this.autocomplete.state.completionList.length === 0) {
await this.autocomplete.forceComplete(); // Don't go back in history if we're in the middle of a multi-line message
this.onDownArrow(e); const selection = this.state.editorState.getSelection();
const blockKey = selection.getStartKey();
const firstBlock = this.state.editorState.getCurrentContent().getFirstBlock();
const lastBlock = this.state.editorState.getCurrentContent().getLastBlock();
let canMoveUp = false;
let canMoveDown = false;
if (blockKey === firstBlock.getKey()) {
canMoveUp = selection.getStartOffset() === selection.getEndOffset() &&
selection.getStartOffset() === 0;
}
if (blockKey === lastBlock.getKey()) {
canMoveDown = selection.getStartOffset() === selection.getEndOffset() &&
selection.getStartOffset() === lastBlock.getText().length;
}
if ((up && !canMoveUp) || (!up && !canMoveDown)) return;
const selected = this.selectHistory(up);
if (selected) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
}
} else { } else {
await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); this.moveAutocompleteSelection(up);
} }
}; };
selectHistory = async (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.historyManager.currentIndex === this.historyManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return;
}
this.setState({
currentlyComposedEditorState: this.state.editorState,
});
} else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
// True when we return to the message being composed currently
this.setState({
editorState: this.state.currentlyComposedEditorState,
});
this.historyManager.currentIndex = this.historyManager.history.length;
return;
}
const newContent = this.historyManager.getItem(delta, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
let editorState = EditorState.push(
this.state.editorState,
newContent,
'insert-characters',
);
// Move selection to the end of the selected history
let newSelection = SelectionState.createEmpty(newContent.getLastBlock().getKey());
newSelection = newSelection.merge({
focusOffset: newContent.getLastBlock().getLength(),
anchorOffset: newContent.getLastBlock().getLength(),
});
editorState = EditorState.forceSelection(editorState, newSelection);
this.setState({editorState});
return true;
};
onTab = async (e) => {
this.setState({
someCompletions: null,
});
e.preventDefault();
if (this.autocomplete.state.completionList.length === 0) {
// Force completions to show for the text currently entered
const completionCount = await this.autocomplete.forceComplete();
this.setState({
someCompletions: completionCount > 0,
});
// Select the first item by moving "down"
await this.moveAutocompleteSelection(false);
} else {
await this.moveAutocompleteSelection(e.shiftKey);
}
};
moveAutocompleteSelection = (up) => {
const completion = up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
return this.setDisplayedCompletion(completion);
};
onEscape = async (e) => { onEscape = async (e) => {
e.preventDefault(); e.preventDefault();
if (this.autocomplete) { if (this.autocomplete) {
@ -676,6 +851,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();
@ -706,6 +882,7 @@ export default class MessageComposerInput extends React.Component {
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
handlePastedText={this.onTextPasted}
handlePastedFiles={this.props.onFilesPasted} handlePastedFiles={this.props.onFilesPasted}
stripPastedStyles={!this.state.isRichtextEnabled} stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab} onTab={this.onTab}

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 = <div className="mx_RoomHeader_topic" ref="topic" title={ topic } dir="auto">{ topic }</div>; 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,16 +69,22 @@ 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');
let roomId; let roomId;
if (opts.andView) {
// We will possibly have a successful join, indicate as such
dis.dispatch({
action: 'will_join',
});
}
return client.createRoom(createOpts).finally(function() { return client.createRoom(createOpts).finally(function() {
modal.close(); modal.close();
}).then(function(res) { }).then(function(res) {
@ -98,10 +104,16 @@ function createRoom(opts) {
action: 'view_room', action: 'view_room',
room_id: roomId, room_id: roomId,
should_peek: false, should_peek: false,
// Creating a room will have joined us to the room
joined: true,
}); });
} }
return roomId; return roomId;
}, function(err) { }, function(err) {
// We also failed to join the room (this sets joining to false in RoomViewStore)
dis.dispatch({
action: 'join_room_error',
});
console.error("Failed to create room " + roomId + " " + err); console.error("Failed to create room " + roomId + " " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: _t("Failure to create room"), title: _t("Failure to create room"),

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];
} }

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
{ {
"Add a widget": "Add a widget",
"af": "Afrikaans", "af": "Afrikaans",
"ar-ae": "Arabic (U.A.E.)", "ar-ae": "Arabic (U.A.E.)",
"ar-bh": "Arabic (Bahrain)", "ar-bh": "Arabic (Bahrain)",
@ -119,6 +120,8 @@
"zh-sg": "Chinese (Singapore)", "zh-sg": "Chinese (Singapore)",
"zh-tw": "Chinese (Taiwan)", "zh-tw": "Chinese (Taiwan)",
"zu": "Zulu", "zu": "Zulu",
"AM": "AM",
"PM": "PM",
"A text message has been sent to +%(msisdn)s. Please enter the verification code it contains": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains",
"accept": "accept", "accept": "accept",
"%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.", "%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",
@ -311,6 +314,7 @@
"Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.", "Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.",
"had": "had", "had": "had",
"Hangup": "Hangup", "Hangup": "Hangup",
"Hide Apps": "Hide Apps",
"Hide read receipts": "Hide read receipts", "Hide read receipts": "Hide read receipts",
"Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar", "Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar",
"Historical": "Historical", "Historical": "Historical",
@ -362,6 +366,7 @@
"Markdown is disabled": "Markdown is disabled", "Markdown is disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled", "Markdown is enabled": "Markdown is enabled",
"matrix-react-sdk version:": "matrix-react-sdk version:", "matrix-react-sdk version:": "matrix-react-sdk version:",
"Matrix Apps": "Matrix Apps",
"Members only": "Members only", "Members only": "Members only",
"Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present", "Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present",
"Missing room_id in request": "Missing room_id in request", "Missing room_id in request": "Missing room_id in request",
@ -464,6 +469,7 @@
"%(senderName)s set a profile picture.": "%(senderName)s set a profile picture.", "%(senderName)s set a profile picture.": "%(senderName)s set a profile picture.",
"%(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.",
"Settings": "Settings", "Settings": "Settings",
"Show Apps": "Show Apps",
"Show panel": "Show panel", "Show panel": "Show panel",
"Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)", "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)",
"Signed Out": "Signed Out", "Signed Out": "Signed Out",

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;

View file

@ -141,6 +141,10 @@ class RoomViewStore extends Store {
shouldPeek: payload.should_peek === undefined ? true : payload.should_peek, shouldPeek: payload.should_peek === undefined ? true : payload.should_peek,
}; };
if (payload.joined) {
newState.joining = false;
}
// If an event ID wasn't specified, default to the one saved for this room // If an event ID wasn't specified, default to the one saved for this room
// via update_scroll_state. Assume initialEventPixelOffset should be set. // via update_scroll_state. Assume initialEventPixelOffset should be set.
if (!newState.initialEventId) { if (!newState.initialEventId) {

1
src/stripped-emoji.json Normal file

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.

View file

@ -99,7 +99,7 @@ describe('MessageComposerInput', () => {
}); });
it('should not change content unnecessarily on Markdown -> RTE conversion', () => { it('should not change content unnecessarily on Markdown -> RTE conversion', () => {
const spy = sinon.spy(client, 'sendHtmlMessage'); const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false); mci.enableRichtext(false);
addTextToDraft('a'); addTextToDraft('a');
mci.handleKeyCommand('toggle-mode'); mci.handleKeyCommand('toggle-mode');
@ -109,8 +109,8 @@ describe('MessageComposerInput', () => {
expect(spy.args[0][1]).toEqual('a'); expect(spy.args[0][1]).toEqual('a');
}); });
it('should send emoji messages in rich text', () => { it('should send emoji messages when rich text is enabled', () => {
const spy = sinon.spy(client, 'sendHtmlMessage'); const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(true); mci.enableRichtext(true);
addTextToDraft('☹'); addTextToDraft('☹');
mci.handleReturn(sinon.stub()); mci.handleReturn(sinon.stub());
@ -118,7 +118,7 @@ describe('MessageComposerInput', () => {
expect(spy.calledOnce).toEqual(true, 'should send message'); expect(spy.calledOnce).toEqual(true, 'should send message');
}); });
it('should send emoji messages in Markdown', () => { it('should send emoji messages when Markdown is enabled', () => {
const spy = sinon.spy(client, 'sendTextMessage'); const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false); mci.enableRichtext(false);
addTextToDraft('☹'); addTextToDraft('☹');