From 5b2cee2fc18cc59a92cea6b2a60ad43ae5117a7f Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 1 Apr 2019 16:42:41 +0100 Subject: [PATCH 01/12] Implement redesigned upload confirmation screens Also fairly significant refactor of the uploading code: there are a number of different ways of triggerring a file upload and each went through a different code path (the media config size limit worked on one of those paths). Basically take a lot of code out of the views and put it into ContentMessages. Sorry about the size of this patch. https://github.com/vector-im/riot-web/issues/7565 --- .eslintignore.errorfiles | 1 - res/css/_components.scss | 1 + .../views/dialogs/_UploadConfirmDialog.scss | 33 ++++ src/ContentMessages.js | 164 +++++++++++++++--- src/components/structures/RoomView.js | 86 +-------- src/components/structures/UploadBar.js | 4 +- .../views/dialogs/UploadConfirmDialog.js | 106 +++++++++++ .../views/dialogs/UploadFailureDialog.js | 120 +++++++++++++ src/components/views/rooms/MessageComposer.js | 105 ++--------- .../views/rooms/MessageComposerInput.js | 9 +- src/i18n/strings/en_EN.json | 25 +-- 11 files changed, 452 insertions(+), 202 deletions(-) create mode 100644 res/css/views/dialogs/_UploadConfirmDialog.scss create mode 100644 src/components/views/dialogs/UploadConfirmDialog.js create mode 100644 src/components/views/dialogs/UploadFailureDialog.js diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index c7d5804d66..6d1874b872 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -52,7 +52,6 @@ src/components/views/settings/ChangePassword.js src/components/views/settings/DevicesPanel.js src/components/views/settings/IntegrationsManager.js src/components/views/settings/Notifications.js -src/ContentMessages.js src/GroupAddressPicker.js src/HtmlUtils.js src/ImageUtils.js diff --git a/res/css/_components.scss b/res/css/_components.scss index ec8476ee63..b7d0c7a2a5 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -70,6 +70,7 @@ @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; +@import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; diff --git a/res/css/views/dialogs/_UploadConfirmDialog.scss b/res/css/views/dialogs/_UploadConfirmDialog.scss new file mode 100644 index 0000000000..116be798e3 --- /dev/null +++ b/res/css/views/dialogs/_UploadConfirmDialog.scss @@ -0,0 +1,33 @@ +/* +Copyright 2019 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_UploadConfirmDialog_fileIcon { + margin-right: 5px; +} + +.mx_UploadConfirmDialog_previewOuter { + text-align: center; +} + +.mx_UploadConfirmDialog_previewInner { + display: inline-block; + text-align: left; +} + +.mx_UploadConfirmDialog_imagePreview { + max-height: 300px; + max-width: 100%; +} diff --git a/src/ContentMessages.js b/src/ContentMessages.js index a319118121..426a707f65 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,17 +18,18 @@ limitations under the License. 'use strict'; import Promise from 'bluebird'; -const extend = require('./extend'); -const dis = require('./dispatcher'); -const MatrixClientPeg = require('./MatrixClientPeg'); -const sdk = require('./index'); +import extend from './extend'; +import dis from './dispatcher'; +import MatrixClientPeg from './MatrixClientPeg'; +import sdk from './index'; import { _t } from './languageHandler'; -const Modal = require('./Modal'); +import Modal from './Modal'; +import RoomViewStore from './stores/RoomViewStore'; -const encrypt = require("browser-encrypt-attachment"); +import encrypt from "browser-encrypt-attachment"; // Polyfill for Canvas.toBlob API using Canvas.toDataURL -require("blueimp-canvas-to-blob"); +import "blueimp-canvas-to-blob"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -91,7 +93,7 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) { /** * Load a file into a newly created image element. * - * @param {File} file The file to load in an image element. + * @param {File} imageFile The file to load in an image element. * @return {Promise} A promise that resolves with the html image element. */ function loadImageElement(imageFile) { @@ -119,7 +121,7 @@ function loadImageElement(imageFile) { * * @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. + * @param {File} imageFile The image to read and thumbnail. * @return {Promise} A promise that resolves with the attachment info. */ function infoForImageFile(matrixClient, roomId, imageFile) { @@ -144,7 +146,7 @@ function infoForImageFile(matrixClient, roomId, imageFile) { /** * Load a file into a newly created video element. * - * @param {File} file The file to load in an video element. + * @param {File} videoFile The file to load in an video element. * @return {Promise} A promise that resolves with the video image element. */ function loadVideoElement(videoFile) { @@ -179,7 +181,7 @@ function loadVideoElement(videoFile) { * * @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. + * @param {File} videoFile The video to read and thumbnail. * @return {Promise} A promise that resolves with the attachment info. */ function infoForVideoFile(matrixClient, roomId, videoFile) { @@ -200,6 +202,7 @@ function infoForVideoFile(matrixClient, roomId, videoFile) { /** * Read the file as an ArrayBuffer. + * @param {File} file The file to read * @return {Promise} A promise that resolves with an ArrayBuffer when the file * is read. */ @@ -269,11 +272,43 @@ function uploadFile(matrixClient, roomId, file, progressHandler) { } } - -class ContentMessages { +export default class ContentMessages { constructor() { this.inprogress = []; this.nextId = 0; + this._mediaConfig = null; + } + + static sharedInstance() { + if (global.mx_ContentMessages === undefined) { + global.mx_ContentMessages = new ContentMessages(); + } + return global.mx_ContentMessages; + } + + _isFileSizeAcceptable(file) { + if (this._mediaConfig !== null && + this._mediaConfig["m.upload.size"] !== undefined && + file.size > this._mediaConfig["m.upload.size"]) { + return false; + } + return true; + } + + _ensureMediaConfigFetched() { + if (this._mediaConfig !== null) return; + + console.log("[Media Config] Fetching"); + return MatrixClientPeg.get().getMediaConfig().then((config) => { + console.log("[Media Config] Fetched config:", config); + return config; + }).catch(() => { + // Media repo can't or won't report limits, so provide an empty object (no limits). + console.log("[Media Config] Could not fetch config, so not limiting uploads."); + return {}; + }).then((config) => { + this._mediaConfig = config; + }); } sendStickerContentToRoom(url, roomId, info, text, matrixClient) { @@ -283,7 +318,88 @@ class ContentMessages { }); } - sendContentToRoom(file, roomId, matrixClient) { + getUploadLimit() { + if (this._mediaConfig !== null && this._mediaConfig["m.upload.size"] !== undefined) { + return this._mediaConfig["m.upload.size"]; + } + } + + async sendContentListToRoom(files, roomId, matrixClient) { + if (matrixClient.isGuest()) { + dis.dispatch({action: 'require_registration'}); + return; + } + + const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); + if (isQuoting) { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const shouldUpload = await new Promise((resolve) => { + Modal.createTrackedDialog('Upload Reply Warning', '', QuestionDialog, { + title: _t('Replying With Files'), + description: ( +
{_t( + 'At this time it is not possible to reply with a file ' + + 'so this will be sent without being a reply.', + )}
+ ), + hasCancelButton: true, + button: _t("Continue"), + onFinished: (shouldUpload) => { + resolve(shouldUpload); + }, + }); + }); + if (!shouldUpload) return; + } + + await this._ensureMediaConfigFetched(); + + const tooBigFiles = []; + const okFiles = []; + + for (let i = 0; i < files.length; ++i) { + if (this._isFileSizeAcceptable(files[i])) { + okFiles.push(files[i]); + } else { + tooBigFiles.push(files[i]); + } + } + + if (tooBigFiles.length > 0) { + const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog"); + const uploadFailureDialogPromise = new Promise((resolve) => { + Modal.createTrackedDialog('Upload Failure', '', UploadFailureDialog, { + badFiles: tooBigFiles, + totalFiles: files.length, + contentMessages: this, + onFinished: (shouldContinue) => { + resolve(shouldContinue); + }, + }); + }); + const shouldContinue = await uploadFailureDialogPromise; + if (!shouldContinue) return; + } + + const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + for (let i = 0; i < okFiles.length; ++i) { + const file = okFiles[i]; + const shouldContinue = await new Promise((resolve) => { + Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { + file, + currentIndex: i, + totalFiles: okFiles.length, + onFinished: (shouldContinue) => { + resolve(shouldContinue); + }, + }); + }); + if (!shouldContinue) break; + this._sendContentToRoom(file, roomId, matrixClient); + } + } + + _sendContentToRoom(file, roomId, matrixClient) { const content = { body: file.name || 'Attachment', info: { @@ -357,9 +473,12 @@ class ContentMessages { }, function(err) { error = err; if (!upload.canceled) { - let desc = _t('The file \'%(fileName)s\' failed to upload', {fileName: upload.fileName}) + '.'; + let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName}); if (err.http_status == 413) { - desc = _t('The file \'%(fileName)s\' exceeds this homeserver\'s size limit for uploads', {fileName: upload.fileName}); + desc = _t( + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", + {fileName: upload.fileName}, + ); } const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { @@ -377,9 +496,16 @@ class ContentMessages { } } if (error) { + // 413: File was too big or upset the server in some way: + // clear the media size limit so we fetch it again next time + // we try to upload + if (error && error.http_status === 413) { + this._mediaConfig = null; + } dis.dispatch({action: 'upload_failed', upload, error}); } else { dis.dispatch({action: 'upload_finished', upload}); + dis.dispatch({action: 'message_sent'}); } }); } @@ -404,9 +530,3 @@ class ContentMessages { } } } - -if (global.mx_ContentMessage === undefined) { - global.mx_ContentMessage = new ContentMessages(); -} - -module.exports = global.mx_ContentMessage; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 73b7545c26..7914810458 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -19,7 +19,6 @@ limitations under the License. // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component // - Drag and drop -// - File uploading - uploadFile() import shouldHideEvent from "../../shouldHideEvent"; @@ -33,7 +32,7 @@ import { _t } from '../../languageHandler'; import {RoomPermalinkCreator} from "../../matrix-to"; const MatrixClientPeg = require("../../MatrixClientPeg"); -const ContentMessages = require("../../ContentMessages"); +import ContentMessages from '../../ContentMessages'; const Modal = require("../../Modal"); const sdk = require('../../index'); const CallHandler = require('../../CallHandler'); @@ -170,7 +169,6 @@ module.exports = React.createClass({ MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged); - this._fetchMediaConfig(); // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); @@ -178,27 +176,6 @@ module.exports = React.createClass({ WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); }, - _fetchMediaConfig: function(invalidateCache: boolean = false) { - /// NOTE: Using global here so we don't make repeated requests for the - /// config every time we swap room. - if(global.mediaConfig !== undefined && !invalidateCache) { - this.setState({mediaConfig: global.mediaConfig}); - return; - } - console.log("[Media Config] Fetching"); - MatrixClientPeg.get().getMediaConfig().then((config) => { - console.log("[Media Config] Fetched config:", config); - return config; - }).catch(() => { - // Media repo can't or won't report limits, so provide an empty object (no limits). - console.log("[Media Config] Could not fetch config, so not limiting uploads."); - return {}; - }).then((config) => { - global.mediaConfig = config; - this.setState({mediaConfig: config}); - }); - }, - _onRoomViewStoreUpdate: function(initial) { if (this.unmounted) { return; @@ -510,7 +487,7 @@ module.exports = React.createClass({ }, onPageUnload(event) { - if (ContentMessages.getCurrentUploads().length > 0) { + if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { return event.returnValue = _t("You seem to be uploading files, are you sure you want to quit?"); } else if (this._getCallForRoom() && this.state.callState !== 'ended') { @@ -561,11 +538,6 @@ module.exports = React.createClass({ case 'picture_snapshot': this.uploadFile(payload.file); break; - case 'upload_failed': - // 413: File was too big or upset the server in some way. - if (payload.error && payload.error.http_status === 413) { - this._fetchMediaConfig(true); - } case 'notifier_enabled': case 'upload_started': case 'upload_finished': @@ -1015,9 +987,11 @@ module.exports = React.createClass({ onDrop: function(ev) { ev.stopPropagation(); ev.preventDefault(); + ContentMessages.sharedInstance().sendContentListToRoom( + ev.dataTransfer.files, this.state.room.roomId, MatrixClientPeg.get(), + ); this.setState({ draggingFile: false }); - const files = [...ev.dataTransfer.files]; - files.forEach(this.uploadFile); + dis.dispatch({action: 'focus_composer'}); }, onDragLeaveOrEnd: function(ev) { @@ -1026,55 +1000,13 @@ module.exports = React.createClass({ this.setState({ draggingFile: false }); }, - isFileUploadAllowed(file) { - if (this.state.mediaConfig !== undefined && - this.state.mediaConfig["m.upload.size"] !== undefined && - file.size > this.state.mediaConfig["m.upload.size"]) { - return _t("File is too big. Maximum file size is %(fileSize)s", {fileSize: filesize(this.state.mediaConfig["m.upload.size"])}); - } - return true; - }, - - uploadFile: async function(file) { - dis.dispatch({action: 'focus_composer'}); - - if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({action: 'require_registration'}); - return; - } - - try { - await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get()); - } catch (error) { - if (error.name === "UnknownDeviceError") { - // Let the status bar handle this - return; - } - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - console.error("Failed to upload file " + file + " " + error); - Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, { - title: _t('Failed to upload file'), - description: ((error && error.message) - ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), - }); - - // bail early to avoid calling the dispatch below - return; - } - - // Send message_sent callback, for things like _checkIfAlone because after all a file is still a message. - dis.dispatch({ - action: 'message_sent', - }); - }, - injectSticker: function(url, info, text) { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'require_registration'}); return; } - ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) + ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) .done(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this @@ -1666,7 +1598,7 @@ module.exports = React.createClass({ let statusBar; let isStatusAreaExpanded = true; - if (ContentMessages.getCurrentUploads().length > 0) { + if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { const UploadBar = sdk.getComponent('structures.UploadBar'); statusBar = ; } else if (!this.state.searchResults) { @@ -1774,11 +1706,9 @@ module.exports = React.createClass({ messageComposer = ; diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index b54ea00c16..a8cb3c0b0e 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -47,7 +47,7 @@ module.exports = React.createClass({displayName: 'UploadBar', }, render: function() { - const uploads = ContentMessages.getCurrentUploads(); + const uploads = ContentMessages.sharedInstance().getCurrentUploads(); // for testing UI... - also fix up the ContentMessages.getCurrentUploads().length // check in RoomView @@ -93,7 +93,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
{ uploadedSize } / { totalSize } diff --git a/src/components/views/dialogs/UploadConfirmDialog.js b/src/components/views/dialogs/UploadConfirmDialog.js new file mode 100644 index 0000000000..fe10f4cd29 --- /dev/null +++ b/src/components/views/dialogs/UploadConfirmDialog.js @@ -0,0 +1,106 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; + +export default class UploadConfirmDialog extends React.Component { + static propTypes = { + file: PropTypes.object.isRequired, + currentIndex: PropTypes.number, + totalFiles: PropTypes.number, + onFinished: PropTypes.func.isRequired, + } + + static defaultProps = { + totalFiles: 1, + } + + constructor(props) { + super(props); + + this._objectUrl = URL.createObjectURL(props.file); + } + + componentWillUnmount() { + if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); + } + + _onCancelClick = () => { + this.props.onFinished(false); + } + + _onUploadClick = () => { + this.props.onFinished(true); + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + let title; + if (this.props.totalFiles > 1 && this.props.currentIndex !== undefined) { + title = _t( + "Upload files (%(current)s of %(total)s)", + { + current: this.props.currentIndex, + total: this.props.totalFiles, + }, + ); + } else { + title = _t('Upload files'); + } + + let preview; + if (this.props.file.type.startsWith('image/')) { + preview =
+
+
+
{this.props.file.name}
+
+
; + } else { + preview =
+
+ + {this.props.file.name} +
+
; + } + + return ( + +
+ {preview} +
+ + +
+ ); + } +} diff --git a/src/components/views/dialogs/UploadFailureDialog.js b/src/components/views/dialogs/UploadFailureDialog.js new file mode 100644 index 0000000000..1669bf242c --- /dev/null +++ b/src/components/views/dialogs/UploadFailureDialog.js @@ -0,0 +1,120 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import filesize from 'filesize'; + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import ContentMessages from '../../../ContentMessages'; + +/* + * Tells the user about files we know cannot be uploaded before we even try uploading + * them. This is named fairly generically but the only thing we check right now is + * the size of the file. + */ +export default class UploadFailureDialog extends React.Component { + static propTypes = { + badFiles: PropTypes.arrayOf(PropTypes.object).isRequired, + totalFiles: PropTypes.number.isRequired, + contentMessages: PropTypes.instanceOf(ContentMessages).isRequired, + onFinished: PropTypes.func.isRequired, + } + + _onCancelClick = () => { + this.props.onFinished(false); + } + + _onUploadClick = () => { + this.props.onFinished(true); + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + let message; + let preview; + let buttons; + if (this.props.totalFiles === 1 && this.props.badFiles.length === 1) { + message = _t( + "This file is too large to upload. " + + "The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.", + { + limit: filesize(this.props.contentMessages.getUploadLimit()), + sizeOfThisFile: filesize(this.props.badFiles[0].size), + }, { + b: sub => {sub}, + }, + ); + buttons = ; + } else if (this.props.totalFiles === this.props.badFiles.length) { + message = _t( + "These files are too large to upload. " + + "The file size limit is %(limit)s.", + { + limit: filesize(this.props.contentMessages.getUploadLimit()), + }, { + b: sub => {sub}, + }, + ); + buttons = ; + } else { + message = _t( + "Some files are too large to be uploaded." + + "The file size limit is %(limit)s.", + { + limit: filesize(this.props.contentMessages.getUploadLimit()), + }, { + b: sub => {sub}, + }, + ); + const howManyOthers = this.props.totalFiles - this.props.badFiles.length; + buttons = ; + } + + return ( + +
+ {message} + {preview} +
+ + {buttons} +
+ ); + } +} diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index a76a45497b..535d565d22 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -26,6 +26,7 @@ import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import Stickerpicker from './Stickerpicker'; import { makeRoomPermalink } from '../../../matrix-to'; +import ContentMessages from '../../../ContentMessages'; import classNames from 'classnames'; import E2EIcon from './E2EIcon'; @@ -47,8 +48,7 @@ export default class MessageComposer extends React.Component { this.onCallClick = this.onCallClick.bind(this); this.onHangupClick = this.onHangupClick.bind(this); this.onUploadClick = this.onUploadClick.bind(this); - this.onUploadFileSelected = this.onUploadFileSelected.bind(this); - this.uploadFiles = this.uploadFiles.bind(this); + this._onUploadFileInputChange = this._onUploadFileInputChange.bind(this); this.onVoiceCallClick = this.onVoiceCallClick.bind(this); this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this); @@ -145,89 +145,25 @@ export default class MessageComposer extends React.Component { this.refs.uploadInput.click(); } - onUploadFileSelected(files) { - const tfiles = files.target.files; - this.uploadFiles(tfiles); - } + _onUploadFileInputChange(ev) { + if (ev.target.files.length === 0) return; - uploadFiles(files) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const TintableSvg = sdk.getComponent("elements.TintableSvg"); - - const fileList = []; - const acceptedFiles = []; - const failedFiles = []; - - for (let i=0; i - { files[i].name || _t('Attachment') } - ); - fileList.push(files[i]); - } else { - failedFiles.push(
  • - { files[i].name || _t('Attachment') }

    { _t('Reason') + ": " + fileAcceptedOrError}

    -
  • ); - } + // take a copy so we can safely reset the value of the form control + // (Note it is a FileList: we can't use slice or sesnible iteration). + const tfiles = []; + for (let i = 0; i < ev.target.files.length; ++i) { + tfiles.push(ev.target.files[i]); } - const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); - let replyToWarning = null; - if (isQuoting) { - replyToWarning =

    { - _t('At this time it is not possible to reply with a file so this will be sent without being a reply.') - }

    ; - } - - const acceptedFilesPart = acceptedFiles.length === 0 ? null : ( -
    -

    { _t('Are you sure you want to upload the following files?') }

    -
      - { acceptedFiles } -
    -
    + ContentMessages.sharedInstance().sendContentListToRoom( + tfiles, this.props.room.roomId, MatrixClientPeg.get(), ); - const failedFilesPart = failedFiles.length === 0 ? null : ( -
    -

    { _t('The following files cannot be uploaded:') }

    -
      - { failedFiles } -
    -
    - ); - let buttonText; - if (acceptedFiles.length > 0 && failedFiles.length > 0) { - buttonText = "Upload selected" - } else if (failedFiles.length > 0) { - buttonText = "Close" - } - - Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, { - title: _t('Upload Files'), - description: ( -
    - { acceptedFilesPart } - { failedFilesPart } - { replyToWarning } -
    - ), - hasCancelButton: acceptedFiles.length > 0, - button: buttonText, - onFinished: (shouldUpload) => { - if (shouldUpload) { - // MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file - if (fileList) { - for (let i=0; i + onChange={this._onUploadFileInputChange} /> ); @@ -414,7 +350,6 @@ export default class MessageComposer extends React.Component { key="controls_input" room={this.props.room} placeholder={placeholderText} - onFilesPasted={this.uploadFiles} onInputStateChanged={this.onInputStateChanged} permalinkCreator={this.props.permalinkCreator} />, formattingButton, @@ -510,12 +445,6 @@ MessageComposer.propTypes = { // string representing the current voip call state callState: PropTypes.string, - // callback when a file to upload is chosen - uploadFile: PropTypes.func.isRequired, - - // function to test whether a file should be allowed to be uploaded. - uploadAllowed: PropTypes.func.isRequired, - // string representing the current room app drawer state showApps: PropTypes.bool }; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index cbea2bccb9..a0b66e40e1 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -47,6 +47,7 @@ import {Completion} from "../../../autocomplete/Autocompleter"; import Markdown from '../../../Markdown'; import ComposerHistoryManager from '../../../ComposerHistoryManager'; import MessageComposerStore from '../../../stores/MessageComposerStore'; +import ContentMessage from '../../../ContentMessages'; import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix'; @@ -1009,7 +1010,13 @@ export default class MessageComposerInput extends React.Component { switch (transfer.type) { case 'files': - return this.props.onFilesPasted(transfer.files); + // This actually not so much for 'files' as such (at time of writing + // neither chrome nor firefox let you paste a plain file copied + // from Finder) but more images copied from a different website + // / word processor etc. + return ContentMessage.sharedInstance().sendContentListToRoom( + transfer.files, this.props.room.roomId, this.client, + ); case 'html': { if (this.state.isRichTextEnabled) { // FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b40c6aab13..2e26771208 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -40,7 +40,10 @@ "A call is already in progress!": "A call is already in progress!", "Permission Required": "Permission Required", "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", - "The file '%(fileName)s' failed to upload": "The file '%(fileName)s' failed to upload", + "Replying With Files": "Replying With Files", + "At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.", + "Continue": "Continue", + "The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", "Upload Failed": "Upload Failed", "Failure to create room": "Failure to create room", @@ -353,7 +356,6 @@ "Verify this user by confirming the following number appears on their screen.": "Verify this user by confirming the following number appears on their screen.", "Unable to find a supported verification method.": "Unable to find a supported verification method.", "For maximum security, we recommend you do this in person or use another trusted means of communication.": "For maximum security, we recommend you do this in person or use another trusted means of communication.", - "Continue": "Continue", "Dog": "Dog", "Cat": "Cat", "Lion": "Lion", @@ -718,11 +720,6 @@ "block-quote": "block-quote", "bulleted-list": "bulleted-list", "numbered-list": "numbered-list", - "Attachment": "Attachment", - "At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.", - "Are you sure you want to upload the following files?": "Are you sure you want to upload the following files?", - "The following files cannot be uploaded:": "The following files cannot be uploaded:", - "Upload Files": "Upload Files", "Hangup": "Hangup", "Voice call": "Voice call", "Video call": "Video call", @@ -873,6 +870,7 @@ "Today": "Today", "Yesterday": "Yesterday", "Error decrypting audio": "Error decrypting audio", + "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", "Decrypt %(text)s": "Decrypt %(text)s", "Download %(text)s": "Download %(text)s", @@ -1191,6 +1189,16 @@ "Room contains unknown devices": "Room contains unknown devices", "\"%(RoomName)s\" contains devices that you haven't seen before.": "\"%(RoomName)s\" contains devices that you haven't seen before.", "Unknown devices": "Unknown devices", + "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", + "Upload files": "Upload files", + "Upload": "Upload", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.", + "These files are too large to upload. The file size limit is %(limit)s.": "These files are too large to upload. The file size limit is %(limit)s.", + "Some files are too large to be uploaded.The file size limit is %(limit)s.": "Some files are too large to be uploaded.The file size limit is %(limit)s.", + "Upload %(count)s other files|other": "Upload %(count)s other files", + "Upload %(count)s other files|one": "Upload %(count)s other file", + "Cancel All": "Cancel All", + "Upload Error": "Upload Error", "A widget would like to verify your identity": "A widget would like to verify your identity", "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.", "Remember my selection for this widget": "Remember my selection for this widget", @@ -1416,9 +1424,6 @@ "There's no one else here! Would you like to invite others or stop warning about the empty room?": "There's no one else here! Would you like to invite others or stop warning about the empty room?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", - "File is too big. Maximum file size is %(fileSize)s": "File is too big. Maximum file size is %(fileSize)s", - "Failed to upload file": "Failed to upload file", - "Server may be unavailable, overloaded, or the file too big": "Server may be unavailable, overloaded, or the file too big", "Search failed": "Search failed", "Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(", "No more results": "No more results", From fedd4598b5eb85d8ba8aac84e276f9218c03bf40 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 1 Apr 2019 16:50:23 +0100 Subject: [PATCH 02/12] Space The final frontier --- src/components/views/dialogs/UploadFailureDialog.js | 2 +- src/i18n/strings/en_EN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/dialogs/UploadFailureDialog.js b/src/components/views/dialogs/UploadFailureDialog.js index 1669bf242c..e264f1a3fb 100644 --- a/src/components/views/dialogs/UploadFailureDialog.js +++ b/src/components/views/dialogs/UploadFailureDialog.js @@ -83,7 +83,7 @@ export default class UploadFailureDialog extends React.Component { />; } else { message = _t( - "Some files are too large to be uploaded." + + "Some files are too large to be uploaded. " + "The file size limit is %(limit)s.", { limit: filesize(this.props.contentMessages.getUploadLimit()), diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2e26771208..a2f939dc81 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1194,7 +1194,7 @@ "Upload": "Upload", "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.", "These files are too large to upload. The file size limit is %(limit)s.": "These files are too large to upload. The file size limit is %(limit)s.", - "Some files are too large to be uploaded.The file size limit is %(limit)s.": "Some files are too large to be uploaded.The file size limit is %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Some files are too large to be uploaded. The file size limit is %(limit)s.", "Upload %(count)s other files|other": "Upload %(count)s other files", "Upload %(count)s other files|one": "Upload %(count)s other file", "Cancel All": "Cancel All", From 0add3d0825cf996f65716ab7984c330e73decdea Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 1 Apr 2019 16:52:06 +0100 Subject: [PATCH 03/12] Translate into peoplecounting --- src/components/views/dialogs/UploadConfirmDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/dialogs/UploadConfirmDialog.js b/src/components/views/dialogs/UploadConfirmDialog.js index fe10f4cd29..f026454cea 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.js +++ b/src/components/views/dialogs/UploadConfirmDialog.js @@ -58,7 +58,7 @@ export default class UploadConfirmDialog extends React.Component { title = _t( "Upload files (%(current)s of %(total)s)", { - current: this.props.currentIndex, + current: this.props.currentIndex + 1, total: this.props.totalFiles, }, ); From 7e2291592c4678a0ad545f50f45f32fefb6ba734 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 28 Mar 2019 19:19:12 -0600 Subject: [PATCH 04/12] Apply 50% opacity to left breadcrumbs Fixes https://github.com/vector-im/riot-web/issues/8564 We listen for membership changes to make sure the state is kept up to date. --- res/css/views/rooms/_RoomBreadcrumbs.scss | 3 +++ src/components/views/rooms/RoomBreadcrumbs.js | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/res/css/views/rooms/_RoomBreadcrumbs.scss b/res/css/views/rooms/_RoomBreadcrumbs.scss index dbd1d3cf5f..67227c7115 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs.scss @@ -47,6 +47,9 @@ limitations under the License. transform: scale(0); } + .mx_RoomBreadcrumbs_left { + opacity: 0.5; + } // Note: we have to manually control the gradient and stuff, but the IndicatorScrollbar // will deal with left/right positioning for us. Normally we'd use position:sticky on diff --git a/src/components/views/rooms/RoomBreadcrumbs.js b/src/components/views/rooms/RoomBreadcrumbs.js index 1feff19ca2..dc1bd3097b 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.js +++ b/src/components/views/rooms/RoomBreadcrumbs.js @@ -52,10 +52,15 @@ export default class RoomBreadcrumbs extends React.Component { console.error("Failed to parse breadcrumbs:", e); } } + + MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership); } componentWillUnmount() { dis.unregister(this._dispatcherRef); + + const client = MatrixClientPeg.get(); + if (client) client.removeListener("Room.myMembership", this.onMyMembership); } componentDidUpdate() { @@ -81,6 +86,13 @@ export default class RoomBreadcrumbs extends React.Component { } } + onMyMembership = (room, membership) => { + if (membership === "leave" || membership === "ban") { + // Force left rooms to render appropriately + this.forceUpdate(); + } + }; + _appendRoomId(roomId) { const room = MatrixClientPeg.get().getRoom(roomId); if (!room) { @@ -136,6 +148,7 @@ export default class RoomBreadcrumbs extends React.Component { "mx_RoomBreadcrumbs_crumb": true, "mx_RoomBreadcrumbs_preAnimate": isFirst && !animated, "mx_RoomBreadcrumbs_animate": isFirst, + "mx_RoomBreadcrumbs_left": !['invite', 'join'].includes(room.getMyMembership()), }); let tooltip = null; From 3f9d0886676806d3760706641eaf91f9c5f8c22a Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 1 Apr 2019 20:34:33 -0600 Subject: [PATCH 05/12] Put the stickerpicker below dialogs Fixes https://github.com/vector-im/riot-web/issues/9353 The ContextualMenu now accepts a zIndex parameter which can be used to control its level. Dialogs are at around 4000 in the z-index, and the context menu defaults to 5000 for the things that need tooltips and such in a dialog. --- src/components/structures/ContextualMenu.js | 11 +++++++++-- src/components/views/rooms/Stickerpicker.js | 7 ++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index d551a6fe27..345eae2b18 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -56,6 +56,7 @@ export default class ContextualMenu extends React.Component { menuPaddingRight: PropTypes.number, menuPaddingBottom: PropTypes.number, menuPaddingLeft: PropTypes.number, + zIndex: PropTypes.number, // If true, insert an invisible screen-sized element behind the // menu that when clicked will close it. @@ -215,16 +216,22 @@ export default class ContextualMenu extends React.Component { menuStyle["paddingRight"] = props.menuPaddingRight; } + const wrapperStyle = {}; + if (!isNaN(Number(props.zIndex))) { + menuStyle["zIndex"] = props.zIndex + 1; + wrapperStyle["zIndex"] = props.zIndex; + } + const ElementClass = props.elementClass; // FIXME: If a menu uses getDefaultProps it clobbers the onFinished // property set here so you can't close the menu from a button click! - return
    + return
    { chevron }
    - { props.hasBackground &&
    }
    ; diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index 9d8912954c..b487c90ae2 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -29,9 +29,9 @@ import PersistedElement from "../elements/PersistedElement"; const widgetType = 'm.stickerpicker'; -// We sit in a context menu, so the persisted element container needs to float -// above it, so it needs a greater z-index than the ContextMenu -const STICKERPICKER_Z_INDEX = 5000; +// This should be below the dialog level (4000), but above the rest of the UI (1000-2000). +// We sit in a context menu, so this should be given to the context menu. +const STICKERPICKER_Z_INDEX = 3500; // Key to store the widget's AppTile under in PersistedElement const PERSISTED_ELEMENT_KEY = "stickerPicker"; @@ -367,6 +367,7 @@ export default class Stickerpicker extends React.Component { menuPaddingTop={0} menuPaddingLeft={0} menuPaddingRight={0} + zIndex={STICKERPICKER_Z_INDEX} />; if (this.state.showStickers) { From b6faaf419c3ddf9257dda3e4a6083e3b027ae028 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 2 Apr 2019 10:50:17 +0100 Subject: [PATCH 06/12] PR feedback --- src/ContentMessages.js | 6 ++++-- src/components/structures/RoomView.js | 28 +++++++++++++-------------- src/i18n/strings/en_EN.json | 2 +- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 426a707f65..f4aafbeb31 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -321,6 +321,8 @@ export default class ContentMessages { getUploadLimit() { if (this._mediaConfig !== null && this._mediaConfig["m.upload.size"] !== undefined) { return this._mediaConfig["m.upload.size"]; + } else { + return null; } } @@ -338,8 +340,8 @@ export default class ContentMessages { title: _t('Replying With Files'), description: (
    {_t( - 'At this time it is not possible to reply with a file ' + - 'so this will be sent without being a reply.', + 'At this time it is not possible to reply with a file. ' + + 'Would you like to upload this file without replying?', )}
    ), hasCancelButton: true, diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 7914810458..47de3f2cb0 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -20,27 +20,27 @@ limitations under the License. // - Search results component // - Drag and drop -import shouldHideEvent from "../../shouldHideEvent"; +import shouldHideEvent from '../../shouldHideEvent'; -const React = require("react"); -const ReactDOM = require("react-dom"); +import React from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import Promise from 'bluebird'; import filesize from 'filesize'; -const classNames = require("classnames"); +import classNames from 'classnames'; import { _t } from '../../languageHandler'; -import {RoomPermalinkCreator} from "../../matrix-to"; +import {RoomPermalinkCreator} from '../../matrix-to'; -const MatrixClientPeg = require("../../MatrixClientPeg"); +import MatrixClientPeg from '../../MatrixClientPeg'; import ContentMessages from '../../ContentMessages'; -const Modal = require("../../Modal"); -const sdk = require('../../index'); -const CallHandler = require('../../CallHandler'); -const dis = require("../../dispatcher"); -const Tinter = require("../../Tinter"); -const rate_limited_func = require('../../ratelimitedfunc'); -const ObjectUtils = require('../../ObjectUtils'); -const Rooms = require('../../Rooms'); +import Modal from '../../Modal'; +import sdk from '../../index'; +import CallHandler from '../../CallHandler'; +import dis from '../../dispatcher'; +import Tinter from '../../Tinter'; +import rate_limited_func from '../../ratelimitedfunc'; +import ObjectUtils from '../../ObjectUtils'; +import Rooms from '../../Rooms'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a2f939dc81..54d4bde14a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -41,7 +41,7 @@ "Permission Required": "Permission Required", "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", "Replying With Files": "Replying With Files", - "At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "At this time it is not possible to reply with a file. Would you like to upload this file without replying?", "Continue": "Continue", "The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", From 6e6e7483764ccc206e4de076feafcc202f0dd676 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 2 Apr 2019 12:31:18 +0100 Subject: [PATCH 07/12] Logging tweaks To try & diagnose https://github.com/vector-im/riot-web/issues/7769 Null check on e before looking for the `name` property and log it separately rather than appending it to a string (not that this will help if it's undefined, but it's more useful in general). --- src/MatrixClientPeg.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index f5994921de..94a016d207 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -120,7 +120,7 @@ class MatrixClientPeg { await this.matrixClient.initCrypto(); } } catch (e) { - if (e.name === 'InvalidCryptoStoreError') { + if (e && e.name === 'InvalidCryptoStoreError') { // The js-sdk found a crypto DB too new for it to use const CryptoStoreTooNewDialog = sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog"); @@ -130,7 +130,7 @@ class MatrixClientPeg { } // this can happen for a number of reasons, the most likely being // that the olm library was missing. It's not fatal. - console.warn("Unable to initialise e2e: " + e); + console.warn("Unable to initialise e2e", e); } const opts = utils.deepCopy(this.opts); From 726e91101af6d2fec424a2e0eee46b0a36aa5d2d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 2 Apr 2019 17:53:53 +0200 Subject: [PATCH 08/12] allow fractional values for scrollTop above and below expected value --- src/components/structures/ScrollPanel.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index cbbca3c468..c3c13b8a11 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -222,10 +222,12 @@ module.exports = React.createClass({ // whether it will stay that way when the children update. isAtBottom: function() { const sn = this._getScrollNode(); - // fractional values for scrollTop happen on certain browsers/platforms + // fractional values (both too big and too small) + // for scrollTop happen on certain browsers/platforms // when scrolled all the way down. E.g. Chrome 72 on debian. - // so ceil everything upwards to make sure it aligns. - return Math.ceil(sn.scrollTop) === Math.ceil(sn.scrollHeight - sn.clientHeight); + // so check difference <= 1; + return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1; + }, // returns the vertical height in the given direction that can be removed from From 86c13b97ce1bcff69470a8f70bf0f86b8b4b000f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 2 Apr 2019 17:54:14 +0200 Subject: [PATCH 09/12] increase PAGE_SIZE so users can scroll up already while waiting for pag. --- src/components/structures/ScrollPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index c3c13b8a11..cd9d68ae7e 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -30,7 +30,7 @@ const UNPAGINATION_PADDING = 6000; // many scroll events causing many unfilling requests. const UNFILL_REQUEST_DEBOUNCE_MS = 200; -const PAGE_SIZE = 200; +const PAGE_SIZE = 400; let debuglog; if (DEBUG_SCROLL) { From c0f06602c598b6139ae8fdb65cf6eedf2f197a65 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 2 Apr 2019 10:47:39 -0600 Subject: [PATCH 10/12] Use state instead of forceUpdate --- src/components/views/rooms/RoomBreadcrumbs.js | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/views/rooms/RoomBreadcrumbs.js b/src/components/views/rooms/RoomBreadcrumbs.js index dc1bd3097b..69c36e5a65 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.js +++ b/src/components/views/rooms/RoomBreadcrumbs.js @@ -88,8 +88,12 @@ export default class RoomBreadcrumbs extends React.Component { onMyMembership = (room, membership) => { if (membership === "leave" || membership === "ban") { - // Force left rooms to render appropriately - this.forceUpdate(); + const rooms = this.state.rooms.slice(); + const roomState = rooms.find((r) => r.room.roomId === room.roomId); + if (roomState) { + roomState.left = true; + this.setState({rooms}); + } } }; @@ -142,24 +146,24 @@ export default class RoomBreadcrumbs extends React.Component { return null; } const rooms = this.state.rooms; - const avatars = rooms.map(({room, animated, hover}, i) => { + const avatars = rooms.map((r, i) => { const isFirst = i === 0; const classes = classNames({ "mx_RoomBreadcrumbs_crumb": true, - "mx_RoomBreadcrumbs_preAnimate": isFirst && !animated, + "mx_RoomBreadcrumbs_preAnimate": isFirst && !r.animated, "mx_RoomBreadcrumbs_animate": isFirst, - "mx_RoomBreadcrumbs_left": !['invite', 'join'].includes(room.getMyMembership()), + "mx_RoomBreadcrumbs_left": r.left, }); let tooltip = null; - if (hover) { - tooltip = ; + if (r.hover) { + tooltip = ; } return ( - this._viewRoom(room)} - onMouseEnter={() => this._onMouseEnter(room)} onMouseLeave={() => this._onMouseLeave(room)}> - + this._viewRoom(r.room)} + onMouseEnter={() => this._onMouseEnter(r.room)} onMouseLeave={() => this._onMouseLeave(r.room)}> + {tooltip} ); From fee8d79267484c4ea33116861495bc4fe37c2df9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Apr 2019 09:16:30 +0200 Subject: [PATCH 11/12] comment for PAGE_SIZE --- src/components/structures/ScrollPanel.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index cd9d68ae7e..7e1f0ff469 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -29,7 +29,10 @@ const UNPAGINATION_PADDING = 6000; // The number of milliseconds to debounce calls to onUnfillRequest, to prevent // many scroll events causing many unfilling requests. const UNFILL_REQUEST_DEBOUNCE_MS = 200; - +// _updateHeight makes the height a ceiled multiple of this so we +// don't have to update the height too often. It also allows the user +// to scroll past the pagination spinner a bit so they don't feel blocked so +// much while the content loads. const PAGE_SIZE = 400; let debuglog; From ba7caaa115af9b1e199f4518c925be8e921aaa45 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 3 Apr 2019 11:50:39 +0100 Subject: [PATCH 12/12] Update yarn.lock --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 5f08d07fcf..79e79279c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4379,7 +4379,7 @@ math-random@^1.0.1: resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== -matrix-js-sdk@^1.0.3: +matrix-js-sdk@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-1.0.3.tgz#d4cc46c4dc80278b78f8e0664741b08fcc395c79" integrity sha512-YpF4NvnG2cttRmTPJ9yqs/KwlBXW15O7+nNMs1FKj1CqdW1Phwb0fcqvahjPgmfXyn5DFzU3Deiv9aNgDIlIog==