From 0e8a49ebb7e6826bc84ab79623bf9d13dcb57ffd Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 14 Nov 2016 16:00:24 +0000 Subject: [PATCH 001/289] Update EventTile to use WithMatrixClient instead of MatrixClientPeg --- src/components/views/rooms/EventTile.js | 51 ++++++++++++------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index f4167b32f6..375c00fed7 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -21,8 +21,8 @@ var classNames = require("classnames"); var Modal = require('../../../Modal'); var sdk = require('../../../index'); -var MatrixClientPeg = require('../../../MatrixClientPeg') var TextForEvent = require('../../../TextForEvent'); +import WithMatrixClient from '../../../wrappers/WithMatrixClient'; var ContextualMenu = require('../../structures/ContextualMenu'); var dispatcher = require("../../../dispatcher"); @@ -63,22 +63,13 @@ var MAX_READ_AVATARS = 5; // | '--------------------------------------' | // '----------------------------------------------------------' -module.exports = React.createClass({ +module.exports = WithMatrixClient(React.createClass({ displayName: 'EventTile', - statics: { - haveTileForEvent: function(e) { - if (e.isRedacted()) return false; - if (eventTileTypes[e.getType()] == undefined) return false; - if (eventTileTypes[e.getType()] == 'messages.TextualEvent') { - return TextForEvent.textForEvent(e) !== ''; - } else { - return true; - } - } - }, - propTypes: { + /* MatrixClient instance for sender verification etc */ + matrixClient: React.PropTypes.object.isRequired, + /* the MatrixEvent to show */ mxEvent: React.PropTypes.object.isRequired, @@ -153,7 +144,7 @@ module.exports = React.createClass({ componentDidMount: function() { this._suppressReadReceiptAnimation = false; - MatrixClientPeg.get().on("deviceVerificationChanged", + this.props.matrixClient.on("deviceVerificationChanged", this.onDeviceVerificationChanged); }, @@ -176,11 +167,9 @@ module.exports = React.createClass({ }, componentWillUnmount: function() { - var client = MatrixClientPeg.get(); - if (client) { - client.removeListener("deviceVerificationChanged", - this.onDeviceVerificationChanged); - } + var client = this.props.matrixClient; + client.removeListener("deviceVerificationChanged", + this.onDeviceVerificationChanged); }, onDeviceVerificationChanged: function(userId, device) { @@ -193,7 +182,7 @@ module.exports = React.createClass({ var verified = null; if (mxEvent.isEncrypted()) { - verified = MatrixClientPeg.get().isEventSenderVerified(mxEvent); + verified = this.props.matrixClient.isEventSenderVerified(mxEvent); } this.setState({ @@ -246,11 +235,11 @@ module.exports = React.createClass({ }, shouldHighlight: function() { - var actions = MatrixClientPeg.get().getPushActionsForEvent(this.props.mxEvent); + var actions = this.props.matrixClient.getPushActionsForEvent(this.props.mxEvent); if (!actions || !actions.tweaks) { return false; } // don't show self-highlights from another of our clients - if (this.props.mxEvent.getSender() === MatrixClientPeg.get().credentials.userId) + if (this.props.mxEvent.getSender() === this.props.matrixClient.credentials.userId) { return false; } @@ -387,7 +376,7 @@ module.exports = React.createClass({ throw new Error("Event type not supported"); } - var e2eEnabled = MatrixClientPeg.get().isRoomEncrypted(this.props.mxEvent.getRoomId()); + var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId()); var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); var classes = classNames({ @@ -481,7 +470,7 @@ module.exports = React.createClass({ } if (this.props.tileShape === "notif") { - var room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); return (
@@ -554,4 +543,14 @@ module.exports = React.createClass({ ); } }, -}); +})); + +module.exports.haveTileForEvent = function(e) { + if (e.isRedacted()) return false; + if (eventTileTypes[e.getType()] == undefined) return false; + if (eventTileTypes[e.getType()] == 'messages.TextualEvent') { + return TextForEvent.textForEvent(e) !== ''; + } else { + return true; + } +}; From 22757cfcd3a2b769c2748ef9e21512ed7c34c8d2 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 14 Nov 2016 18:20:15 +0000 Subject: [PATCH 002/289] Inject MatrixClient into React context in tests Now that EventTile expects MatrixClient in the context, we had better provide it. --- .../structures/MessagePanel-test.js | 40 ++++++++++------ .../structures/TimelinePanel-test.js | 34 +++++++++++--- test/test-utils.js | 46 ++++++++++++------- 3 files changed, 83 insertions(+), 37 deletions(-) diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index d16371b368..a96d0ed5d1 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -20,30 +20,44 @@ var TestUtils = require('react-addons-test-utils'); var expect = require('expect'); var sdk = require('matrix-react-sdk'); -var MatrixClientPeg = require('MatrixClientPeg'); var MessagePanel = sdk.getComponent('structures.MessagePanel'); var test_utils = require('test-utils'); var mockclock = require('mock-clock'); +var client; + +// wrap MessagePanel with a component which provides the MatrixClient in the context. +const WrappedMessagePanel = React.createClass({ + childContextTypes: { + matrixClient: React.PropTypes.object, + }, + + getChildContext: function() { + return { + matrixClient: client, + }; + }, + + render: function() { + return ; + }, +}); + describe('MessagePanel', function () { - var sandbox; var clock = mockclock.clock(); var realSetTimeout = window.setTimeout; var events = mkEvents(); beforeEach(function() { test_utils.beforeEach(this); - sandbox = test_utils.stubClient(sandbox); - - var client = MatrixClientPeg.get(); + client = test_utils.createTestClient(); client.credentials = {userId: '@me:here'}; }); afterEach(function () { clock.uninstall(); - sandbox.restore(); }); function mkEvents() { @@ -61,7 +75,7 @@ describe('MessagePanel', function () { it('should show the events', function() { var res = TestUtils.renderIntoDocument( - + ); // just check we have the right number of tiles for now @@ -72,7 +86,7 @@ describe('MessagePanel', function () { it('should show the read-marker in the right place', function() { var res = TestUtils.renderIntoDocument( - ); @@ -96,7 +110,7 @@ describe('MessagePanel', function () { // first render with the RM in one place var mp = ReactDOM.render( - , parentDiv); @@ -112,7 +126,7 @@ describe('MessagePanel', function () { // now move the RM mp = ReactDOM.render( - , parentDiv); @@ -147,7 +161,7 @@ describe('MessagePanel', function () { // first render with the RM in one place var mp = ReactDOM.render( - , parentDiv); @@ -159,7 +173,7 @@ describe('MessagePanel', function () { // now move the RM mp = ReactDOM.render( - , parentDiv); @@ -175,7 +189,7 @@ describe('MessagePanel', function () { // and move the RM again mp = ReactDOM.render( - , parentDiv); diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index d8ded918f6..9c78a56359 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -33,6 +33,24 @@ var test_utils = require('test-utils'); var ROOM_ID = '!room:localhost'; var USER_ID = '@me:localhost'; +// wrap TimelinePanel with a component which provides the MatrixClient in the context. +const WrappedTimelinePanel = React.createClass({ + childContextTypes: { + matrixClient: React.PropTypes.object, + }, + + getChildContext: function() { + return { + matrixClient: peg.get(), + }; + }, + + render: function() { + return ; + }, +}); + + describe('TimelinePanel', function() { var sandbox; var timelineSet; @@ -105,11 +123,12 @@ describe('TimelinePanel', function() { } var scrollDefer; - var panel = ReactDOM.render( - {scrollDefer.resolve()}} + var rendered = ReactDOM.render( + {scrollDefer.resolve()}} />, parentDiv, ); + var panel = rendered.refs.panel; var scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( panel, "gm-scroll-view"); @@ -188,10 +207,11 @@ describe('TimelinePanel', function() { return q(true); }); - var panel = ReactDOM.render( - , + var rendered = ReactDOM.render( + , parentDiv ); + var panel = rendered.refs.panel; var messagePanel = ReactTestUtils.findRenderedComponentWithType( panel, sdk.getComponent('structures.MessagePanel')); @@ -236,14 +256,14 @@ describe('TimelinePanel', function() { console.log("added events to timeline"); var scrollDefer; - var panel = ReactDOM.render( - {scrollDefer.resolve()}} + var rendered = ReactDOM.render( + {scrollDefer.resolve()}} timelineCap={TIMELINE_CAP} />, parentDiv ); console.log("TimelinePanel rendered"); - + var panel = rendered.refs.panel; var messagePanel = ReactTestUtils.findRenderedComponentWithType( panel, sdk.getComponent('structures.MessagePanel')); var scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( diff --git a/test/test-utils.js b/test/test-utils.js index 1201daefe0..db405c2e1a 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -24,23 +24,49 @@ export function beforeEach(context) { * Stub out the MatrixClient, and configure the MatrixClientPeg object to * return it when get() is called. * + * TODO: once the components are updated to get their MatrixClients from + * the react context, we can get rid of this and just inject a test client + * via the context instead. + * * @returns {sinon.Sandbox}; remember to call sandbox.restore afterwards. */ export function stubClient() { var sandbox = sinon.sandbox.create(); - var client = { + var client = createTestClient(); + + // stub out the methods in MatrixClientPeg + // + // 'sandbox.restore()' doesn't work correctly on inherited methods, + // so we do this for each method + var methods = ['get', 'unset', 'replaceUsingCreds']; + for (var i = 0; i < methods.length; i++) { + sandbox.stub(peg, methods[i]); + } + // MatrixClientPeg.get() is called a /lot/, so implement it with our own + // fast stub function rather than a sinon stub + peg.get = function() { return client; }; + return sandbox; +} + +/** + * Create a stubbed-out MatrixClient + * + * @returns {object} MatrixClient stub + */ +export function createTestClient() { + return { getHomeserverUrl: sinon.stub(), getIdentityServerUrl: sinon.stub(), getPushActionsForEvent: sinon.stub(), - getRoom: sinon.stub().returns(this.mkStubRoom()), + getRoom: sinon.stub().returns(mkStubRoom()), getRooms: sinon.stub().returns([]), loginFlows: sinon.stub(), on: sinon.stub(), removeListener: sinon.stub(), isRoomEncrypted: sinon.stub().returns(false), - peekInRoom: sinon.stub().returns(q(this.mkStubRoom())), + peekInRoom: sinon.stub().returns(q(mkStubRoom())), paginateEventTimeline: sinon.stub().returns(q()), sendReadReceipt: sinon.stub().returns(q()), @@ -59,22 +85,8 @@ export function stubClient() { sendHtmlMessage: () => q({}), getSyncState: () => "SYNCING", }; - - // stub out the methods in MatrixClientPeg - // - // 'sandbox.restore()' doesn't work correctly on inherited methods, - // so we do this for each method - var methods = ['get', 'unset', 'replaceUsingCreds']; - for (var i = 0; i < methods.length; i++) { - sandbox.stub(peg, methods[i]); - } - // MatrixClientPeg.get() is called a /lot/, so implement it with our own - // fast stub function rather than a sinon stub - peg.get = function() { return client; }; - return sandbox; } - /** * Create an Event. * @param {Object} opts Values for the event. From b209cc551e0610624947347081044428f14dda85 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 15 Nov 2016 11:12:52 +0000 Subject: [PATCH 003/289] Update eventtiles when the events are decrypted Events are sometimes decrypted after they arrive, so add an eventlistener for it and update the tile. --- src/components/views/rooms/EventTile.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 375c00fed7..410ad2b953 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -146,6 +146,7 @@ module.exports = WithMatrixClient(React.createClass({ this._suppressReadReceiptAnimation = false; this.props.matrixClient.on("deviceVerificationChanged", this.onDeviceVerificationChanged); + this.props.mxEvent.on("Event.decrypted", this._onDecrypted); }, componentWillReceiveProps: function (nextProps) { @@ -170,6 +171,15 @@ module.exports = WithMatrixClient(React.createClass({ var client = this.props.matrixClient; client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); + this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted); + }, + + /** called when the event is decrypted after we show it. + */ + _onDecrypted: function() { + // we need to re-verify the sending device. + this._verifyEvent(this.props.mxEvent); + this.forceUpdate(); }, onDeviceVerificationChanged: function(userId, device) { From 13f28e53e1d7e55157e8ae00936b355579004f60 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Tue, 15 Nov 2016 11:22:39 +0000 Subject: [PATCH 004/289] Generate thumbnails when sending m.image and m.video messages. (#555) * Send a thumbnail when sending a m.image * Use the 'thumbnail_file' when displaying encrypted images * Whitespace * Generate thumbnails for m.video * Fix docstring, remove unused vars, use const * Don't change the upload promise behaviour * Polyfill for Canvas.toBlob to support older browsers * Lowercase for integer types in jsdoc --- package.json | 1 + src/ContentMessages.js | 260 +++++++++++++++----- src/components/views/messages/MImageBody.js | 25 +- 3 files changed, 219 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index 34bb87eb79..7d822fae89 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "babel-runtime": "^6.11.6", + "blueimp-canvas-to-blob": "^3.5.0", "browser-encrypt-attachment": "^0.1.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 28c28e875e..e2f9086f66 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -25,22 +25,87 @@ var Modal = require('./Modal'); var encrypt = require("browser-encrypt-attachment"); -function infoForImageFile(imageFile) { - var deferred = q.defer(); +// Polyfill for Canvas.toBlob API using Canvas.toDataURL +require("blueimp-canvas-to-blob"); + +const MAX_WIDTH = 800; +const MAX_HEIGHT = 600; + + +/** + * Create a thumbnail for a image DOM element. + * The image will be smaller than MAX_WIDTH and MAX_HEIGHT. + * The thumbnail will have the same aspect ratio as the original. + * Draws the element into a canvas using CanvasRenderingContext2D.drawImage + * Then calls Canvas.toBlob to get a blob object for the image data. + * + * Since it needs to calculate the dimensions of the source image and the + * thumbnailed image it returns an info object filled out with information + * about the original image and the thumbnail. + * + * @param {HTMLElement} element The element to thumbnail. + * @param {integer} inputWidth The width of the image in the input element. + * @param {integer} inputHeight the width of the image in the input element. + * @param {String} mimeType The mimeType to save the blob as. + * @return {Promise} A promise that resolves with an object with an info key + * and a thumbnail key. + */ +function createThumbnail(element, inputWidth, inputHeight, mimeType) { + const deferred = q.defer(); + + var targetWidth = inputWidth; + var targetHeight = inputHeight; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); + canvas.toBlob(function(thumbnail) { + deferred.resolve({ + info: { + thumbnail_info: { + w: targetWidth, + h: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + }, + w: inputWidth, + h: inputHeight, + }, + thumbnail: thumbnail + }); + }, mimeType); + + return deferred.promise; +} + +/** + * Load a file into a newly created image element. + * + * @param {File} file The file to load in an image element. + * @return {Promise} A promise that resolves with the html image element. + */ +function loadImageElement(imageFile) { + const deferred = q.defer(); // Load the file into an html element - var img = document.createElement("img"); + const img = document.createElement("img"); - var reader = new FileReader(); + const reader = new FileReader(); reader.onload = function(e) { img.src = e.target.result; - // Once ready, returns its size + // Once ready, create a thumbnail img.onload = function() { - deferred.resolve({ - w: img.width, - h: img.height - }); + deferred.resolve(img); }; img.onerror = function(e) { deferred.reject(e); @@ -54,22 +119,53 @@ function infoForImageFile(imageFile) { return deferred.promise; } -function infoForVideoFile(videoFile) { - var deferred = q.defer(); +/** + * Read the metadata for an image file and create and upload a thumbnail of the image. + * + * @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with. + * @param {String} roomId The ID of the room the image will be uploaded in. + * @param {File} The image to read and thumbnail. + * @return {Promise} A promise that resolves with the attachment info. + */ +function infoForImageFile(matrixClient, roomId, imageFile) { + var thumbnailType = "image/png"; + if (imageFile.type == "image/jpeg") { + thumbnailType = "image/jpeg"; + } + + var imageInfo; + return loadImageElement(imageFile).then(function(img) { + return createThumbnail(img, img.width, img.height, thumbnailType); + }).then(function(result) { + imageInfo = result.info; + return uploadFile(matrixClient, roomId, result.thumbnail); + }).then(function(result) { + imageInfo.thumbnail_url = result.url; + imageInfo.thumbnail_file = result.file; + return imageInfo; + }); +} + +/** + * Load a file into a newly created video element. + * + * @param {File} file The file to load in an video element. + * @return {Promise} A promise that resolves with the video image element. + */ +function loadVideoElement(videoFile) { + const deferred = q.defer(); // Load the file into an html element - var video = document.createElement("video"); + const video = document.createElement("video"); - var reader = new FileReader(); + const reader = new FileReader(); reader.onload = function(e) { video.src = e.target.result; // Once ready, returns its size - video.onloadedmetadata = function() { - deferred.resolve({ - w: video.videoWidth, - h: video.videoHeight - }); + // Wait until we have enough data to thumbnail the first frame. + video.onloadeddata = function() { + deferred.resolve(video); }; video.onerror = function(e) { deferred.reject(e); @@ -83,6 +179,30 @@ function infoForVideoFile(videoFile) { return deferred.promise; } +/** + * Read the metadata for a video file and create and upload a thumbnail of the video. + * + * @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with. + * @param {String} roomId The ID of the room the video will be uploaded to. + * @param {File} The video to read and thumbnail. + * @return {Promise} A promise that resolves with the attachment info. + */ +function infoForVideoFile(matrixClient, roomId, videoFile) { + const thumbnailType = "image/jpeg"; + + var videoInfo; + return loadVideoElement(videoFile).then(function(video) { + return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); + }).then(function(result) { + videoInfo = result.info; + return uploadFile(matrixClient, roomId, result.thumbnail); + }).then(function(result) { + videoInfo.thumbnail_url = result.url; + videoInfo.thumbnail_file = result.file; + return videoInfo; + }); +} + /** * Read the file as an ArrayBuffer. * @return {Promise} A promise that resolves with an ArrayBuffer when the file @@ -101,6 +221,48 @@ function readFileAsArrayBuffer(file) { return deferred.promise; } +/** + * Upload the file to the content repository. + * If the room is encrypted then encrypt the file before uploading. + * + * @param {MatrixClient} matrixClient The matrix client to upload the file with. + * @param {String} roomId The ID of the room being uploaded to. + * @param {File} file The file to upload. + * @return {Promise} A promise that resolves with an object. + * If the file is unencrypted then the object will have a "url" key. + * If the file is encrypted then the object will have a "file" key. + */ +function uploadFile(matrixClient, roomId, file) { + if (matrixClient.isRoomEncrypted(roomId)) { + // If the room is encrypted then encrypt the file before uploading it. + // First read the file into memory. + return readFileAsArrayBuffer(file).then(function(data) { + // Then encrypt the file. + return encrypt.encryptAttachment(data); + }).then(function(encryptResult) { + // Record the information needed to decrypt the attachment. + const encryptInfo = encryptResult.info; + // Pass the encrypted data as a Blob to the uploader. + const blob = new Blob([encryptResult.data]); + return matrixClient.uploadContent(blob).then(function(url) { + // If the attachment is encrypted then bundle the URL along + // with the information needed to decrypt the attachment and + // add it under a file key. + encryptInfo.url = url; + if (file.type) { + encryptInfo.mimetype = file.type; + } + return {"file": encryptInfo}; + }); + }); + } else { + return matrixClient.uploadContent(file).then(function(url) { + // If the attachment isn't encrypted then include the URL directly. + return {"url": url}; + }); + } +} + class ContentMessages { constructor() { @@ -109,7 +271,7 @@ class ContentMessages { } sendContentToRoom(file, roomId, matrixClient) { - var content = { + const content = { body: file.name, info: { size: file.size, @@ -121,13 +283,14 @@ class ContentMessages { content.info.mimetype = file.type; } - var def = q.defer(); + const def = q.defer(); if (file.type.indexOf('image/') == 0) { content.msgtype = 'm.image'; - infoForImageFile(file).then(imageInfo=>{ + infoForImageFile(matrixClient, roomId, file).then(imageInfo=>{ extend(content.info, imageInfo); def.resolve(); }, error=>{ + console.error(error); content.msgtype = 'm.file'; def.resolve(); }); @@ -136,7 +299,7 @@ class ContentMessages { def.resolve(); } else if (file.type.indexOf('video/') == 0) { content.msgtype = 'm.video'; - infoForVideoFile(file).then(videoInfo=>{ + infoForVideoFile(matrixClient, roomId, file).then(videoInfo=>{ extend(content.info, videoInfo); def.resolve(); }, error=>{ @@ -148,35 +311,23 @@ class ContentMessages { def.resolve(); } - var upload = { + const upload = { fileName: file.name, roomId: roomId, total: 0, - loaded: 0 + loaded: 0, }; this.inprogress.push(upload); dis.dispatch({action: 'upload_started'}); - var encryptInfo = null; var error; - var self = this; return def.promise.then(function() { - if (matrixClient.isRoomEncrypted(roomId)) { - // If the room is encrypted then encrypt the file before uploading it. - // First read the file into memory. - upload.promise = readFileAsArrayBuffer(file).then(function(data) { - // Then encrypt the file. - return encrypt.encryptAttachment(data); - }).then(function(encryptResult) { - // Record the information needed to decrypt the attachment. - encryptInfo = encryptResult.info; - // Pass the encrypted data as a Blob to the uploader. - var blob = new Blob([encryptResult.data]); - return matrixClient.uploadContent(blob); - }); - } else { - upload.promise = matrixClient.uploadContent(file); - } + upload.promise = uploadFile( + matrixClient, roomId, file + ).then(function(result) { + content.file = result.file; + content.url = result.url; + }); return upload.promise; }).progress(function(ev) { if (ev) { @@ -185,19 +336,6 @@ class ContentMessages { dis.dispatch({action: 'upload_progress', upload: upload}); } }).then(function(url) { - if (encryptInfo === null) { - // If the attachment isn't encrypted then include the URL directly. - content.url = url; - } else { - // If the attachment is encrypted then bundle the URL along - // with the information needed to decrypt the attachment and - // add it under a file key. - encryptInfo.url = url; - if (file.type) { - encryptInfo.mimetype = file.type; - } - content.file = encryptInfo; - } return matrixClient.sendMessage(roomId, content); }, function(err) { error = err; @@ -212,12 +350,12 @@ class ContentMessages { description: desc }); } - }).finally(function() { - var inprogressKeys = Object.keys(self.inprogress); - for (var i = 0; i < self.inprogress.length; ++i) { + }).finally(() => { + const inprogressKeys = Object.keys(this.inprogress); + for (var i = 0; i < this.inprogress.length; ++i) { var k = inprogressKeys[i]; - if (self.inprogress[k].promise === upload.promise) { - self.inprogress.splice(k, 1); + if (this.inprogress[k].promise === upload.promise) { + this.inprogress.splice(k, 1); break; } } @@ -235,7 +373,7 @@ class ContentMessages { } cancelUpload(promise) { - var inprogressKeys = Object.keys(this.inprogress); + const inprogressKeys = Object.keys(this.inprogress); var upload; for (var i = 0; i < this.inprogress.length; ++i) { var k = inprogressKeys[i]; diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 4a5dbab51e..6f46544bf4 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -24,6 +24,7 @@ import Modal from '../../../Modal'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import {decryptFile} from '../../../utils/DecryptFile'; +import q from 'q'; module.exports = React.createClass({ displayName: 'MImageBody', @@ -36,6 +37,7 @@ module.exports = React.createClass({ getInitialState: function() { return { decryptedUrl: null, + decryptedThumbnailUrl: null, }; }, @@ -94,7 +96,9 @@ module.exports = React.createClass({ _getThumbUrl: function() { const content = this.props.mxEvent.getContent(); if (content.file !== undefined) { - // TODO: Decrypt and use the thumbnail file if one is present. + if (this.state.decryptedThumbnailUrl) { + return this.state.decryptedThumbnailUrl; + } return this.state.decryptedUrl; } else { return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600); @@ -106,15 +110,24 @@ module.exports = React.createClass({ this.fixupHeight(); const content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { - decryptFile(content.file).done((url) => { - this.setState({ - decryptedUrl: url, + var thumbnailPromise = q(null); + if (content.info.thumbnail_file) { + thumbnailPromise = decryptFile( + content.info.thumbnail_file + ); + } + thumbnailPromise.then((thumbnailUrl) => { + decryptFile(content.file).then((contentUrl) => { + this.setState({ + decryptedUrl: contentUrl, + decryptedThumbnailUrl: thumbnailUrl, + }); }); - }, (err) => { + }).catch((err) => { console.warn("Unable to decrypt attachment: ", err) // Set a placeholder image when we can't decrypt the image. this.refs.image.src = "img/warning.svg"; - }); + }).done(); } }, From a3d4ed5aee53942561d49350c783e3d9a2597cfd Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 15 Nov 2016 12:31:17 +0000 Subject: [PATCH 005/289] Show an open padlock for unencrypted rooms Also, add a tooltip, and enlarge the img to 12px wide, because the open padlock looked silly at 10px and they both look fine at 12px --- src/components/views/rooms/MessageComposer.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index e78cf0b13e..ee9c49d52a 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -222,13 +222,22 @@ export default class MessageComposer extends React.Component {
); + let e2eimg, e2etitle; + if (MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId)) { // FIXME: show a /!\ if there are untrusted devices in the room... - controls.push( - Encrypted room - ); + e2eimg = 'img/e2e-verified.svg'; + e2etitle = 'Encrypted room'; + } else { + e2eimg = 'img/e2e-unencrypted.svg'; + e2etitle = 'Unencrypted room'; } + controls.push( + {e2etitle} + ); var callButton, videoCallButton, hangupButton; if (this.props.callState && this.props.callState !== 'ended') { hangupButton = From 595493e5bb3354d81654bbcda136c4cc68f30c7a Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Tue, 15 Nov 2016 15:54:14 +0000 Subject: [PATCH 006/289] Clean up MFileBody.presentableTextForFile --- src/components/views/messages/MFileBody.js | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index e8c97e5f44..3f29915561 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -32,24 +32,30 @@ module.exports = React.createClass({ }; }, + /** + * Extracts a human readable label for the file attachment to use as + * link text. + * + * @params {Object} content The "content" key of the matrix event. + * @return {string} the human readable link text for the attachment. + */ presentableTextForFile: function(content) { var linkText = 'Attachment'; if (content.body && content.body.length > 0) { + // The content body should be the name of the file including a + // file extension. linkText = content.body; } - var additionals = []; - if (content.info) { - // if (content.info.mimetype && content.info.mimetype.length > 0) { - // additionals.push(content.info.mimetype); - // } - if (content.info.size) { - additionals.push(filesize(content.info.size)); - } - } - - if (additionals.length > 0) { - linkText += ' (' + additionals.join(', ') + ')'; + if (content.info && content.info.size) { + // If we know the size of the file then add it as human readable + // string to the end of the link text so that the user knows how + // big a file they are downloading. + // The content.info also contains a MIME-type but we don't display + // it since it is "ugly", users generally aren't aware what it + // means and the type of the attachment can usually be inferrered + // from the file extension. + linkText += ' (' + filesize(content.info.size) + ')'; } return linkText; }, From 6ccc825f0d833c0e0c6f92ccce1e8bd50b0c2f81 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Wed, 16 Nov 2016 14:16:51 +0000 Subject: [PATCH 007/289] Cache the tinted SVGs for MFileBody as data URLs (#559) * Use a list of callbacks for things that need tinting. Rather than gutwrenching the internals of TintableSVG inside the Tinter. * Share a data: url for the tinted download svg in MFileBody * Check image exists before tinting * Add comments * Use fetch+DomParser rather than XMLHttpRequest * Remove comment about XMLHttpRequest --- src/Tinter.js | 29 +++++++--- src/components/views/elements/TintableSvg.js | 9 ++++ src/components/views/messages/MFileBody.js | 56 +++++++++++++++++++- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/Tinter.js b/src/Tinter.js index 336fb90fa2..534a1d810b 100644 --- a/src/Tinter.js +++ b/src/Tinter.js @@ -153,7 +153,25 @@ function rgbToHex(rgb) { return '#' + (0x1000000 + val).toString(16).slice(1) } +// List of functions to call when the tint changes. +const tintables = []; + module.exports = { + /** + * Register a callback to fire when the tint changes. + * This is used to rewrite the tintable SVGs with the new tint. + * + * It's not possible to unregister a tintable callback. So this can only be + * used to register a static callback. If a set of tintables will change + * over time then the best bet is to register a single callback for the + * entire set. + * + * @param {Function} tintable Function to call when the tint changes. + */ + registerTintable : function(tintable) { + tintables.push(tintable); + }, + tint: function(primaryColor, secondaryColor, tertiaryColor) { if (!cached) { @@ -201,12 +219,9 @@ module.exports = { // tell all the SVGs to go fix themselves up // we don't do this as a dispatch otherwise it will visually lag - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - if (TintableSvg.mounts) { - Object.keys(TintableSvg.mounts).forEach((id) => { - TintableSvg.mounts[id].tint(); - }); - } + tintables.forEach(function(tintable) { + tintable(); + }); }, // XXX: we could just move this all into TintableSvg, but as it's so similar @@ -265,5 +280,5 @@ module.exports = { svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]); } if (DEBUG) console.log("applySvgFixups end"); - }, + } }; diff --git a/src/components/views/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js index e8be5f3415..0157131506 100644 --- a/src/components/views/elements/TintableSvg.js +++ b/src/components/views/elements/TintableSvg.js @@ -74,4 +74,13 @@ var TintableSvg = React.createClass({ } }); +// Register with the Tinter so that we will be told if the tint changes +Tinter.registerTintable(function() { + if (TintableSvg.mounts) { + Object.keys(TintableSvg.mounts).forEach((id) => { + TintableSvg.mounts[id].tint(); + }); + } +}); + module.exports = TintableSvg; diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 3f29915561..60b0653f1f 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -21,7 +21,42 @@ import filesize from 'filesize'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import {decryptFile} from '../../../utils/DecryptFile'; +import Tinter from '../../../Tinter'; +import 'isomorphic-fetch'; +import q from 'q'; +// A cached tinted copy of "img/download.svg" +var tintedDownloadImageURL; +// Track a list of mounted MFileBody instances so that we can update +// the "img/download.svg" when the tint changes. +var nextMountId = 0; +const mounts = {}; + +/** + * Updates the tinted copy of "img/download.svg" when the tint changes. + */ +function updateTintedDownloadImage() { + // Download the svg as an XML document. + // We could cache the XML response here, but since the tint rarely changes + // it's probably not worth it. + q(fetch("img/download.svg")).then(function(response) { + return response.text(); + }).then(function(svgText) { + const svg = new DOMParser().parseFromString(svgText, "image/svg+xml"); + // Apply the fixups to the XML. + const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]); + Tinter.applySvgFixups(fixups); + // Encoded the fixed up SVG as a data URL. + const svgString = new XMLSerializer().serializeToString(svg); + tintedDownloadImageURL = "data:image/svg+xml;base64," + window.btoa(svgString); + // Notify each mounted MFileBody that the URL has changed. + Object.keys(mounts).forEach(function(id) { + mounts[id].tint(); + }); + }).done(); +} + +Tinter.registerTintable(updateTintedDownloadImage); module.exports = React.createClass({ displayName: 'MFileBody', @@ -70,6 +105,12 @@ module.exports = React.createClass({ }, componentDidMount: function() { + // Add this to the list of mounted components to receive notifications + // when the tint changes. + this.id = nextMountId++; + mounts[this.id] = this; + this.tint(); + // Check whether we need to decrypt the file content. const content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { decryptFile(content.file).done((url) => { @@ -84,12 +125,23 @@ module.exports = React.createClass({ } }, + componentWillUnmount: function() { + // Remove this from the list of mounted components + delete mounts[this.id]; + }, + + tint: function() { + // Update our tinted copy of "img/download.svg" + if (this.refs.downloadImage) { + this.refs.downloadImage.src = tintedDownloadImageURL; + } + }, + render: function() { const content = this.props.mxEvent.getContent(); const text = this.presentableTextForFile(content); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); if (content.file !== undefined && this.state.decryptedUrl === null) { // Need to decrypt the attachment @@ -155,7 +207,7 @@ module.exports = React.createClass({
- + Download {text}
From b718f1542c188a59b668d22a9434d8b77532d678 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 16 Nov 2016 14:25:52 +0000 Subject: [PATCH 008/289] Fix infinite loop when there are a lot of invisible events (#554) Instead of using a window of a fixed number of events, unpaginate based on the distance of the viewport from the end of the scroll range. The ScrollPanel uses the scrollTokens to convey to its parent (the TimelinePanel, in this case) the point to unpaginate up to. The TimelinePanel then takes a chunk of events off the front or back of `this.state.events` using `timelineWindow.unpaginate`. Fixes https://github.com/vector-im/vector-web/issues/2020 --- src/components/structures/MessagePanel.js | 8 +- src/components/structures/ScrollPanel.js | 102 +++++++++++++++++- src/components/structures/TimelinePanel.js | 29 ++++- .../structures/TimelinePanel-test.js | 4 +- 4 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index ef4d10e66b..ee9d5f1ff4 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -321,8 +321,11 @@ module.exports = React.createClass({ } ret.push( - - {eventTiles} + + {eventTiles} ); continue; @@ -564,6 +567,7 @@ module.exports = React.createClass({ onScroll={ this.props.onScroll } onResize={ this.onResize } onFillRequest={ this.props.onFillRequest } + onUnfillRequest={ this.props.onUnfillRequest } style={ style } stickyBottom={ this.props.stickyBottom }> {topSpinner} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 6970cd190c..36dbf041e8 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -23,6 +23,10 @@ var KeyCode = require('../../KeyCode'); var DEBUG_SCROLL = false; // var DEBUG_SCROLL = true; +// The amount of extra scroll distance to allow prior to unfilling. +// See _getExcessHeight. +const UNPAGINATION_PADDING = 500; + if (DEBUG_SCROLL) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); @@ -101,6 +105,17 @@ module.exports = React.createClass({ */ onFillRequest: React.PropTypes.func, + /* onUnfillRequest(backwards): a callback which is called on scroll when + * there are children elements that are far out of view and could be removed + * without causing pagination to occur. + * + * This function should accept a boolean, which is true to indicate the back/top + * of the panel and false otherwise, and a scroll token, which refers to the + * first element to remove if removing from the front/bottom, and last element + * to remove if removing from the back/top. + */ + onUnfillRequest: React.PropTypes.func, + /* onScroll: a callback which is called whenever any scroll happens. */ onScroll: React.PropTypes.func, @@ -124,6 +139,7 @@ module.exports = React.createClass({ stickyBottom: true, startAtBottom: true, onFillRequest: function(backwards) { return q(false); }, + onUnfillRequest: function(backwards, scrollToken) {}, onScroll: function() {}, }; }, @@ -226,6 +242,46 @@ module.exports = React.createClass({ return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3; }, + // returns the vertical height in the given direction that can be removed from + // the content box (which has a height of scrollHeight, see checkFillState) without + // pagination occuring. + // + // padding* = UNPAGINATION_PADDING + // + // ### Region determined as excess. + // + // .---------. - - + // |#########| | | + // |#########| - | scrollTop | + // | | | padding* | | + // | | | | | + // .-+---------+-. - - | | + // : | | : | | | + // : | | : | clientHeight | | + // : | | : | | | + // .-+---------+-. - - | + // | | | | | | + // | | | | | clientHeight | scrollHeight + // | | | | | | + // `-+---------+-' - | + // : | | : | | + // : | | : | clientHeight | + // : | | : | | + // `-+---------+-' - - | + // | | | padding* | + // | | | | + // |#########| - | + // |#########| | + // `---------' - + _getExcessHeight: function(backwards) { + var sn = this._getScrollNode(); + if (backwards) { + return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING; + } else { + return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; + } + }, + // check the scroll state and send out backfill requests if necessary. checkFillState: function() { if (this.unmounted) { @@ -268,6 +324,47 @@ module.exports = React.createClass({ } }, + // check if unfilling is possible and send an unfill request if necessary + _checkUnfillState: function(backwards) { + let excessHeight = this._getExcessHeight(backwards); + if (excessHeight <= 0) { + return; + } + var itemlist = this.refs.itemlist; + var tiles = itemlist.children; + + // The scroll token of the first/last tile to be unpaginated + let markerScrollToken = null; + + // Subtract clientHeights to simulate the events being unpaginated whilst counting + // the events to be unpaginated. + if (backwards) { + // Iterate forwards from start of tiles, subtracting event tile height + let i = 0; + while (i < tiles.length && excessHeight > tiles[i].clientHeight) { + excessHeight -= tiles[i].clientHeight; + if (tiles[i].dataset.scrollToken) { + markerScrollToken = tiles[i].dataset.scrollToken; + } + i++; + } + } else { + // Iterate backwards from end of tiles, subtracting event tile height + let i = tiles.length - 1; + while (i > 0 && excessHeight > tiles[i].clientHeight) { + excessHeight -= tiles[i].clientHeight; + if (tiles[i].dataset.scrollToken) { + markerScrollToken = tiles[i].dataset.scrollToken; + } + i--; + } + } + + if (markerScrollToken) { + this.props.onUnfillRequest(backwards, markerScrollToken); + } + }, + // check if there is already a pending fill request. If not, set one off. _maybeFill: function(backwards) { var dir = backwards ? 'b' : 'f'; @@ -285,7 +382,7 @@ module.exports = React.createClass({ this._pendingFillRequests[dir] = true; var fillPromise; try { - fillPromise = this.props.onFillRequest(backwards); + fillPromise = this.props.onFillRequest(backwards); } catch (e) { this._pendingFillRequests[dir] = false; throw e; @@ -294,6 +391,9 @@ module.exports = React.createClass({ q.finally(fillPromise, () => { this._pendingFillRequests[dir] = false; }).then((hasMoreResults) => { + // Unpaginate once filling is complete + this._checkUnfillState(!backwards); + debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults); if (hasMoreResults) { // further pagination requests have been disabled until now, so diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 06e38ffb5a..7101a8b80c 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -108,7 +108,9 @@ var TimelinePanel = React.createClass({ getDefaultProps: function() { return { - timelineCap: 250, + // By default, disable the timelineCap in favour of unpaginating based on + // event tile heights. (See _unpaginateEvents) + timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', }; }, @@ -245,6 +247,30 @@ var TimelinePanel = React.createClass({ } }, + onMessageListUnfillRequest: function(backwards, scrollToken) { + let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; + debuglog("TimelinePanel: unpaginating events in direction", dir); + + // All tiles are inserted by MessagePanel to have a scrollToken === eventId + let eventId = scrollToken; + + let marker = this.state.events.findIndex( + (ev) => { + return ev.getId() === eventId; + } + ); + + let count = backwards ? marker + 1 : this.state.events.length - marker; + + if (count > 0) { + debuglog("TimelinePanel: Unpaginating", count, "in direction", dir); + this._timelineWindow._unpaginate(count, backwards); + this.setState({ + events: this._getEvents(), + }); + } + }, + // set off a pagination request. onMessageListFillRequest: function(backwards) { var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; @@ -984,6 +1010,7 @@ var TimelinePanel = React.createClass({ stickyBottom={ stickyBottom } onScroll={ this.onMessageListScroll } onFillRequest={ this.onMessageListFillRequest } + onUnfillRequest={ this.onMessageListUnfillRequest } opacity={ this.props.opacity } className={ this.props.className } tileShape={ this.props.tileShape } diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index 9c78a56359..a6d2e3f184 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -321,7 +321,7 @@ describe('TimelinePanel', function() { expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); var events = scryEventTiles(panel); expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]); - expect(events.length).toEqual(TIMELINE_CAP); + expect(events.length).toBeLessThanOrEqualTo(TIMELINE_CAP); // we should now be able to scroll down, and paginate in the other // direction. @@ -339,7 +339,7 @@ describe('TimelinePanel', function() { expect(messagePanel.props.suppressFirstDateSeparator).toBe(true); var events = scryEventTiles(panel); - expect(events.length).toEqual(TIMELINE_CAP); + expect(events.length).toBeLessThanOrEqualTo(TIMELINE_CAP); // we don't really know what the first event tile will be, since that // depends on how much the timelinepanel decides to paginate. From beecbc7cd720dc3e255220d314a79d9dd362c566 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 16 Nov 2016 14:42:30 +0000 Subject: [PATCH 009/289] Fix join/part collapsing regressions (#553) * Fix join/part collapsing regressions * Simplify loop * Explain e,e * Explain return null in _renderSummary * Kill it properly * Move . to _renderSummary * Only use the first and last events to decide whether a net change has occured * Do not sort events by TS before summarising * fix loop and comment * remove data-number-events * Better explanation comment in _renderSummary * Less tortuous comment --- src/components/structures/MessagePanel.js | 32 ++++++++++----- src/components/views/avatars/MemberAvatar.js | 4 +- .../views/elements/MemberEventListSummary.js | 41 ++++++++++++++----- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index ee9d5f1ff4..1e0db2321d 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -229,6 +229,7 @@ module.exports = React.createClass({ _getEventTiles: function() { var EventTile = sdk.getComponent('rooms.EventTile'); + var DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); this.eventNodes = {}; @@ -278,8 +279,8 @@ module.exports = React.createClass({ var isMembershipChange = (e) => e.getType() === 'm.room.member' - && ['join', 'leave'].indexOf(e.event.content.membership) !== -1 - && (!e.event.prev_content || e.event.content.membership !== e.event.prev_content.membership); + && ['join', 'leave'].indexOf(e.getContent().membership) !== -1 + && (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership); for (i = 0; i < this.props.events.length; i++) { var mxEv = this.props.events[i]; @@ -294,23 +295,32 @@ module.exports = React.createClass({ // Wrap consecutive member events in a ListSummary if (isMembershipChange(mxEv)) { - let summarisedEvents = [mxEv]; - i++; - for (;i < this.props.events.length; i++) { - let collapsedMxEv = this.props.events[i]; + let ts1 = mxEv.getTs(); - if (!isMembershipChange(collapsedMxEv)) { - i--; + if (this._wantsDateSeparator(prevEvent, ts1)) { + let dateSeparator =
  • ; + ret.push(dateSeparator); + } + + let summarisedEvents = [mxEv]; + for (;i + 1 < this.props.events.length; i++) { + let collapsedMxEv = this.props.events[i + 1]; + + if (!isMembershipChange(collapsedMxEv) || + this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getTs())) { break; } summarisedEvents.push(collapsedMxEv); } - // At this point, i = this.props.events.length OR i = the index of the last - // MembershipChange in a sequence of MembershipChanges + // At this point, i = the index of the last event in the summary sequence let eventTiles = summarisedEvents.map( (e) => { - let ret = this._getTilesForEvent(prevEvent, e); + // In order to prevent DateSeparators from appearing in the expanded form + // of MemberEventListSummary, render each member event as if the previous + // one was itself. This way, the timestamp of the previous event === the + // timestamp of the current event, and no DateSeperator is inserted. + let ret = this._getTilesForEvent(e, e); prevEvent = e; return ret; } diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index 161c37ef55..1f6736138d 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -69,9 +69,9 @@ module.exports = React.createClass({ render: function() { var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - var {member, onClick, ...otherProps} = this.props; + var {member, onClick, viewUserOnClick, ...otherProps} = this.props; - if (this.props.viewUserOnClick) { + if (viewUserOnClick) { onClick = () => { dispatcher.dispatch({ action: 'view_user', diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index f50f56ffb4..4a4e8bfd1e 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -107,10 +107,20 @@ module.exports = React.createClass({
    ); } + + // The joinEvents and leaveEvents are representative of the net movement + // per-user, and so it is possible that the total net movement is nil, + // whilst there are some events in the expanded list. If the total net + // movement is nil, then neither joinSummary nor leaveSummary will be + // truthy, so return null. + if (!joinSummary && !leaveSummary) { + return null; + } + return ( {joinSummary}{joinSummary && leaveSummary?'; ':''} - {leaveSummary} + {leaveSummary}.  ); }, @@ -140,15 +150,24 @@ module.exports = React.createClass({ // Reorder events so that joins come before leaves let eventsToRender = this.props.events; - // Filter out those who joined, then left - let filteredEvents = eventsToRender.filter( - (e) => { - return eventsToRender.filter( - (e2) => { - return e.getSender() === e2.getSender() - && e.event.content.membership !== e2.event.content.membership; - } - ).length === 0; + // Create an array of events that are not "cancelled-out" by another + // A join of sender S is cancelled out by a leave of sender S etc. + let filteredEvents = []; + let senders = new Set(eventsToRender.map((e) => e.getSender())); + senders.forEach( + (userId) => { + // Only push the last event if it isn't the same membership as the first + + let userEvents = eventsToRender.filter((e) => { + return e.getSender() === userId; + }); + + let firstEvent = userEvents[0]; + let lastEvent = userEvents[userEvents.length - 1]; + + if (firstEvent.getContent().membership !== lastEvent.getContent().membership) { + filteredEvents.push(lastEvent); + } } ); @@ -194,7 +213,7 @@ module.exports = React.createClass({ {avatars} - {summary}{joinAndLeft? '. ' + joinAndLeft + ' ' + noun + ' joined and left' : ''} + {summary}{joinAndLeft ? joinAndLeft + ' ' + noun + ' joined and left' : ''}   {toggleButton} From 324448563acbb07b58944409106c4f097524ade9 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 16 Nov 2016 16:07:19 +0000 Subject: [PATCH 010/289] If only one mevent, return than one Also, the net change of nil is detected as having the first and last events being _different_. The summary should only include those that have their first and last events being the _same_ because that is a net change (within the block of member events). --- src/components/views/elements/MemberEventListSummary.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 4a4e8bfd1e..b460a3513e 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -162,10 +162,15 @@ module.exports = React.createClass({ return e.getSender() === userId; }); + if (userEvents.length === 1) { + filteredEvents.push(userEvents[0]); + return; + } + let firstEvent = userEvents[0]; let lastEvent = userEvents[userEvents.length - 1]; - if (firstEvent.getContent().membership !== lastEvent.getContent().membership) { + if (firstEvent.getContent().membership === lastEvent.getContent().membership) { filteredEvents.push(lastEvent); } } From 3618b4998238044cffe72ddc256ff46093003c5e Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 16 Nov 2016 16:10:23 +0000 Subject: [PATCH 011/289] Use new js-sdk public unpaginate --- src/components/structures/TimelinePanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 7101a8b80c..3d204be510 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -264,7 +264,7 @@ var TimelinePanel = React.createClass({ if (count > 0) { debuglog("TimelinePanel: Unpaginating", count, "in direction", dir); - this._timelineWindow._unpaginate(count, backwards); + this._timelineWindow.unpaginate(count, backwards); this.setState({ events: this._getEvents(), }); From 7e88f0083ddf9c68cd389448a3bd5689b7be2c1d Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 16 Nov 2016 16:26:24 +0000 Subject: [PATCH 012/289] Fix more join-part collapsing regressions Use the previous content of the first event known for a user in a block of membership changes. This means single events are not special cased. --- .../views/elements/MemberEventListSummary.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index b460a3513e..69bcd7c203 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -156,21 +156,21 @@ module.exports = React.createClass({ let senders = new Set(eventsToRender.map((e) => e.getSender())); senders.forEach( (userId) => { - // Only push the last event if it isn't the same membership as the first - let userEvents = eventsToRender.filter((e) => { return e.getSender() === userId; }); - if (userEvents.length === 1) { - filteredEvents.push(userEvents[0]); - return; - } - + // NB: These may be the same event, in which case the lastEvent is used + // because prev_content should != content let firstEvent = userEvents[0]; let lastEvent = userEvents[userEvents.length - 1]; - if (firstEvent.getContent().membership === lastEvent.getContent().membership) { + // Membership BEFORE eventsToRender + let previousMembership = firstEvent.getPrevContent().membership || "leave"; + + // Otherwise, if the last membership event differs from previousMembership, + // use that. + if (previousMembership !== lastEvent.getContent().membership) { filteredEvents.push(lastEvent); } } From 3ef0a08493ea342b62a3487ae57a41128edbade3 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 16 Nov 2016 17:58:43 +0000 Subject: [PATCH 013/289] Pin to 15.3.2 because 15.4.0 is broken and fails with missing CSSProperty file --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 7d822fae89..f89ebca20e 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,9 @@ "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", - "react": "^15.2.1", - "react-addons-css-transition-group": "^15.2.1", - "react-dom": "^15.2.1", + "react": "15.3.2", + "react-addons-css-transition-group": "15.3.2", + "react-dom": "15.3.2", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.11.1", "velocity-vector": "vector-im/velocity#059e3b2", @@ -100,7 +100,7 @@ "karma-webpack": "^1.7.0", "mocha": "^2.4.5", "phantomjs-prebuilt": "^2.1.7", - "react-addons-test-utils": "^15.0.1", + "react-addons-test-utils": "15.3.2", "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^1.17.3", From d3c97921269911ce31f5c9ea6a22e5aa30d51963 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 17 Nov 2016 10:15:10 +0000 Subject: [PATCH 014/289] Remove pin on react 15.3 and bump draft-js-export-html instead draft-js-export-html have fixed the incompatibility with react 15.4, so we can now update. --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index f89ebca20e..bca4fa99f5 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "browser-request": "^0.3.3", "classnames": "^2.1.2", "draft-js": "^0.8.1", - "draft-js-export-html": "^0.4.0", + "draft-js-export-html": "^0.5.0", "draft-js-export-markdown": "^0.2.0", "emojione": "2.2.3", "filesize": "^3.1.2", @@ -62,9 +62,9 @@ "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", - "react": "15.3.2", + "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", - "react-dom": "15.3.2", + "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.11.1", "velocity-vector": "vector-im/velocity#059e3b2", @@ -100,7 +100,7 @@ "karma-webpack": "^1.7.0", "mocha": "^2.4.5", "phantomjs-prebuilt": "^2.1.7", - "react-addons-test-utils": "15.3.2", + "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^1.17.3", From 341175ea5847c2b15f2515ed7848f85637a170e0 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 18 Nov 2016 11:15:14 +0000 Subject: [PATCH 015/289] Fix pagination issue where recent events are lost (#563) Fix pagination issue where recent events are lost Scrolling up a few pages followed by scrolling down to the most recent events previously caused some events to go missing. A test has been modified in conjunction with this fix to make sure that this failure mode is tested for in future. This commit should fix the issue, and the most recent events should be paginated back in. --- src/components/structures/TimelinePanel.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 3d204be510..4311f683df 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -265,7 +265,11 @@ var TimelinePanel = React.createClass({ if (count > 0) { debuglog("TimelinePanel: Unpaginating", count, "in direction", dir); this._timelineWindow.unpaginate(count, backwards); + + // We can now paginate in the unpaginated direction + const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate'; this.setState({ + [canPaginateKey]: true, events: this._getEvents(), }); } From cf411556105032b8fc9e2d2b5531cb32beb524e6 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 18 Nov 2016 11:44:45 +0000 Subject: [PATCH 016/289] Test TimelinePanel canForwardPaginate (#561) Fix scroll up, down pagination test NB: this test may not fail on Travis, although it did fail locally without a fix: #563. Once the test has scrolled the panel to the top, to the earliest events, it should be able to forward paginate, because some degree of unpagination occurs. This does assume that unpagination will occur when scrolling to the beginning of the events and that unpagination should allow pagination again in the same direction. Instead of checking that the first event is no longer the first event (varies due to unpagination), check instead that the most recent event can be seen when scrolling all the way down to the bottom of the TimelinePanel. Scrolling past the bottom of content seems to have strange behaviour, which isn't a useful part of the test. So now the test will scroll down until the last event instead. --- .../structures/TimelinePanel-test.js | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index a6d2e3f184..52b9185f18 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -238,9 +238,8 @@ describe('TimelinePanel', function() { }, 0); }); - it("should let you scroll down again after you've scrolled up", function(done) { - var TIMELINE_CAP = 100; // needs to be more than we can fit in the div - var N_EVENTS = 120; // needs to be more than TIMELINE_CAP + it("should let you scroll down to the bottom after you've scrolled up", function(done) { + var N_EVENTS = 120; // the number of events to simulate being added to the timeline // sadly, loading all those events takes a while this.timeout(N_EVENTS * 50); @@ -257,9 +256,7 @@ describe('TimelinePanel', function() { var scrollDefer; var rendered = ReactDOM.render( - {scrollDefer.resolve()}} - timelineCap={TIMELINE_CAP} - />, + {scrollDefer.resolve()}}/>, parentDiv ); console.log("TimelinePanel rendered"); @@ -273,6 +270,7 @@ describe('TimelinePanel', function() { // the TimelinePanel fires a scroll event var awaitScroll = function() { scrollDefer = q.defer(); + return scrollDefer.promise.then(() => { console.log("got scroll event; scrollTop now " + scrollingDiv.scrollTop); @@ -306,6 +304,27 @@ describe('TimelinePanel', function() { }); } + function scrollDown() { + // Scroll the bottom of the viewport to the bottom of the panel + setScrollTop(scrollingDiv.scrollHeight - scrollingDiv.clientHeight); + console.log("scrolling down... " + scrollingDiv.scrollTop); + return awaitScroll().delay(0).then(() => { + + let eventTiles = scryEventTiles(panel); + let events = timeline.getEvents(); + + let lastEventInPanel = eventTiles[eventTiles.length - 1].props.mxEvent; + let lastEventInTimeline = events[events.length - 1]; + + // Scroll until the last event in the panel = the last event in the timeline + if(lastEventInPanel.getId() !== lastEventInTimeline.getId()) { + // need to go further + return scrollDown(); + } + console.log("paginated to end."); + }); + } + // let the first round of pagination finish off awaitScroll().then(() => { // we should now have loaded the first few events @@ -321,31 +340,22 @@ describe('TimelinePanel', function() { expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); var events = scryEventTiles(panel); expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]); - expect(events.length).toBeLessThanOrEqualTo(TIMELINE_CAP); - // we should now be able to scroll down, and paginate in the other - // direction. - setScrollTop(scrollingDiv.scrollHeight); - scrollingDiv.scrollTop = scrollingDiv.scrollHeight; + // Expect to be able to paginate forwards, having unpaginated a few events + expect(panel.state.canForwardPaginate).toBe(true); - // the delay() below is a heinous hack to deal with the fact that, - // without it, we may or may not get control back before the - // forward pagination completes. The delay means that it should - // have completed. - return awaitScroll().delay(0); + // scroll all the way to the bottom + return scrollDown(); }).then(() => { expect(messagePanel.props.backPaginating).toBe(false); expect(messagePanel.props.forwardPaginating).toBe(false); - expect(messagePanel.props.suppressFirstDateSeparator).toBe(true); var events = scryEventTiles(panel); - expect(events.length).toBeLessThanOrEqualTo(TIMELINE_CAP); - // we don't really know what the first event tile will be, since that - // depends on how much the timelinepanel decides to paginate. - // - // just check that the first tile isn't event 0. - expect(events[0].props.mxEvent).toNotBe(timeline.getEvents()[0]); + // Expect to be able to see the most recent event + var lastEventInPanel = events[events.length - 1].props.mxEvent; + var lastEventInTimeline = timeline.getEvents()[timeline.getEvents().length - 1]; + expect(lastEventInPanel.getContent()).toBe(lastEventInTimeline.getContent()); console.log("done"); }).done(done, done); From 7cb3c0935b597a28a07b60bed7edbe9b7bc9a93c Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Fri, 18 Nov 2016 20:08:26 +0000 Subject: [PATCH 017/289] Make the spinner smaller, don't decrypt files as eagerly (#564) --- src/components/views/messages/MAudioBody.js | 26 ++++++++---- src/components/views/messages/MFileBody.js | 45 ++++++++++++++------- src/components/views/messages/MImageBody.js | 26 ++++++++++-- src/components/views/messages/MVideoBody.js | 26 ++++++++++-- 4 files changed, 93 insertions(+), 30 deletions(-) diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index ff753621c7..393bf549ae 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -29,6 +29,7 @@ export default class MAudioBody extends React.Component { this.state = { playing: false, decryptedUrl: null, + error: null, } } onPlayToggle() { @@ -51,27 +52,38 @@ export default class MAudioBody extends React.Component { if (content.file !== undefined && this.state.decryptedUrl === null) { decryptFile(content.file).done((url) => { this.setState({ - decryptedUrl: url + decryptedUrl: url, }); }, (err) => { - console.warn("Unable to decrypt attachment: ", err) - // Set a placeholder image when we can't decrypt the image. - this.refs.image.src = "img/warning.svg"; + console.warn("Unable to decrypt attachment: ", err); + this.setState({ + error: err, + }); }); } } render() { + const content = this.props.mxEvent.getContent(); + if (this.state.error !== null) { + return ( + + + Error decrypting audio + + ); + } + if (content.file !== undefined && this.state.decryptedUrl === null) { // Need to decrypt the attachment // The attachment is decrypted in componentDidMount. - // For now add an img tag with a spinner. + // For now add an img tag with a 16x16 spinner. + // Not sure how tall the audio player is so not sure how tall it should actually be. return ( - {content.body} + {content.body} ); } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 60b0653f1f..32b53e3b7d 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -24,6 +24,8 @@ import {decryptFile} from '../../../utils/DecryptFile'; import Tinter from '../../../Tinter'; import 'isomorphic-fetch'; import q from 'q'; +import Modal from '../../../Modal'; + // A cached tinted copy of "img/download.svg" var tintedDownloadImageURL; @@ -110,19 +112,6 @@ module.exports = React.createClass({ this.id = nextMountId++; mounts[this.id] = this; this.tint(); - // Check whether we need to decrypt the file content. - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined && this.state.decryptedUrl === null) { - decryptFile(content.file).done((url) => { - this.setState({ - decryptedUrl: url, - }); - }, (err) => { - console.warn("Unable to decrypt attachment: ", err) - // Set a placeholder image when we can't decrypt the image. - this.refs.image.src = "img/warning.svg"; - }); - } }, componentWillUnmount: function() { @@ -141,16 +130,42 @@ module.exports = React.createClass({ const content = this.props.mxEvent.getContent(); const text = this.presentableTextForFile(content); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); if (content.file !== undefined && this.state.decryptedUrl === null) { + var decrypting = false; + const decrypt = () => { + if (decrypting) { + return false; + } + decrypting = true; + decryptFile(content.file).then((url) => { + this.setState({ + decryptedUrl: url, + }); + }).catch((err) => { + console.warn("Unable to decrypt attachment: ", err) + // Set a placeholder image when we can't decrypt the image + Modal.createDialog(ErrorDialog, { + description: "Error decrypting attachment" + }); + }).finally(function() { + decrypting = false; + }).done(); + return false; + }; + // Need to decrypt the attachment // The attachment is decrypted in componentDidMount. // For now add an img tag with a spinner. return ( - {content.body} + ); } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 6f46544bf4..63fd119335 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -38,6 +38,7 @@ module.exports = React.createClass({ return { decryptedUrl: null, decryptedThumbnailUrl: null, + error: null }; }, @@ -124,9 +125,11 @@ module.exports = React.createClass({ }); }); }).catch((err) => { - console.warn("Unable to decrypt attachment: ", err) + console.warn("Unable to decrypt attachment: ", err); // Set a placeholder image when we can't decrypt the image. - this.refs.image.src = "img/warning.svg"; + this.setState({ + error: err, + }); }).done(); } }, @@ -165,6 +168,15 @@ module.exports = React.createClass({ const TintableSvg = sdk.getComponent("elements.TintableSvg"); const content = this.props.mxEvent.getContent(); + if (this.state.error !== null) { + return ( + + + Error decrypting image + + ); + } + if (content.file !== undefined && this.state.decryptedUrl === null) { // Need to decrypt the attachment @@ -172,8 +184,14 @@ module.exports = React.createClass({ // For now add an img tag with a spinner. return ( - {content.body} +
    + {content.body} +
    ); } diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index a32348ea1a..a0dbb141ac 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -31,6 +31,7 @@ module.exports = React.createClass({ return { decryptedUrl: null, decryptedThumbnailUrl: null, + error: null, }; }, @@ -95,7 +96,9 @@ module.exports = React.createClass({ }).catch((err) => { console.warn("Unable to decrypt attachment: ", err) // Set a placeholder image when we can't decrypt the image. - this.refs.image.src = "img/warning.svg"; + this.setState({ + error: err, + }); }).done(); } }, @@ -103,14 +106,29 @@ module.exports = React.createClass({ render: function() { const content = this.props.mxEvent.getContent(); + if (this.state.error !== null) { + return ( + + + Error decrypting video + + ); + } + if (content.file !== undefined && this.state.decryptedUrl === null) { // Need to decrypt the attachment // The attachment is decrypted in componentDidMount. // For now add an img tag with a spinner. return ( - - {content.body} + +
    + {content.body} +
    ); } From 617f0e6568e87aa10c21849888f06e03e89deb15 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 19 Nov 2016 02:01:42 +0200 Subject: [PATCH 018/289] Prepare changelog for v0.8.0 --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d6c38d01..90b3231e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,54 @@ +Changes in [0.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.0) (2016-11-19) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.7.5...v0.8.0) + + * Fix more membership change collapsing bugs + [\#560](https://github.com/matrix-org/matrix-react-sdk/pull/560) + * Show an open padlock for unencrypted rooms + [\#557](https://github.com/matrix-org/matrix-react-sdk/pull/557) + * Clean up MFileBody.presentableTextForFile + [\#558](https://github.com/matrix-org/matrix-react-sdk/pull/558) + * Update eventtiles when the events are decrypted + [\#556](https://github.com/matrix-org/matrix-react-sdk/pull/556) + * Update EventTile to use WithMatrixClient instead of MatrixClientPeg + [\#552](https://github.com/matrix-org/matrix-react-sdk/pull/552) + * Disable conference calling for encrypted rooms + [\#549](https://github.com/matrix-org/matrix-react-sdk/pull/549) + * Encrypt attachments in encrypted rooms + [\#548](https://github.com/matrix-org/matrix-react-sdk/pull/548) + * Fix MemberAvatar PropTypes & MemberEventListSummary key + [\#547](https://github.com/matrix-org/matrix-react-sdk/pull/547) + * Revert "Encrypt attachments in encrypted rooms," + [\#546](https://github.com/matrix-org/matrix-react-sdk/pull/546) + * Fix the vector web version in UserSettings + [\#542](https://github.com/matrix-org/matrix-react-sdk/pull/542) + * Truncate consecutive member events + [\#544](https://github.com/matrix-org/matrix-react-sdk/pull/544) + * Encrypt attachments in encrypted rooms, + [\#533](https://github.com/matrix-org/matrix-react-sdk/pull/533) + * Fix the ctrl+e mute camera shortcut + [\#545](https://github.com/matrix-org/matrix-react-sdk/pull/545) + * Show the error that occured when trying to reach scalar + [\#543](https://github.com/matrix-org/matrix-react-sdk/pull/543) + * Don't do URL previews for matrix.to + [\#541](https://github.com/matrix-org/matrix-react-sdk/pull/541) + * Fix NPE in LoggedInView + [\#540](https://github.com/matrix-org/matrix-react-sdk/pull/540) + * Make room alias & user ID links matrix.to links + [\#538](https://github.com/matrix-org/matrix-react-sdk/pull/538) + * Make MemberInfo use the matrixclient from the context + [\#537](https://github.com/matrix-org/matrix-react-sdk/pull/537) + * Add the MatrixClient to the react context + [\#536](https://github.com/matrix-org/matrix-react-sdk/pull/536) + * Factor out LoggedInView from MatrixChat + [\#535](https://github.com/matrix-org/matrix-react-sdk/pull/535) + * Move 'new version' support into Platform + [\#532](https://github.com/matrix-org/matrix-react-sdk/pull/532) + * Move Notifications into Platform + [\#534](https://github.com/matrix-org/matrix-react-sdk/pull/534) + * Move platform-specific functionality into Platform + [\#531](https://github.com/matrix-org/matrix-react-sdk/pull/531) + Changes in [0.7.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.7.5) (2016-11-04) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.7.5-rc.1...v0.7.5) From 09105608ab979761a13c7ec258870be73affb829 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 19 Nov 2016 02:01:43 +0200 Subject: [PATCH 019/289] v0.8.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bca4fa99f5..c58a878a47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.7.5", + "version": "0.8.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -59,7 +59,7 @@ "linkifyjs": "^2.1.3", "lodash": "^4.13.1", "marked": "^0.3.5", - "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "0.7.0", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.4.0", From 2e691240ae95d91ae4b56c9c1322bbcc26d4525a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 19 Nov 2016 02:44:36 +0200 Subject: [PATCH 020/289] fix e2e disclaimer --- src/components/views/rooms/RoomSettings.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 6d365ddc0a..bf20422a53 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -462,11 +462,10 @@ module.exports = React.createClass({ description: (

    End-to-end encryption is in beta and may not be reliable.

    -

    You should not yet trust it to secure data. File transfers and calls are not yet encrypted.

    +

    You should not yet trust it to secure data.

    Devices will not yet be able to decrypt history from before they joined the room.

    Once encryption is enabled for a room it cannot be turned off again (for now).

    -

    Encrypted messages will not be visible on clients that do not yet implement encryption
    - (e.g. Riot/iOS and Riot/Android).

    +

    Encrypted messages will not be visible on clients that do not yet implement encryption.

    ), onFinished: confirm=>{ From 03988015aaaadde5a848e5ece595f5effd5a9cd1 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 21 Nov 2016 09:19:40 +0000 Subject: [PATCH 021/289] Fix 'Quote' for e2e messages Fixes https://github.com/vector-im/vector-web/issues/2612 --- src/components/views/rooms/MessageComposerInput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index b33b5098b7..37d937d6f5 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -272,7 +272,7 @@ export default class MessageComposerInput extends React.Component { break; case 'quote': { - let {event: {content: {body, formatted_body}}} = payload.event || {}; + let {body, formatted_body} = payload.event.getContent(); formatted_body = formatted_body || escape(body); if (formatted_body) { let content = RichText.HTMLtoContentState(`
    ${formatted_body}
    `); From 4476b09ce7ca33b35c487e41310416ed2fae4283 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 21 Nov 2016 10:25:48 +0000 Subject: [PATCH 022/289] Get rid of always-on labs settings If the setting is on by default, it's not much use as a Labs setting. The E2e setting was only confusing everyone anyway. --- src/UserSettingsStore.js | 10 ---- src/components/views/messages/TextualBody.js | 12 ---- src/components/views/rooms/MemberInfo.js | 7 +-- src/components/views/rooms/RoomSettings.js | 60 +++++++++----------- 4 files changed, 28 insertions(+), 61 deletions(-) diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 845e81de36..e5dba62ee7 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -30,16 +30,6 @@ module.exports = { id: 'rich_text_editor', default: false, }, - { - name: 'End-to-End Encryption', - id: 'e2e_encryption', - default: true, - }, - { - name: 'Integration Management', - id: 'integration_management', - default: true, - }, ], loadProfileInfo: function() { diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 7540d40dc2..d005ef0cca 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -27,7 +27,6 @@ var sdk = require('../../../index'); var ScalarAuthClient = require("../../../ScalarAuthClient"); var Modal = require("../../../Modal"); var SdkConfig = require('../../../SdkConfig'); -var UserSettingsStore = require('../../../UserSettingsStore'); linkifyMatrix(linkify); @@ -213,16 +212,6 @@ module.exports = React.createClass({ // which requires the user to click through and THEN we can open the link in a new tab because // the window.open command occurs in the same stack frame as the onClick callback. - let integrationsEnabled = UserSettingsStore.isFeatureEnabled("integration_management"); - if (!integrationsEnabled) { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Integrations disabled", - description: "You need to enable the Labs option 'Integrations Management' in your Riot user settings first.", - }); - return; - } - // Go fetch a scalar token let scalarClient = new ScalarAuthClient(); scalarClient.connect().then(() => { @@ -304,4 +293,3 @@ module.exports = React.createClass({ } }, }); - diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 52edd494b2..d57bf4bce1 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -30,7 +30,6 @@ var classNames = require('classnames'); var dis = require("../../../dispatcher"); var Modal = require("../../../Modal"); var sdk = require('../../../index'); -var UserSettingsStore = require('../../../UserSettingsStore'); var createRoom = require('../../../createRoom'); var DMRoomMap = require('../../../utils/DMRoomMap'); var Unread = require('../../../Unread'); @@ -71,10 +70,8 @@ module.exports = WithMatrixClient(React.createClass({ componentWillMount: function() { this._cancelDeviceList = null; - // only display the devices list if our client supports E2E *and* the - // feature is enabled in the user settings - this._enableDevices = this.props.matrixClient.isCryptoEnabled() && - UserSettingsStore.isFeatureEnabled("e2e_encryption"); + // only display the devices list if our client supports E2E + this._enableDevices = this.props.matrixClient.isCryptoEnabled(); const cli = this.props.matrixClient; cli.on("deviceVerificationChanged", this.onDeviceVerificationChanged); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index bf20422a53..04ea05843d 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -24,7 +24,6 @@ var ObjectUtils = require("../../../ObjectUtils"); var dis = require("../../../dispatcher"); var ScalarAuthClient = require("../../../ScalarAuthClient"); var ScalarMessaging = require('../../../ScalarMessaging'); -var UserSettingsStore = require('../../../UserSettingsStore'); // parse a string as an integer; if the input is undefined, or cannot be parsed // as an integer, return a default. @@ -81,16 +80,14 @@ module.exports = React.createClass({ console.error("Failed to get room visibility: " + err); }); - if (UserSettingsStore.isFeatureEnabled("integration_management")) { - this.scalarClient = new ScalarAuthClient(); - this.scalarClient.connect().done(() => { - this.forceUpdate(); - }, (err) => { - this.setState({ - scalar_error: err - }); - }) - } + this.scalarClient = new ScalarAuthClient(); + this.scalarClient.connect().done(() => { + this.forceUpdate(); + }, (err) => { + this.setState({ + scalar_error: err + }); + }); dis.dispatch({ action: 'ui_opacity', @@ -477,10 +474,6 @@ module.exports = React.createClass({ }, _renderEncryptionSection: function() { - if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { - return null; - } - var cli = MatrixClientPeg.get(); var roomState = this.props.room.currentState; var isEncrypted = cli.isRoomEncrypted(this.props.room.roomId); @@ -684,27 +677,26 @@ module.exports = React.createClass({
    ); } - if (UserSettingsStore.isFeatureEnabled("integration_management")) { - if (this.scalarClient.hasCredentials()) { - integrationsButton = ( + + if (this.scalarClient.hasCredentials()) { + integrationsButton = (
    - Manage Integrations -
    - ); - } else if (this.state.scalar_error) { - integrationsButton = ( + Manage Integrations + + ); + } else if (this.state.scalar_error) { + integrationsButton = (
    - Integrations Error - { integrationsError } -
    - ); - } else { - integrationsButton = ( + Integrations Error + { integrationsError } + + ); + } else { + integrationsButton = (
    - Manage Integrations -
    - ); - } + Manage Integrations + + ); } return ( @@ -795,7 +787,7 @@ module.exports = React.createClass({ roomId={this.props.room.roomId} canSetCanonicalAlias={ roomState.mayClientSendStateEvent("m.room.canonical_alias", cli) } canSetAliases={ - true + true /* Originally, we arbitrarily restricted creating aliases to room admins: roomState.mayClientSendStateEvent("m.room.aliases", cli) */ } canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')} From d1a5d94916806d4dee2a940ca782f3b5d4f88043 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 22 Nov 2016 16:47:56 +0000 Subject: [PATCH 023/289] Make the unpagination process less aggressive This increases `UNPAGINATION_PADDING` (see the ASCII on ScrollPanel.js, `_getExcessHeight`), and also debounces unfilling requests made for 200ms. This forces unfilling requests not to be sent unless the next 200ms has no scrolling, effectively. --- src/components/structures/ScrollPanel.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 36dbf041e8..f7f954bc0f 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -25,7 +25,7 @@ var DEBUG_SCROLL = false; // The amount of extra scroll distance to allow prior to unfilling. // See _getExcessHeight. -const UNPAGINATION_PADDING = 500; +const UNPAGINATION_PADDING = 1500; if (DEBUG_SCROLL) { // using bind means that we get to keep useful line numbers in the console @@ -361,7 +361,14 @@ module.exports = React.createClass({ } if (markerScrollToken) { - this.props.onUnfillRequest(backwards, markerScrollToken); + // Use a debouncer to prevent multiple unfill calls in quick succession + // This is to make the unfilling process less aggressive + if (this._unfillDebouncer) { + clearTimeout(this._unfillDebouncer); + } + this._unfillDebouncer = setTimeout(() => { + this.props.onUnfillRequest(backwards, markerScrollToken); + }, 200); } }, From 42fc7b1b66f4dbedf0af82213016fb7713f56fa4 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 22 Nov 2016 17:23:06 +0000 Subject: [PATCH 024/289] Use UNFILL_REQUEST_DEBOUNCE_MS constant, reset unfillDebouncer timeout reference. --- src/components/structures/ScrollPanel.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index f7f954bc0f..31ac59c730 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -26,6 +26,9 @@ var DEBUG_SCROLL = false; // The amount of extra scroll distance to allow prior to unfilling. // See _getExcessHeight. const UNPAGINATION_PADDING = 1500; +// The number of milliseconds to debounce calls to onUnfillRequest, to prevent +// many scroll events causing many unfilling requests. +const UNFILL_REQUEST_DEBOUNCE_MS = 200; if (DEBUG_SCROLL) { // using bind means that we get to keep useful line numbers in the console @@ -367,8 +370,9 @@ module.exports = React.createClass({ clearTimeout(this._unfillDebouncer); } this._unfillDebouncer = setTimeout(() => { + this._unfillDebouncer = null; this.props.onUnfillRequest(backwards, markerScrollToken); - }, 200); + }, UNFILL_REQUEST_DEBOUNCE_MS); } }, From 8a6ed1d7e9ff158bf3e8ed720cfebb4176e03220 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 22 Nov 2016 17:43:45 +0000 Subject: [PATCH 025/289] Do not assume unpagination will occur during scroll test --- test/components/structures/TimelinePanel-test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index 52b9185f18..b2cdfbd590 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -341,8 +341,9 @@ describe('TimelinePanel', function() { var events = scryEventTiles(panel); expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]); - // Expect to be able to paginate forwards, having unpaginated a few events - expect(panel.state.canForwardPaginate).toBe(true); + // At this point, we make no assumption that unpagination has happened. This doesn't + // mean that we shouldn't be able to scroll all the way down to the bottom to see the + // most recent event in the timeline. // scroll all the way to the bottom return scrollDown(); From b67fcf8109395cad5b3fbd886842cd7a06e50eb2 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Tue, 22 Nov 2016 18:18:16 +0000 Subject: [PATCH 026/289] Bump browser-encrypt-attachment to v0.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c58a878a47..eab94077e6 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dependencies": { "babel-runtime": "^6.11.6", "blueimp-canvas-to-blob": "^3.5.0", - "browser-encrypt-attachment": "^0.1.0", + "browser-encrypt-attachment": "^0.2.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", "draft-js": "^0.8.1", From 6842544cd7fff95fb72243b4608ad7519ce4cf7e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 22 Nov 2016 22:12:50 +0000 Subject: [PATCH 027/289] Unpin js-sdk --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eab94077e6..a25260e35d 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "linkifyjs": "^2.1.3", "lodash": "^4.13.1", "marked": "^0.3.5", - "matrix-js-sdk": "0.7.0", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.4.0", From 6e6bcf8b7880d4b4c91236076696de0e01a35c2d Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 23 Nov 2016 10:56:16 +0000 Subject: [PATCH 028/289] Reinstate missing sections from the UserSettings The 'devices' and 'cryptography' sections got removed from UserSettings by #566. --- src/components/structures/UserSettings.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index c6efc55607..b82f2f5958 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -338,10 +338,6 @@ module.exports = React.createClass({ }, _renderCryptoInfo: function() { - if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { - return null; - } - var client = MatrixClientPeg.get(); var deviceId = client.deviceId; var identityKey = client.getDeviceEd25519Key() || ""; @@ -362,9 +358,6 @@ module.exports = React.createClass({ }, _renderDevicesPanel: function() { - if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { - return null; - } var DevicesPanel = sdk.getComponent('settings.DevicesPanel'); return (
    From 0bc4659fe03e580f5eacf7b26fc730d87fb75920 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 24 Nov 2016 12:33:31 +0000 Subject: [PATCH 029/289] Fix crash on logging in If you arrived at the page via a link to a room. Fixes https://github.com/vector-im/vector-web/issues/2634 --- src/components/structures/MatrixChat.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 945088106b..e632d9a4c5 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -75,6 +75,9 @@ module.exports = React.createClass({ loading: true, screen: undefined, + // What the LoggedInView would be showing if visible + page_type: PageTypes.RoomDirectory, + // If we are viewing a room by alias, this contains the alias currentRoomAlias: null, @@ -235,6 +238,7 @@ module.exports = React.createClass({ setStateForNewScreen: function(state) { const newState = { screen: undefined, + page_type: PageTypes.RoomDirectory, currentRoomAlias: null, currentRoomId: null, viewUserId: null, From 00693936506c021c1df98ca033d11d9513e79628 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 24 Nov 2016 13:28:51 +0000 Subject: [PATCH 030/289] Go back to same room after logging in --- src/components/structures/MatrixChat.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index e632d9a4c5..4d3ddc5634 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -238,9 +238,6 @@ module.exports = React.createClass({ setStateForNewScreen: function(state) { const newState = { screen: undefined, - page_type: PageTypes.RoomDirectory, - currentRoomAlias: null, - currentRoomId: null, viewUserId: null, logged_in: false, ready: false, From 2aba646acd706608778d0e2c04eaf4f86ca6c6fa Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 24 Nov 2016 14:58:37 +0000 Subject: [PATCH 031/289] Clear room alias etc. on logout --- src/components/structures/MatrixChat.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 4d3ddc5634..7fa7c9f152 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -598,6 +598,9 @@ module.exports = React.createClass({ ready: false, collapse_lhs: false, collapse_rhs: false, + currentRoomAlias: null, + currentRoomId: null, + page_type: PageTypes.RoomDirectory, }); }, From 8547d00f32b14af6be7be118e3e2eb1f8ed2c739 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 24 Nov 2016 16:39:48 +0000 Subject: [PATCH 032/289] Don't default the page_type to room directory As it breaks the behaviour of redirecting to /#/directory --- src/components/structures/MatrixChat.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 7fa7c9f152..2a31850f68 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -76,7 +76,7 @@ module.exports = React.createClass({ screen: undefined, // What the LoggedInView would be showing if visible - page_type: PageTypes.RoomDirectory, + page_type: null, // If we are viewing a room by alias, this contains the alias currentRoomAlias: null, From 21d65d2ad16c942c866513223b9853eba4e9baad Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 25 Nov 2016 23:19:20 +0000 Subject: [PATCH 033/289] Fix the download icon on attachments --- src/components/views/messages/MFileBody.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 32b53e3b7d..c7c0881cbb 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -22,7 +22,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import {decryptFile} from '../../../utils/DecryptFile'; import Tinter from '../../../Tinter'; -import 'isomorphic-fetch'; +import request from 'browser-request'; import q from 'q'; import Modal from '../../../Modal'; @@ -41,10 +41,13 @@ function updateTintedDownloadImage() { // Download the svg as an XML document. // We could cache the XML response here, but since the tint rarely changes // it's probably not worth it. - q(fetch("img/download.svg")).then(function(response) { - return response.text(); - }).then(function(svgText) { - const svg = new DOMParser().parseFromString(svgText, "image/svg+xml"); + // Also note that we can't use fetch here because fetch doesn't support + // file URLs, which the download image will be if we're running from + // the filesystem (like in an Electron wrapper). + request({uri: "img/download.svg"}, (err, response, body) => { + if (err) return; + + const svg = new DOMParser().parseFromString(body, "image/svg+xml"); // Apply the fixups to the XML. const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]); Tinter.applySvgFixups(fixups); @@ -55,7 +58,7 @@ function updateTintedDownloadImage() { Object.keys(mounts).forEach(function(id) { mounts[id].tint(); }); - }).done(); + }); } Tinter.registerTintable(updateTintedDownloadImage); From c7fb83ed2dfeb39d27369faa9373bcc0801fd25d Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 29 Nov 2016 18:25:26 +0000 Subject: [PATCH 034/289] s/block/blacklist for e2e Fixes https://github.com/vector-im/vector-web/issues/2315 --- .../views/dialogs/EncryptedEventDialog.js | 2 +- .../views/elements/DeviceVerifyButtons.js | 28 +++++++++---------- .../views/rooms/MemberDeviceInfo.js | 4 +-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/views/dialogs/EncryptedEventDialog.js b/src/components/views/dialogs/EncryptedEventDialog.js index c86b1d20f8..ba706e0aa5 100644 --- a/src/components/views/dialogs/EncryptedEventDialog.js +++ b/src/components/views/dialogs/EncryptedEventDialog.js @@ -83,7 +83,7 @@ module.exports = React.createClass({ var verificationStatus = (NOT verified); if (device.isBlocked()) { - verificationStatus = (Blocked); + verificationStatus = (Blacklisted); } else if (device.isVerified()) { verificationStatus = "verified"; } diff --git a/src/components/views/elements/DeviceVerifyButtons.js b/src/components/views/elements/DeviceVerifyButtons.js index 90af1635c9..aeb93e866c 100644 --- a/src/components/views/elements/DeviceVerifyButtons.js +++ b/src/components/views/elements/DeviceVerifyButtons.js @@ -49,7 +49,7 @@ export default React.createClass({

    If it matches, press the verify button below. If it doesnt, then someone else is intercepting this device - and you probably want to press the block button instead. + and you probably want to press the blacklist button instead.

    In future this verification process will be more sophisticated. @@ -73,33 +73,33 @@ export default React.createClass({ ); }, - onBlockClick: function() { + onBlacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( this.props.userId, this.props.device.deviceId, true ); }, - onUnblockClick: function() { + onUnblacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( this.props.userId, this.props.device.deviceId, false ); }, render: function() { - var blockButton = null, verifyButton = null; + var blacklistButton = null, verifyButton = null; if (this.props.device.isBlocked()) { - blockButton = ( - ); } else { - blockButton = ( - ); } @@ -115,7 +115,7 @@ export default React.createClass({ verifyButton = ( ); } @@ -124,7 +124,7 @@ export default React.createClass({ return (

    { verifyButton } - { blockButton } + { blacklistButton }
    ); }, diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js index 51bf7d3637..1e7850ab44 100644 --- a/src/components/views/rooms/MemberDeviceInfo.js +++ b/src/components/views/rooms/MemberDeviceInfo.js @@ -24,8 +24,8 @@ export default class MemberDeviceInfo extends React.Component { if (this.props.device.isBlocked()) { indicator = ( -
    - Blocked +
    + Blacklisted
    ); } else if (this.props.device.isVerified()) { From 4d2926485b914843b3b462403c5825f6fa21adf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Tue, 29 Nov 2016 20:56:48 +0100 Subject: [PATCH 035/289] Replace marked with commonmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marked has some annoying bugs, and the author is inactive, so replace it with commonmark.js, which is the reference JavaScript implementation of CommonMark. CommonMark is also preferable since it has a specification, and a conformance test suite to make sure that parsers are correct. Signed-off-by: Johannes Löthberg --- package.json | 2 +- src/Markdown.js | 92 +++++++++++++------------------------------------ 2 files changed, 24 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index a25260e35d..e93aadf8d1 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "marked": "^0.3.5", + "commonmark": "^0.27.0", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", diff --git a/src/Markdown.js b/src/Markdown.js index a7b267b110..80805144e2 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -14,20 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import marked from 'marked'; - -// marked only applies the default options on the high -// level marked() interface, so we do it here. -const marked_options = Object.assign({}, marked.defaults, { - gfm: true, - tables: true, - breaks: true, - pedantic: false, - sanitize: true, - smartLists: true, - smartypants: false, - xhtml: true, // return self closing tags (ie.
    not
    ) -}); +import commonmark from 'commonmark'; /** * Class that wraps marked, adding the ability to see whether @@ -36,16 +23,7 @@ const marked_options = Object.assign({}, marked.defaults, { */ export default class Markdown { constructor(input) { - const lexer = new marked.Lexer(marked_options); - this.tokens = lexer.lex(input); - } - - _copyTokens() { - // copy tokens (the parser modifies its input arg) - const tokens_copy = this.tokens.slice(); - // it also has a 'links' property, because this is javascript - // and why wouldn't you have an array that also has properties? - return Object.assign(tokens_copy, this.tokens); + this.input = input } isPlainText() { @@ -64,65 +42,41 @@ export default class Markdown { is_plain = false; } - const dummy_renderer = {}; - for (const k of Object.keys(marked.Renderer.prototype)) { + const dummy_renderer = new commonmark.HtmlRenderer(); + for (const k of Object.keys(commonmark.HtmlRenderer.prototype)) { dummy_renderer[k] = setNotPlain; } // text and paragraph are just text - dummy_renderer.text = function(t){return t;} - dummy_renderer.paragraph = function(t){return t;} + dummy_renderer.text = function(t) { return t; } + dummy_renderer.paragraph = function(t) { return t; } - // ignore links where text is just the url: - // this ignores plain URLs that markdown has - // detected whilst preserving markdown syntax links - dummy_renderer.link = function(href, title, text) { - if (text != href) { - is_plain = false; - } - } - - const dummy_options = Object.assign({}, marked_options, { - renderer: dummy_renderer, - }); - const dummy_parser = new marked.Parser(dummy_options); - dummy_parser.parse(this._copyTokens()); + const dummy_parser = new commonmark.Parser(); + dummy_renderer.render(dummy_parser.parse(this.input)); return is_plain; } toHTML() { - const real_renderer = new marked.Renderer(); - real_renderer.link = function(href, title, text) { - // prevent marked from turning plain URLs - // into links, because its algorithm is fairly - // poor. Let's send plain URLs rather than - // badly linkified ones (the linkifier Vector - // uses on message display is way better, eg. - // handles URLs with closing parens at the end). - if (text == href) { - return href; - } - return marked.Renderer.prototype.link.apply(this, arguments); - } + const parser = new commonmark.Parser(); - real_renderer.paragraph = (text) => { - // The tokens at the top level are the 'blocks', so if we - // have more than one, there are multiple 'paragraphs'. - // If there is only one top level token, just return the + const renderer = new commonmark.HtmlRenderer({safe: true}); + const real_paragraph = renderer.paragraph; + renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the // bare text: it's a single line of text and so should be - // 'inline', rather than necessarily wrapped in its own - // p tag. If, however, we have multiple tokens, each gets + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets // its own p tag to keep them as separate paragraphs. - if (this.tokens.length == 1) { - return text; + var par = node; + while (par.parent) { + par = par.parent + } + if (par.firstChild != par.lastChild) { + real_paragraph.bind(this)(node, entering); } - return '

    ' + text + '

    '; } - const real_options = Object.assign({}, marked_options, { - renderer: real_renderer, - }); - const real_parser = new marked.Parser(real_options); - return real_parser.parse(this._copyTokens()); + var parsed = parser.parse(this.input); + return renderer.render(parsed); } } From 5f160d2e7f86f435db73b7d17799a8a35bb83514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= Date: Wed, 30 Nov 2016 01:03:05 +0100 Subject: [PATCH 036/289] Markdown: Use .call instead of .bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Löthberg --- src/Markdown.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Markdown.js b/src/Markdown.js index 80805144e2..18c888b541 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -72,7 +72,7 @@ export default class Markdown { par = par.parent } if (par.firstChild != par.lastChild) { - real_paragraph.bind(this)(node, entering); + real_paragraph.call(this, node, entering); } } From 5d03543f8589f6648a1da49c4bb9c5b8aa28326e Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 30 Nov 2016 10:49:40 +0000 Subject: [PATCH 037/289] Make cut operations update the tab complete list --- src/TabComplete.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/TabComplete.js b/src/TabComplete.js index 65441c9381..a0380f36c4 100644 --- a/src/TabComplete.js +++ b/src/TabComplete.js @@ -227,8 +227,20 @@ class TabComplete { // pressing any key at all (except tab) restarts the automatic tab-complete timer if (this.opts.autoEnterTabComplete) { + const cachedText = ev.target.value; clearTimeout(this.enterTabCompleteTimerId); this.enterTabCompleteTimerId = setTimeout(() => { + if (this.completing) { + // If you highlight text and CTRL+X it, tab-completing will not be reset. + // This check makes sure that if something like a cut operation has been + // done, that we correctly refresh the tab-complete list. Normal backspace + // operations get caught by the stopTabCompleting() section above, but + // because the CTRL key is held, this does not execute for CTRL+X. + if (cachedText !== this.textArea.value) { + this.stopTabCompleting(); + } + } + if (!this.completing) { this.handleTabPress(true, false); } From 3aa1e0dd9ed3432a665c6a278397d3a23f2155fa Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 1 Dec 2016 13:12:22 +0000 Subject: [PATCH 038/289] Update browser-encrypt-attachment to v0.3.0 (#570) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a25260e35d..5b5c41c53e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dependencies": { "babel-runtime": "^6.11.6", "blueimp-canvas-to-blob": "^3.5.0", - "browser-encrypt-attachment": "^0.2.0", + "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", "draft-js": "^0.8.1", From 5665a0ef370f63654f2305e43f57d962db3cf9d2 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 2 Dec 2016 11:11:35 +0000 Subject: [PATCH 039/289] Fix scroll jump on image decryption `onWidgetLoad` is now being called when an image has been decrypted so that the ScrollPanel maintains its scroll position (whether it's stuckAtBottom or not). This attempts to fix https://github.com/vector-im/riot-web/issues/2624 --- src/components/views/messages/MImageBody.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 63fd119335..08969114f9 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -32,6 +32,9 @@ module.exports = React.createClass({ propTypes: { /* the MatrixEvent to show */ mxEvent: React.PropTypes.object.isRequired, + + /* called when the image has loaded */ + onWidgetLoad: React.PropTypes.func.isRequired, }, getInitialState: function() { @@ -123,6 +126,7 @@ module.exports = React.createClass({ decryptedUrl: contentUrl, decryptedThumbnailUrl: thumbnailUrl, }); + this.props.onWidgetLoad(); }); }).catch((err) => { console.warn("Unable to decrypt attachment: ", err); @@ -186,11 +190,12 @@ module.exports = React.createClass({
    - {content.body} + {content.body}
    ); From 81e429eb143ae99e25af80462570d8f77653a026 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Fri, 2 Dec 2016 14:21:07 +0000 Subject: [PATCH 040/289] Fix e2e attachment download by using iframes. (#562) * Render attachments inside iframes. * Fix up the image and video views * Fix m.audio * Comments, and only use the cross domain renderer if the attachment is encrypted * Fix whitespace * Don't decrypt file attachments immediately * Use https://usercontent.riot.im/v1.html by default * typos * Put the config in the React context. Use it in MFileBody to configure the cross origin renderer URL. * Call it appConfig in the context * Return the promises so they don't get dropped --- src/components/structures/MatrixChat.js | 10 + src/components/views/messages/MAudioBody.js | 12 +- src/components/views/messages/MFileBody.js | 280 +++++++++++++++----- src/components/views/messages/MImageBody.js | 16 +- src/components/views/messages/MVideoBody.js | 16 +- src/utils/DecryptFile.js | 4 +- 6 files changed, 253 insertions(+), 85 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 2a31850f68..ff5a44e016 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -66,10 +66,20 @@ module.exports = React.createClass({ defaultDeviceDisplayName: React.PropTypes.string, }, + childContextTypes: { + appConfig: React.PropTypes.object, + }, + AuxPanel: { RoomSettings: "room_settings", }, + getChildContext: function() { + return { + appConfig: this.props.config, + } + }, + getInitialState: function() { var s = { loading: true, diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 393bf549ae..7e338e8466 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -21,7 +21,7 @@ import MFileBody from './MFileBody'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; -import { decryptFile } from '../../../utils/DecryptFile'; +import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; export default class MAudioBody extends React.Component { constructor(props) { @@ -29,6 +29,7 @@ export default class MAudioBody extends React.Component { this.state = { playing: false, decryptedUrl: null, + decryptedBlob: null, error: null, } } @@ -50,9 +51,14 @@ export default class MAudioBody extends React.Component { componentDidMount() { var content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { - decryptFile(content.file).done((url) => { + var decryptedBlob; + decryptFile(content.file).then(function(blob) { + decryptedBlob = blob; + return readBlobAsDataUri(decryptedBlob); + }).done((url) => { this.setState({ decryptedUrl: url, + decryptedBlob: decryptedBlob, }); }, (err) => { console.warn("Unable to decrypt attachment: ", err); @@ -93,7 +99,7 @@ export default class MAudioBody extends React.Component { return ( ); } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index c7c0881cbb..4f5ca2d3be 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -63,15 +63,137 @@ function updateTintedDownloadImage() { Tinter.registerTintable(updateTintedDownloadImage); +// User supplied content can contain scripts, we have to be careful that +// we don't accidentally run those script within the same origin as the +// client. Otherwise those scripts written by remote users can read +// the access token and end-to-end keys that are in local storage. +// +// For attachments downloaded directly from the homeserver we can use +// Content-Security-Policy headers to disable script execution. +// +// But attachments with end-to-end encryption are more difficult to handle. +// We need to decrypt the attachment on the client and then display it. +// To display the attachment we need to turn the decrypted bytes into a URL. +// +// There are two ways to turn bytes into URLs, data URL and blob URLs. +// Data URLs aren't suitable for downloading a file because Chrome has a +// 2MB limit on the size of URLs that can be viewed in the browser or +// downloaded. This limit does not seem to apply when the url is used as +// the source attribute of an image tag. +// +// Blob URLs are generated using window.URL.createObjectURL and unforuntately +// for our purposes they inherit the origin of the page that created them. +// This means that any scripts that run when the URL is viewed will be able +// to access local storage. +// +// The easiest solution is to host the code that generates the blob URL on +// a different domain to the client. +// Another possibility is to generate the blob URL within a sandboxed iframe. +// The downside of using a second domain is that it complicates hosting, +// the downside of using a sandboxed iframe is that the browers are overly +// restrictive in what you are allowed to do with the generated URL. +// +// For now given how unusable the blobs generated in sandboxed iframes are we +// default to using a renderer hosted on "usercontent.riot.im". This is +// overridable so that people running their own version of the client can +// choose a different renderer. +// +// To that end the first version of the blob generation will be the following +// html: +// +// +// +// This waits to receive a message event sent using the window.postMessage API. +// When it receives the event it evals a javascript function in data.code and +// runs the function passing the event as an argument. +// +// In particular it means that the rendering function can be written as a +// ordinary javascript function which then is turned into a string using +// toString(). +// +const DEFAULT_CROSS_ORIGIN_RENDERER = "https://usercontent.riot.im/v1.html"; + +/** + * Render the attachment inside the iframe. + * We can't use imported libraries here so this has to be vanilla JS. + */ +function remoteRender(event) { + const data = event.data; + + const img = document.createElement("img"); + img.id = "img"; + img.src = data.imgSrc; + + const a = document.createElement("a"); + a.id = "a"; + a.rel = data.rel; + a.target = data.target; + a.download = data.download; + a.style = data.style; + a.href = window.URL.createObjectURL(data.blob); + a.appendChild(img); + a.appendChild(document.createTextNode(data.textContent)); + + const body = document.body; + // Don't display scrollbars if the link takes more than one line + // to display. + body.style = "margin: 0px; overflow: hidden"; + body.appendChild(a); +} + +/** + * Update the tint inside the iframe. + * We can't use imported libraries here so this has to be vanilla JS. + */ +function remoteSetTint(event) { + const data = event.data; + + const img = document.getElementById("img"); + img.src = data.imgSrc; + img.style = data.imgStyle; + + const a = document.getElementById("a"); + a.style = data.style; +} + + +/** + * Get the current CSS style for a DOMElement. + * @param {HTMLElement} element The element to get the current style of. + * @return {string} The CSS style encoded as a string. + */ +function computedStyle(element) { + if (!element) { + return ""; + } + const style = window.getComputedStyle(element, null); + var cssText = style.cssText; + if (cssText == "") { + // Firefox doesn't implement ".cssText" for computed styles. + // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 + for (var i = 0; i < style.length; i++) { + cssText += style[i] + ":"; + cssText += style.getPropertyValue(style[i]) + ";"; + } + } + return cssText; +} + module.exports = React.createClass({ displayName: 'MFileBody', getInitialState: function() { return { - decryptedUrl: (this.props.decryptedUrl ? this.props.decryptedUrl : null), + decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null), }; }, + contextTypes: { + appConfig: React.PropTypes.object, + }, + /** * Extracts a human readable label for the file attachment to use as * link text. @@ -102,11 +224,7 @@ module.exports = React.createClass({ _getContentUrl: function() { const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { - return this.state.decryptedUrl; - } else { - return MatrixClientPeg.get().mxcUrlToHttp(content.url); - } + return MatrixClientPeg.get().mxcUrlToHttp(content.url); }, componentDidMount: function() { @@ -127,90 +245,108 @@ module.exports = React.createClass({ if (this.refs.downloadImage) { this.refs.downloadImage.src = tintedDownloadImageURL; } + if (this.refs.iframe) { + // If the attachment is encrypted then the download image + // will be inside the iframe so we wont be able to update + // it directly. + this.refs.iframe.contentWindow.postMessage({ + code: remoteSetTint.toString(), + imgSrc: tintedDownloadImageURL, + style: computedStyle(this.refs.dummyLink), + }, "*"); + } }, render: function() { const content = this.props.mxEvent.getContent(); - const text = this.presentableTextForFile(content); + const isEncrypted = content.file !== undefined; + const fileName = content.body && content.body.length > 0 ? content.body : "Attachment"; + const contentUrl = this._getContentUrl(); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - if (content.file !== undefined && this.state.decryptedUrl === null) { + if (isEncrypted) { + if (this.state.decryptedBlob === null) { + // Need to decrypt the attachment + // Wait for the user to click on the link before downloading + // and decrypting the attachment. + var decrypting = false; + const decrypt = () => { + if (decrypting) { + return false; + } + decrypting = true; + decryptFile(content.file).then((blob) => { + this.setState({ + decryptedBlob: blob, + }); + }).catch((err) => { + console.warn("Unable to decrypt attachment: ", err) + Modal.createDialog(ErrorDialog, { + description: "Error decrypting attachment" + }); + }).finally(() => { + decrypting = false; + return; + }); + }; - var decrypting = false; - const decrypt = () => { - if (decrypting) { - return false; - } - decrypting = true; - decryptFile(content.file).then((url) => { - this.setState({ - decryptedUrl: url, - }); - }).catch((err) => { - console.warn("Unable to decrypt attachment: ", err) - // Set a placeholder image when we can't decrypt the image - Modal.createDialog(ErrorDialog, { - description: "Error decrypting attachment" - }); - }).finally(function() { - decrypting = false; - }).done(); - return false; + return ( + + + + ); + } + + // When the iframe loads we tell it to render a download link + const onIframeLoad = (ev) => { + ev.target.contentWindow.postMessage({ + code: remoteRender.toString(), + imgSrc: tintedDownloadImageURL, + style: computedStyle(this.refs.dummyLink), + blob: this.state.decryptedBlob, + // Set a download attribute for encrypted files so that the file + // will have the correct name when the user tries to download it. + // We can't provide a Content-Disposition header like we would for HTTP. + download: fileName, + target: "_blank", + textContent: "Download " + text, + }, "*"); }; - // Need to decrypt the attachment - // The attachment is decrypted in componentDidMount. - // For now add an img tag with a spinner. + // If the attachment is encryped then put the link inside an iframe. + let renderer_url = DEFAULT_CROSS_ORIGIN_RENDERER; + if (this.context.appConfig && this.context.appConfig.cross_origin_renderer_url) { + renderer_url = this.context.appConfig.cross_origin_renderer_url; + } return ( - +
    - - Decrypt {text} - +
    + {/* + * Add dummy copy of the "a" tag + * We'll use it to learn how the download link + * would have been styled if it was rendered inline. + */} + +
    +