diff --git a/prototypes/menu-relative.html b/prototypes/menu-relative.html new file mode 100644 index 00000000..8b1c79b0 --- /dev/null +++ b/prototypes/menu-relative.html @@ -0,0 +1,378 @@ + + + + + + + +
+
+

Welcome!

+ +
+
+
+

Room xyz

+ +
+ +
+ + +
+
+
+ + + diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 6cfea617..1858fce7 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -133,7 +133,8 @@ export class RoomTileViewModel extends ViewModel { get avatarUrl() { if (this._room.avatarUrl) { - return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, 32, 32, "crop"); + const size = 32 * this.platform.devicePixelRatio; + return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop"); } return null; } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 10be15a7..5e6ee998 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -134,7 +134,8 @@ export class RoomViewModel extends ViewModel { get avatarUrl() { if (this._room.avatarUrl) { - return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, 32, 32, "crop"); + const size = 32 * this.platform.devicePixelRatio; + return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop"); } return null; } @@ -164,21 +165,67 @@ export class RoomViewModel extends ViewModel { return false; } - async _sendFile() { - let file; + async _pickAndSendFile() { try { - file = await this.platform.openFile(); + const file = await this.platform.openFile(); + if (!file) { + return; + } + return this._sendFile(file); } catch (err) { - return; + console.error(err); } - const attachment = this._room.uploadAttachment(file.blob, file.name); + } + + async _sendFile(file) { const content = { body: file.name, - msgtype: "m.file", + msgtype: "m.file" }; - await this._room.sendEvent("m.room.message", content, attachment); + await this._room.sendEvent("m.room.message", content, { + "url": this._room.createAttachment(file.blob, file.name) + }); } + async _pickAndSendPicture() { + try { + if (!this.platform.hasReadPixelPermission()) { + alert("Please allow canvas image data access, so we can scale your images down."); + return; + } + const file = await this.platform.openFile("image/*"); + if (!file) { + return; + } + if (!file.blob.mimeType.startsWith("image/")) { + return this._sendFile(file); + } + let image = await this.platform.loadImage(file.blob); + const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); + if (limit && image.maxDimension > limit) { + image = await image.scale(limit); + } + const content = { + body: file.name, + msgtype: "m.image", + info: imageToInfo(image) + }; + const attachments = { + "url": this._room.createAttachment(image.blob, file.name), + }; + if (image.maxDimension > 600) { + const thumbnail = await image.scale(400); + content.info.thumbnail_info = imageToInfo(thumbnail); + attachments["info.thumbnail_url"] = + this._room.createAttachment(thumbnail.blob, file.name); + } + await this._room.sendEvent("m.room.message", content, attachments); + } catch (err) { + console.error(err); + } + } + + get composerViewModel() { return this._composerVM; } @@ -204,8 +251,12 @@ class ComposerViewModel extends ViewModel { return success; } - sendAttachment() { - this._roomVM._sendFile(); + sendPicture() { + this._roomVM._pickAndSendPicture(); + } + + sendFile() { + this._roomVM._pickAndSendFile(); } get canSend() { @@ -223,3 +274,12 @@ class ComposerViewModel extends ViewModel { } } } + +function imageToInfo(image) { + return { + w: image.width, + h: image.height, + mimetype: image.blob.mimeType, + size: image.blob.size + }; +} diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index eab55136..f3a31e21 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -17,24 +17,17 @@ limitations under the License. import {MessageTile} from "./MessageTile.js"; import {formatSize} from "../../../../../utils/formatSize.js"; +import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; export class FileTile extends MessageTile { constructor(options) { super(options); - this._error = null; + this._downloadError = null; this._downloading = false; - if (this._isUploading) { - // should really do this with an ObservableValue and waitFor to prevent leaks when the promise never resolves - this._entry.attachment.uploaded().finally(() => { - if (!this.isDisposed) { - this.emitChange("label"); - } - }); - } } async download() { - if (this._downloading || this._isUploading) { + if (this._downloading || this.isPending) { return; } const content = this._getContent(); @@ -46,7 +39,7 @@ export class FileTile extends MessageTile { blob = await this._mediaRepository.downloadAttachment(content); this.platform.saveFileAs(blob, filename); } catch (err) { - this._error = err; + this._downloadError = err; } finally { blob?.dispose(); this._downloading = false; @@ -54,39 +47,40 @@ export class FileTile extends MessageTile { this.emitChange("label"); } - get size() { - if (this._isUploading) { - return this._entry.attachment.localPreview.size; - } else { - return this._getContent().info?.size; - } - } - - get _isUploading() { - return this._entry.attachment && !this._entry.attachment.isUploaded; - } - get label() { - if (this._error) { - return `Could not decrypt file: ${this._error.message}`; - } - if (this._entry.attachment?.error) { - return `Failed to upload: ${this._entry.attachment.error.message}`; + if (this._downloadError) { + return `Could not download file: ${this._downloadError.message}`; } const content = this._getContent(); const filename = content.body; - const size = formatSize(this.size); - if (this._isUploading) { - return this.i18n`Uploading ${filename} (${size})…`; - } else if (this._downloading) { - return this.i18n`Downloading ${filename} (${size})…`; - } else { - return this.i18n`Download ${filename} (${size})`; - } - } - get error() { - return null; + if (this._entry.isPending) { + const {pendingEvent} = this._entry; + switch (pendingEvent?.status) { + case SendStatus.Waiting: + return this.i18n`Waiting to send ${filename}…`; + case SendStatus.EncryptingAttachments: + case SendStatus.Encrypting: + return this.i18n`Encrypting ${filename}…`; + case SendStatus.UploadingAttachments:{ + const percent = Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); + return this.i18n`Uploading ${filename}: ${percent}%`; + } + case SendStatus.Sending: + return this.i18n`Sending ${filename}…`; + case SendStatus.Error: + return this.i18n`Error: could not send ${filename}: ${pendingEvent.error.message}`; + default: + return `Unknown send status for ${filename}`; + } + } else { + const size = formatSize(this._getContent().info?.size); + if (this._downloading) { + return this.i18n`Downloading ${filename} (${size})…`; + } else { + return this.i18n`Download ${filename} (${size})`; + } + } } get shape() { diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 6d518b11..fc70551d 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -16,7 +16,7 @@ limitations under the License. */ import {MessageTile} from "./MessageTile.js"; - +import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; const MAX_HEIGHT = 300; const MAX_WIDTH = 400; @@ -26,7 +26,9 @@ export class ImageTile extends MessageTile { this._decryptedThumbail = null; this._decryptedImage = null; this._error = null; - this.load(); + if (!this.isPending) { + this.tryLoadEncryptedThumbnail(); + } this._lightboxUrl = this.urlCreator.urlForSegments([ // ensure the right room is active if in grid view this.navigation.segment("room", this._room.id), @@ -43,7 +45,7 @@ export class ImageTile extends MessageTile { return this.track(blob); } - async load() { + async tryLoadEncryptedThumbnail() { try { const thumbnailFile = this._getContent().info?.thumbnail_file; const file = this._getContent().file; @@ -61,7 +63,38 @@ export class ImageTile extends MessageTile { } get lightboxUrl() { - return this._lightboxUrl; + if (!this.isPending) { + return this._lightboxUrl; + } + return ""; + } + + get isUploading() { + return this.isPending && this._entry.pendingEvent.status === SendStatus.UploadingAttachments; + } + + get uploadPercentage() { + const {pendingEvent} = this._entry; + return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); + } + + get sendStatus() { + const {pendingEvent} = this._entry; + switch (pendingEvent?.status) { + case SendStatus.Waiting: + return this.i18n`Waiting…`; + case SendStatus.EncryptingAttachments: + case SendStatus.Encrypting: + return this.i18n`Encrypting…`; + case SendStatus.UploadingAttachments: + return this.i18n`Uploading…`; + case SendStatus.Sending: + return this.i18n`Sending…`; + case SendStatus.Error: + return this.i18n`Error: ${pendingEvent.error.message}`; + default: + return ""; + } } get thumbnailUrl() { @@ -70,6 +103,10 @@ export class ImageTile extends MessageTile { } else if (this._decryptedImage) { return this._decryptedImage.url; } + if (this._entry.isPending) { + const attachment = this._entry.pendingEvent.getAttachment("url"); + return attachment && attachment.localPreview.url; + } const mxcUrl = this._getContent()?.url; if (typeof mxcUrl === "string") { return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); @@ -77,16 +114,6 @@ export class ImageTile extends MessageTile { return ""; } - async loadImageUrl() { - if (!this._decryptedImage) { - const file = this._getContent().file; - if (file) { - this._decryptedImage = await this._loadEncryptedFile(file); - } - } - return this._decryptedImage?.url || ""; - } - _scaleFactor() { const info = this._getContent()?.info; const scaleHeightFactor = MAX_HEIGHT / info?.h; diff --git a/src/domain/session/room/timeline/tiles/MissingAttachmentTile.js b/src/domain/session/room/timeline/tiles/MissingAttachmentTile.js new file mode 100644 index 00000000..0a9b5976 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/MissingAttachmentTile.js @@ -0,0 +1,33 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 {MessageTile} from "./MessageTile.js"; + +export class MissingAttachmentTile extends MessageTile { + get shape() { + return "missing-attachment" + } + + get label() { + const name = this._getContent().body; + const msgtype = this._getContent().msgtype; + if (msgtype === "m.image") { + return this.i18n`The image ${name} wasn't fully sent previously and could not be recovered.`; + } else { + return this.i18n`The file ${name} wasn't fully sent previously and could not be recovered.`; + } + } +} diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index ec60bbba..f29f46d6 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -46,6 +46,11 @@ export class SimpleTile extends ViewModel { get isPending() { return this._entry.isPending; } + + abortSending() { + this._entry.pendingEvent?.abort(); + } + // TilesCollection contract below setUpdateEmit(emitUpdate) { this.updateOptions({emitChange: paramName => { diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index 9ac27a54..1901efe2 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -23,12 +23,15 @@ import {RoomNameTile} from "./tiles/RoomNameTile.js"; import {RoomMemberTile} from "./tiles/RoomMemberTile.js"; import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js"; import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js"; +import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js"; export function tilesCreator(baseOptions) { return function tilesCreator(entry, emitUpdate) { const options = Object.assign({entry, emitUpdate}, baseOptions); if (entry.isGap) { return new GapTile(options); + } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { + return new MissingAttachmentTile(options); } else if (entry.eventType) { switch (entry.eventType) { case "m.room.message": { diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 0a1f223c..2c072305 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -36,10 +36,26 @@ export class SettingsViewModel extends ViewModel { this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session}))); this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._estimate = null; + + this.sentImageSizeLimit = null; + this.minSentImageSizeLimit = 400; + this.maxSentImageSizeLimit = 4000; + } + + setSentImageSizeLimit(size) { + if (size > this.maxSentImageSizeLimit || size < this.minSentImageSizeLimit) { + this.sentImageSizeLimit = null; + this.platform.settingsStorage.remove("sentImageSizeLimit"); + } else { + this.sentImageSizeLimit = Math.round(size); + this.platform.settingsStorage.setInt("sentImageSizeLimit", size); + } + this.emitChange("sentImageSizeLimit"); } async load() { this._estimate = await this.platform.estimateStorageUsage(); + this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); this.emitChange(""); } diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js index 105c16c5..3ead83eb 100644 --- a/src/matrix/e2ee/attachment.js +++ b/src/matrix/e2ee/attachment.js @@ -65,7 +65,7 @@ export async function encryptAttachment(platform, blob) { const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer}); const digest = await crypto.digest("SHA-256", ciphertext); return { - blob: platform.createBlob(ciphertext, blob.mimeType), + blob: platform.createBlob(ciphertext, 'application/octet-stream'), info: { v: "v2", key, diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index bdf35363..935c6889 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -110,6 +110,7 @@ export class HomeServerApi { headers, body: encodedBody, timeout: options?.timeout, + uploadProgress: options?.uploadProgress, format: "json" // response format }); diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index 98d81894..a66fbd45 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -17,56 +17,31 @@ limitations under the License. import {encryptAttachment} from "../e2ee/attachment.js"; export class AttachmentUpload { - constructor({filename, blob, hsApi, platform, isEncrypted}) { + constructor({filename, blob, platform}) { this._filename = filename; + // need to keep around for local preview while uploading this._unencryptedBlob = blob; - this._isEncrypted = isEncrypted; + this._transferredBlob = this._unencryptedBlob; this._platform = platform; - this._hsApi = hsApi; this._mxcUrl = null; - this._transferredBlob = null; this._encryptionInfo = null; - this._uploadPromise = null; this._uploadRequest = null; this._aborted = false; this._error = null; + this._sentBytes = 0; } - upload() { - if (!this._uploadPromise) { - this._uploadPromise = this._upload(); - } - return this._uploadPromise; + /** important to call after encrypt() if encryption is needed */ + get size() { + return this._transferredBlob.size; } - async _upload() { - try { - let transferredBlob = this._unencryptedBlob; - if (this._isEncrypted) { - const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob); - transferredBlob = blob; - this._encryptionInfo = info; - } - if (this._aborted) { - return; - } - this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename); - const {content_uri} = await this._uploadRequest.response(); - this._mxcUrl = content_uri; - this._transferredBlob = transferredBlob; - } catch (err) { - this._error = err; - throw err; - } - } - - get isUploaded() { - return !!this._transferredBlob; + get sentBytes() { + return this._sentBytes; } /** @public */ abort() { - this._aborted = true; this._uploadRequest?.abort(); } @@ -75,34 +50,62 @@ export class AttachmentUpload { return this._unencryptedBlob; } - get error() { - return this._error; - } - /** @package */ - uploaded() { - if (!this._uploadPromise) { - throw new Error("upload has not started yet"); + async encrypt() { + if (this._encryptionInfo) { + throw new Error("already encrypted"); } - return this._uploadPromise; + const {info, blob} = await encryptAttachment(this._platform, this._transferredBlob); + this._transferredBlob = blob; + this._encryptionInfo = info; } /** @package */ - applyToContent(content) { + async upload(hsApi, progressCallback) { + this._uploadRequest = hsApi.uploadAttachment(this._transferredBlob, this._filename, { + uploadProgress: sentBytes => { + this._sentBytes = sentBytes; + progressCallback(); + } + }); + const {content_uri} = await this._uploadRequest.response(); + this._mxcUrl = content_uri; + } + + /** @package */ + applyToContent(urlPath, content) { if (!this._mxcUrl) { throw new Error("upload has not finished"); } - content.info = { - size: this._transferredBlob.size, - mimetype: this._unencryptedBlob.mimeType, - }; - if (this._isEncrypted) { - content.file = Object.assign(this._encryptionInfo, { + let prefix = urlPath.substr(0, urlPath.lastIndexOf("url")); + setPath(`${prefix}info.size`, content, this._transferredBlob.size); + setPath(`${prefix}info.mimetype`, content, this._unencryptedBlob.mimeType); + if (this._encryptionInfo) { + setPath(`${prefix}file`, content, Object.assign(this._encryptionInfo, { mimetype: this._unencryptedBlob.mimeType, url: this._mxcUrl - }); + })); } else { - content.url = this._mxcUrl; + setPath(`${prefix}url`, content, this._mxcUrl); } } + + dispose() { + this._unencryptedBlob.dispose(); + this._transferredBlob.dispose(); + } +} + +function setPath(path, content, value) { + const parts = path.split("."); + let obj = content; + for (let i = 0; i < (parts.length - 1); i += 1) { + const key = parts[i]; + if (!obj[key]) { + obj[key] = {}; + } + obj = obj[key]; + } + const propKey = parts[parts.length - 1]; + obj[propKey] = value; } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 109c096e..4cf9106e 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -352,8 +352,8 @@ export class Room extends EventEmitter { } /** @public */ - sendEvent(eventType, content, attachment) { - return this._sendQueue.enqueueEvent(eventType, content, attachment); + sendEvent(eventType, content, attachments) { + return this._sendQueue.enqueueEvent(eventType, content, attachments); } /** @public */ @@ -633,16 +633,14 @@ export class Room extends EventEmitter { } } - uploadAttachment(blob, filename) { - const attachment = new AttachmentUpload({blob, filename, - hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted}); - attachment.upload(); - return attachment; + createAttachment(blob, filename) { + return new AttachmentUpload({blob, filename, platform: this._platform}); } dispose() { this._roomEncryption?.dispose(); this._timeline?.dispose(); + this._sendQueue.dispose(); } } diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 030e57b6..1980b30b 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -13,11 +13,35 @@ 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 {createEnum} from "../../../utils/enum.js"; +import {AbortError} from "../../../utils/error.js"; + +export const SendStatus = createEnum( + "Waiting", + "EncryptingAttachments", + "UploadingAttachments", + "Encrypting", + "Sending", + "Sent", + "Error", +); export class PendingEvent { - constructor(data, attachment) { + constructor({data, remove, emitUpdate, attachments}) { this._data = data; - this.attachment = attachment; + this._attachments = attachments; + this._emitUpdate = () => { + console.log("PendingEvent status", this.status, this._attachments && Object.entries(this._attachments).map(([key, a]) => `${key}: ${a.sentBytes}/${a.size}`)); + emitUpdate(); + }; + this._removeFromQueueCallback = remove; + this._aborted = false; + this._status = SendStatus.Waiting; + this._sendRequest = null; + this._attachmentsTotalBytes = 0; + if (this._attachments) { + this._attachmentsTotalBytes = Object.values(this._attachments).reduce((t, a) => t + a.size, 0); + } } get roomId() { return this._data.roomId; } @@ -25,14 +49,129 @@ export class PendingEvent { get eventType() { return this._data.eventType; } get txnId() { return this._data.txnId; } get remoteId() { return this._data.remoteId; } - set remoteId(value) { this._data.remoteId = value; } get content() { return this._data.content; } - get needsEncryption() { return this._data.needsEncryption; } get data() { return this._data; } + getAttachment(key) { + return this._attachments && this._attachments[key]; + } + + get needsSending() { + return !this.remoteId && !this.aborted; + } + + get needsEncryption() { + return this._data.needsEncryption && !this.aborted; + } + + get needsUpload() { + return this._data.needsUpload && !this.aborted; + } + + get isMissingAttachments() { + return this.needsUpload && !this._attachments; + } + + setEncrypting() { + this._status = SendStatus.Encrypting; + this._emitUpdate("status"); + } + setEncrypted(type, content) { - this._data.eventType = type; - this._data.content = content; + this._data.encryptedEventType = type; + this._data.encryptedContent = content; this._data.needsEncryption = false; } + + setError(error) { + this._status = SendStatus.Error; + this._error = error; + this._emitUpdate("status"); + } + + get status() { return this._status; } + get error() { return this._error; } + + get attachmentsTotalBytes() { + return this._attachmentsTotalBytes; + } + + get attachmentsSentBytes() { + return this._attachments && Object.values(this._attachments).reduce((t, a) => t + a.sentBytes, 0); + } + + async uploadAttachments(hsApi) { + if (!this.needsUpload) { + return; + } + if (!this._attachments) { + throw new Error("attachments missing"); + } + if (this.needsEncryption) { + this._status = SendStatus.EncryptingAttachments; + this._emitUpdate("status"); + for (const attachment of Object.values(this._attachments)) { + await attachment.encrypt(); + if (this.aborted) { + throw new AbortError(); + } + } + } + this._status = SendStatus.UploadingAttachments; + this._emitUpdate("status"); + const entries = Object.entries(this._attachments); + // upload smallest attachments first + entries.sort(([, a1], [, a2]) => a1.size - a2.size); + for (const [urlPath, attachment] of entries) { + await attachment.upload(hsApi, () => { + this._emitUpdate("attachmentsSentBytes"); + }); + attachment.applyToContent(urlPath, this.content); + } + this._data.needsUpload = false; + } + + abort() { + if (!this._aborted) { + this._aborted = true; + if (this._attachments) { + for (const attachment of Object.values(this._attachments)) { + attachment.abort(); + } + } + this._sendRequest?.abort(); + this._removeFromQueueCallback(); + } + } + + get aborted() { + return this._aborted; + } + + async send(hsApi) { + console.log(`sending event ${this.eventType} in ${this.roomId}`); + this._status = SendStatus.Sending; + this._emitUpdate("status"); + const eventType = this._data.encryptedEventType || this._data.eventType; + const content = this._data.encryptedContent || this._data.content; + this._sendRequest = hsApi.send( + this.roomId, + eventType, + this.txnId, + content + ); + const response = await this._sendRequest.response(); + this._sendRequest = null; + this._data.remoteId = response.event_id; + this._status = SendStatus.Sent; + this._emitUpdate("status"); + } + + dispose() { + if (this._attachments) { + for (const attachment of Object.values(this._attachments)) { + attachment.dispose(); + } + } + } } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 76ff2c66..4a678cfe 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -29,13 +29,22 @@ export class SendQueue { if (pendingEvents.length) { console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents); } - this._pendingEvents.setManyUnsorted(pendingEvents.map(data => new PendingEvent(data))); + this._pendingEvents.setManyUnsorted(pendingEvents.map(data => this._createPendingEvent(data))); this._isSending = false; this._offline = false; - this._amountSent = 0; this._roomEncryption = null; } + _createPendingEvent(data, attachments = null) { + const pendingEvent = new PendingEvent({ + data, + remove: () => this._removeEvent(pendingEvent), + emitUpdate: () => this._pendingEvents.set(pendingEvent), + attachments + }); + return pendingEvent; + } + enableEncryption(roomEncryption) { this._roomEncryption = roomEncryption; } @@ -43,54 +52,44 @@ export class SendQueue { async _sendLoop() { this._isSending = true; try { - console.log("start sending", this._amountSent, "<", this._pendingEvents.length); - while (this._amountSent < this._pendingEvents.length) { - const pendingEvent = this._pendingEvents.get(this._amountSent); - console.log("trying to send", pendingEvent.content.body); - if (pendingEvent.remoteId) { - this._amountSent += 1; - continue; - } - if (pendingEvent.attachment) { - const {attachment} = pendingEvent; - try { - await attachment.uploaded(); - } catch (err) { - console.log("upload failed, skip sending message", pendingEvent); - this._amountSent += 1; - continue; + for (let i = 0; i < this._pendingEvents.length; i += 1) { + const pendingEvent = this._pendingEvents.get(i); + try { + await this._sendEvent(pendingEvent); + } catch(err) { + if (err instanceof ConnectionError) { + this._offline = true; + break; + } else { + pendingEvent.setError(err); } - attachment.applyToContent(pendingEvent.content); - } - if (pendingEvent.needsEncryption) { - const {type, content} = await this._roomEncryption.encrypt( - pendingEvent.eventType, pendingEvent.content, this._hsApi); - pendingEvent.setEncrypted(type, content); - await this._tryUpdateEvent(pendingEvent); - } - console.log("really sending now"); - const response = await this._hsApi.send( - pendingEvent.roomId, - pendingEvent.eventType, - pendingEvent.txnId, - pendingEvent.content - ).response(); - pendingEvent.remoteId = response.event_id; - // - console.log("writing remoteId now"); - await this._tryUpdateEvent(pendingEvent); - console.log("keep sending?", this._amountSent, "<", this._pendingEvents.length); - this._amountSent += 1; - } - } catch(err) { - if (err instanceof ConnectionError) { - this._offline = true; + } } } finally { this._isSending = false; } } + async _sendEvent(pendingEvent) { + if (pendingEvent.needsUpload) { + await pendingEvent.uploadAttachments(this._hsApi); + console.log("attachments upload, content is now", pendingEvent.content); + await this._tryUpdateEvent(pendingEvent); + } + if (pendingEvent.needsEncryption) { + pendingEvent.setEncrypting(); + const {type, content} = await this._roomEncryption.encrypt( + pendingEvent.eventType, pendingEvent.content, this._hsApi); + pendingEvent.setEncrypted(type, content); + await this._tryUpdateEvent(pendingEvent); + } + if (pendingEvent.needsSending) { + await pendingEvent.send(this._hsApi); + console.log("writing remoteId"); + await this._tryUpdateEvent(pendingEvent); + } + } + removeRemoteEchos(events, txn) { const removed = []; for (const event of events) { @@ -110,13 +109,28 @@ export class SendQueue { return removed; } + async _removeEvent(pendingEvent) { + const idx = this._pendingEvents.array.indexOf(pendingEvent); + if (idx !== -1) { + const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); + try { + txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex); + } catch (err) { + txn.abort(); + } + await txn.complete(); + this._pendingEvents.remove(idx); + } + pendingEvent.dispose(); + } + emitRemovals(pendingEvents) { for (const pendingEvent of pendingEvents) { const idx = this._pendingEvents.array.indexOf(pendingEvent); if (idx !== -1) { - this._amountSent -= 1; this._pendingEvents.remove(idx); } + pendingEvent.dispose(); } } @@ -127,8 +141,8 @@ export class SendQueue { } } - async enqueueEvent(eventType, content, attachment) { - const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment); + async enqueueEvent(eventType, content, attachments) { + const pendingEvent = await this._createAndStoreEvent(eventType, content, attachments); this._pendingEvents.set(pendingEvent); console.log("added to _pendingEvents set", this._pendingEvents.length); if (!this._isSending && !this._offline) { @@ -161,7 +175,7 @@ export class SendQueue { await txn.complete(); } - async _createAndStoreEvent(eventType, content, attachment) { + async _createAndStoreEvent(eventType, content, attachments) { console.log("_createAndStoreEvent"); const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); let pendingEvent; @@ -171,14 +185,15 @@ export class SendQueue { const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0; console.log("_createAndStoreEvent got maxQueueIndex", maxQueueIndex); const queueIndex = maxQueueIndex + 1; - pendingEvent = new PendingEvent({ + pendingEvent = this._createPendingEvent({ roomId: this._roomId, queueIndex, eventType, content, txnId: makeTxnId(), - needsEncryption: !!this._roomEncryption - }, attachment); + needsEncryption: !!this._roomEncryption, + needsUpload: !!attachments + }, attachments); console.log("_createAndStoreEvent: adding to pendingEventsStore"); pendingEventsStore.add(pendingEvent.data); } catch (err) { @@ -188,4 +203,10 @@ export class SendQueue { await txn.complete(); return pendingEvent; } + + dispose() { + for (const pe in this._pendingEvents.array) { + pe.dispose(); + } + } } diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 9f91c80f..eff14cb5 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -64,8 +64,8 @@ export class PendingEventEntry extends BaseEntry { return this._pendingEvent.txnId; } - get attachment() { - return this._pendingEvent.attachment; + get pendingEvent() { + return this._pendingEvent; } notifyUpdate() { diff --git a/src/platform/web/LegacyPlatform.js b/src/platform/web/LegacyPlatform.js index 4ecea1d3..e6bf7774 100644 --- a/src/platform/web/LegacyPlatform.js +++ b/src/platform/web/LegacyPlatform.js @@ -1,3 +1,19 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 aesjs from "../../../lib/aes-js/index.js"; import {hkdf} from "../../utils/crypto/hkdf.js"; import {Platform as ModernPlatform} from "./Platform.js"; diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 6a981c64..1b2226f2 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -18,6 +18,7 @@ import {createFetchRequest} from "./dom/request/fetch.js"; import {xhrRequest} from "./dom/request/xhr.js"; import {StorageFactory} from "../../matrix/storage/idb/StorageFactory.js"; import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionInfoStorage.js"; +import {SettingsStorage} from "./dom/SettingsStorage.js"; import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js"; import {RootView} from "./ui/RootView.js"; import {Clock} from "./dom/Clock.js"; @@ -28,6 +29,7 @@ import {Crypto} from "./dom/Crypto.js"; import {estimateStorageUsage} from "./dom/StorageEstimate.js"; import {WorkerPool} from "./dom/WorkerPool.js"; import {BlobHandle} from "./dom/BlobHandle.js"; +import {hasReadPixelPermission, ImageHandle} from "./dom/ImageHandle.js"; import {downloadInIframe} from "./dom/download.js"; function addScript(src) { @@ -77,7 +79,6 @@ async function loadOlmWorker(paths) { return olmWorker; } - export class Platform { constructor(container, paths, cryptoExtras = null) { this._paths = paths; @@ -93,6 +94,7 @@ export class Platform { this.crypto = new Crypto(cryptoExtras); this.storageFactory = new StorageFactory(this._serviceWorkerHandler); this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); + this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_"); this.estimateStorageUsage = estimateStorageUsage; this.random = Math.random; if (typeof fetch === "function") { @@ -156,9 +158,9 @@ export class Platform { const file = input.files[0]; this._container.removeChild(input); if (file) { - resolve({name: file.name, blob: BlobHandle.fromFile(file)}); + resolve({name: file.name, blob: BlobHandle.fromBlob(file)}); } else { - reject(new Error("No file selected")); + resolve(); } } input.addEventListener("change", checkFile, true); @@ -168,4 +170,16 @@ export class Platform { input.click(); return promise; } + + async loadImage(blob) { + return ImageHandle.fromBlob(blob); + } + + hasReadPixelPermission() { + return hasReadPixelPermission(); + } + + get devicePixelRatio() { + return window.devicePixelRatio || 1; + } } diff --git a/src/platform/web/dom/BlobHandle.js b/src/platform/web/dom/BlobHandle.js index 00098de1..fb1b150a 100644 --- a/src/platform/web/dom/BlobHandle.js +++ b/src/platform/web/dom/BlobHandle.js @@ -84,9 +84,9 @@ export class BlobHandle { return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer); } - static fromFile(file) { + static fromBlob(blob) { // ok to not filter mimetypes as these are local files - return new BlobHandle(file); + return new BlobHandle(blob); } get nativeBlob() { diff --git a/src/platform/web/dom/ImageHandle.js b/src/platform/web/dom/ImageHandle.js new file mode 100644 index 00000000..c8b08e04 --- /dev/null +++ b/src/platform/web/dom/ImageHandle.js @@ -0,0 +1,106 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 {BlobHandle} from "./BlobHandle.js"; + +export class ImageHandle { + static async fromBlob(blob) { + const img = await loadImgFromBlob(blob); + const {width, height} = img; + return new ImageHandle(blob, width, height, img); + } + + constructor(blob, width, height, imgElement) { + this.blob = blob; + this.width = width; + this.height = height; + this._imgElement = imgElement; + } + + get maxDimension() { + return Math.max(this.width, this.height); + } + + async _getImgElement() { + if (!this._imgElement) { + this._imgElement = await loadImgFromBlob(this.blob); + } + return this._imgElement; + } + + async scale(maxDimension) { + const aspectRatio = this.width / this.height; + const scaleFactor = Math.min(1, maxDimension / (aspectRatio >= 1 ? this.width : this.height)); + const scaledWidth = this.width * scaleFactor; + const scaledHeight = this.height * scaleFactor; + + const canvas = document.createElement("canvas"); + canvas.width = scaledWidth; + canvas.height = scaledHeight; + const ctx = canvas.getContext("2d"); + const img = await this._getImgElement(); + ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight); + let mimeType = this.blob.mimeType === "image/jpeg" ? "image/jpeg" : "image/png"; + let nativeBlob; + if (canvas.toBlob) { + nativeBlob = await new Promise(resolve => canvas.toBlob(resolve, mimeType)); + } else if (canvas.msToBlob) { + mimeType = "image/png"; + nativeBlob = canvas.msToBlob(); + } else { + throw new Error("canvas can't be turned into blob"); + } + const blob = BlobHandle.fromBlob(nativeBlob); + return new ImageHandle(blob, scaledWidth, scaledHeight, null); + } + + dispose() { + this.blob.dispose(); + } +} + +export function hasReadPixelPermission() { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + const rgb = [ + Math.round(Math.random() * 255), + Math.round(Math.random() * 255), + Math.round(Math.random() * 255), + ] + ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + ctx.fillRect(0, 0, 1, 1); + const data = ctx.getImageData(0, 0, 1, 1).data; + return data[0] === rgb[0] && data[1] === rgb[1] && data[2] === rgb[2]; +} + +async function loadImgFromBlob(blob) { + const img = document.createElement("img"); + let detach; + const loadPromise = new Promise((resolve, reject) => { + detach = () => { + img.removeEventListener("load", resolve); + img.removeEventListener("error", reject); + }; + img.addEventListener("load", resolve); + img.addEventListener("error", reject); + }); + img.src = blob.url; + await loadPromise; + detach(); + return img; +} diff --git a/src/platform/web/dom/SettingsStorage.js b/src/platform/web/dom/SettingsStorage.js new file mode 100644 index 00000000..0b3e81a8 --- /dev/null +++ b/src/platform/web/dom/SettingsStorage.js @@ -0,0 +1,37 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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. +*/ + +export class SettingsStorage { + constructor(prefix) { + this._prefix = prefix; + } + + async setInt(key, value) { + window.localStorage.setItem(`${this._prefix}${key}`, value); + } + + async getInt(key) { + const value = window.localStorage.getItem(`${this._prefix}${key}`); + if (typeof value === "string") { + return parseInt(value, 10); + } + return; + } + + async remove(key) { + window.localStorage.removeItem(`${this._prefix}${key}`); + } +} diff --git a/src/platform/web/dom/download.js b/src/platform/web/dom/download.js index 4e8aaece..ec934a65 100644 --- a/src/platform/web/dom/download.js +++ b/src/platform/web/dom/download.js @@ -20,7 +20,7 @@ export async function downloadInIframe(container, iframeSrc, blob, filename) { iframe = document.createElement("iframe"); iframe.setAttribute("sandbox", "allow-scripts allow-downloads allow-downloads-without-user-activation"); iframe.setAttribute("src", iframeSrc); - iframe.className = "downloadSandbox"; + iframe.className = "hidden"; container.appendChild(iframe); let detach; await new Promise((resolve, reject) => { diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 96c9ff9c..dd3b7949 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -21,6 +21,7 @@ import { } from "../../../../matrix/error.js"; import {abortOnTimeout} from "./timeout.js"; import {addCacheBuster} from "./common.js"; +import {xhrRequest} from "./xhr.js"; class RequestResult { constructor(promise, controller) { @@ -51,7 +52,12 @@ class RequestResult { } export function createFetchRequest(createTimeout) { - return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) { + return function fetchRequest(url, requestOptions) { + // fetch doesn't do upload progress yet, delegate to xhr + if (requestOptions?.uploadProgress) { + return xhrRequest(url, requestOptions); + } + let {method, headers, body, timeout, format, cache = false} = requestOptions; const controller = typeof AbortController === "function" ? new AbortController() : null; // if a BlobHandle, take native blob if (body?.nativeBlob) { diff --git a/src/platform/web/dom/request/xhr.js b/src/platform/web/dom/request/xhr.js index 5ca2d460..98893387 100644 --- a/src/platform/web/dom/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -35,7 +35,7 @@ class RequestResult { } } -function send(url, {method, headers, timeout, body, format}) { +function createXhr(url, {method, headers, timeout, format, uploadProgress}) { const xhr = new XMLHttpRequest(); xhr.open(method, url); @@ -45,18 +45,20 @@ function send(url, {method, headers, timeout, body, format}) { } if (headers) { for(const [name, value] of headers.entries()) { - xhr.setRequestHeader(name, value); + try { + xhr.setRequestHeader(name, value); + } catch (err) { + console.info(`Could not set ${name} header: ${err.message}`); + } } } if (timeout) { xhr.timeout = timeout; } - // if a BlobHandle, take native blob - if (body?.nativeBlob) { - body = body.nativeBlob; + if (uploadProgress) { + xhr.upload.addEventListener("progress", evt => uploadProgress(evt.loaded)); } - xhr.send(body || null); return xhr; } @@ -71,12 +73,12 @@ function xhrAsPromise(xhr, method, url) { } export function xhrRequest(url, options) { - const {cache, format} = options; + let {cache, format, body, method} = options; if (!cache) { url = addCacheBuster(url); } - const xhr = send(url, options); - const promise = xhrAsPromise(xhr, options.method, url).then(xhr => { + const xhr = createXhr(url, options); + const promise = xhrAsPromise(xhr, method, url).then(xhr => { const {status} = xhr; let body = null; if (format === "buffer") { @@ -86,5 +88,12 @@ export function xhrRequest(url, options) { } return {status, body}; }); + + // if a BlobHandle, take native blob + if (body?.nativeBlob) { + body = body.nativeBlob; + } + xhr.send(body || null); + return new RequestResult(promise, xhr); } diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 15d5b9c8..60c3eafa 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -96,6 +96,8 @@ main { width: 100%; /* otherwise we don't get scrollbars and the content grows as large as it can */ min-height: 0; + /* make popups relative to this element so changing the left panel width doesn't affect their position */ + position: relative; } .RoomView { @@ -109,12 +111,11 @@ main { } .lightbox { - /* cover left and middle panel, not status view - use numeric positions because named grid areas - are not present in mobile layout */ - grid-area: 2 / 1 / 3 / 3; - /* this should not be necessary, but chrome seems to have a bug when there are scrollbars in other grid items, - it seems to put the scroll areas on top of the other grid items unless they have a z-index */ + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; z-index: 1; } @@ -164,6 +165,11 @@ main { pointer-events: none; } +.menu { + position: absolute; + z-index: 2; +} + .Settings { display: flex; flex-direction: column; diff --git a/src/platform/web/ui/css/main.css b/src/platform/web/ui/css/main.css index 913141b6..aa22839e 100644 --- a/src/platform/web/ui/css/main.css +++ b/src/platform/web/ui/css/main.css @@ -49,7 +49,3 @@ body.hydrogen { input::-ms-clear { display: none; } - -.hydrogen > iframe.downloadSandbox { - display: none; -} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 57bfc03b..a4d7c4e6 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -316,6 +316,7 @@ a { .SessionStatusView button.link { color: currentcolor; + text-align: left; } .SessionStatusView > .end { @@ -556,11 +557,17 @@ ul.Timeline > li.messageStatus .message-container > p { .message-container .picture { display: grid; - text-decoration: none; margin-top: 4px; width: 100%; } + +.message-container .picture > a { + text-decoration: none; + width: 100%; + display: block; +} + /* .spacer grows with an inline padding-top to the size of the image, so the timeline doesn't jump when the image loads */ .message-container .picture > * { @@ -568,24 +575,41 @@ so the timeline doesn't jump when the image loads */ grid-column: 1; } -.message-container .picture > img { +.message-container .picture img { width: 100%; height: auto; /* for IE11 to still scale even though the spacer is too tall */ align-self: start; border-radius: 4px; + display: block; } /* stretch the image (to the spacer) on platforms where we can trust the spacer to always have the correct height, otherwise the image starts with height 0 and with loading=lazy only loads when the top comes into view*/ -.hydrogen:not(.legacy) .message-container .picture > img { +.hydrogen:not(.legacy) .message-container .picture img { align-self: stretch; } +.message-container .picture > .sendStatus { + align-self: end; + justify-self: start; + font-size: 0.8em; +} + +.message-container .picture > progress { + align-self: center; + justify-self: center; + width: 75%; +} + .message-container .picture > time { align-self: end; justify-self: end; +} + +.message-container .picture > time, +.message-container .picture > .sendStatus { color: #2e2f32; display: block; padding: 2px; @@ -653,6 +677,7 @@ only loads when the top comes into view*/ .Settings .row .content { margin-left: 4px; + flex: 1; } .Settings .row.code .content { @@ -664,6 +689,12 @@ only loads when the top comes into view*/ margin: 0 8px; } +.Settings .row .content input[type=range] { + width: 100%; + max-width: 300px; + min-width: 160px; +} + .Settings .row { margin: 4px 0px; display: flex; @@ -762,4 +793,31 @@ button.link { width: 200px; } +.menu { + border-radius: 8px; + box-shadow: 2px 2px 10px rgba(0,0,0,0.5); + padding: 4px; + background-color: white; + list-style: none; + margin: 0; +} +.menu button { + border-radius: 4px; + display: block; + border: none; + width: 100%; + background-color: transparent; + text-align: left; + padding: 8px 32px 8px 8px; +} + +.menu button:focus { + background-color: #03B381; + color: white; +} + +.menu button:hover { + background-color: #03B381; + color: white; +} diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js new file mode 100644 index 00000000..10c5f07e --- /dev/null +++ b/src/platform/web/ui/general/Menu.js @@ -0,0 +1,49 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 {TemplateView} from "./TemplateView.js"; + +export class Menu extends TemplateView { + static option(label, callback) { + return new MenuOption(label, callback); + } + + constructor(options) { + super(); + this._options = options; + } + + render(t) { + return t.ul({className: "menu", role: "menu"}, this._options.map(o => { + return t.li({ + className: o.icon ? `icon ${o.icon}` : "", + }, t.button({onClick: o.callback}, o.label)); + })); + } +} + +class MenuOption { + constructor(label, callback) { + this.label = label; + this.callback = callback; + this.icon = null; + } + + setIcon(className) { + this.icon = className; + return this; + } +} diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js new file mode 100644 index 00000000..51b53e6f --- /dev/null +++ b/src/platform/web/ui/general/Popup.js @@ -0,0 +1,181 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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. +*/ + +const HorizontalAxis = { + scrollOffset(el) {return el.scrollLeft;}, + size(el) {return el.offsetWidth;}, + offsetStart(el) {return el.offsetLeft;}, + setStart(el, value) {el.style.left = `${value}px`;}, + setEnd(el, value) {el.style.right = `${value}px`;}, +}; +const VerticalAxis = { + scrollOffset(el) {return el.scrollTop;}, + size(el) {return el.offsetHeight;}, + offsetStart(el) {return el.offsetTop;}, + setStart(el, value) {el.style.top = `${value}px`;}, + setEnd(el, value) {el.style.bottom = `${value}px`;}, +}; + +export class Popup { + constructor(view) { + this._view = view; + this._target = null; + this._arrangement = null; + this._scroller = null; + this._fakeRoot = null; + this._trackingTemplateView = null; + } + + trackInTemplateView(templateView) { + this._trackingTemplateView = templateView; + this._trackingTemplateView.addSubView(this); + } + + showRelativeTo(target, arrangement) { + this._target = target; + this._arrangement = arrangement; + this._scroller = findScrollParent(this._target); + this._view.mount(); + this._target.offsetParent.appendChild(this._popup); + this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal); + this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical); + if (this._scroller) { + document.body.addEventListener("scroll", this, true); + } + setTimeout(() => { + document.body.addEventListener("click", this, false); + }, 10); + } + + get isOpen() { + return !!this._view; + } + + close() { + if (this._view) { + this._view.unmount(); + this._trackingTemplateView.removeSubView(this); + if (this._scroller) { + document.body.removeEventListener("scroll", this, true); + } + document.body.removeEventListener("click", this, false); + this._popup.remove(); + this._view = null; + } + } + + get _popup() { + return this._view.root(); + } + + handleEvent(evt) { + if (evt.type === "scroll") { + this._onScroll(); + } else if (evt.type === "click") { + this._onClick(evt); + } + } + + _onScroll() { + if (this._scroller && !this._isVisibleInScrollParent(VerticalAxis)) { + this.close(); + } + this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal); + this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical); + } + + _onClick() { + this.close(); + } + + _applyArrangementAxis(axis, {relativeTo, align, before, after}) { + if (relativeTo === "end") { + let end = axis.size(this._target.offsetParent) - axis.offsetStart(this._target); + if (align === "end") { + end -= axis.size(this._popup); + } else if (align === "center") { + end -= ((axis.size(this._popup) / 2) - (axis.size(this._target) / 2)); + } + if (typeof before === "number") { + end += before; + } else if (typeof after === "number") { + end -= (axis.size(this._target) + after); + } + axis.setEnd(this._popup, end); + } else if (relativeTo === "start") { + let scrollOffset = this._scroller ? axis.scrollOffset(this._scroller) : 0; + let start = axis.offsetStart(this._target) - scrollOffset; + if (align === "start") { + start -= axis.size(this._popup); + } else if (align === "center") { + start -= ((axis.size(this._popup) / 2) - (axis.size(this._target) / 2)); + } + if (typeof before === "number") { + start -= before; + } else if (typeof after === "number") { + start += (axis.size(this._target) + after); + } + axis.setStart(this._popup, start); + } else { + throw new Error("unknown relativeTo: " + relativeTo); + } + } + + _isVisibleInScrollParent(axis) { + // clipped at start? + if ((axis.offsetStart(this._target) + axis.size(this._target)) < ( + axis.offsetStart(this._scroller) + + axis.scrollOffset(this._scroller) + )) { + return false; + } + // clipped at end? + if (axis.offsetStart(this._target) > ( + axis.offsetStart(this._scroller) + + axis.size(this._scroller) + + axis.scrollOffset(this._scroller) + )) { + return false; + } + return true; + } + + /* fake UIView api, so it can be tracked by a template view as a subview */ + root() { + return this._fakeRoot; + } + + mount() { + this._fakeRoot = document.createComment("popup"); + return this._fakeRoot; + } + + unmount() { + this.close(); + } + + update() {} +} + +function findScrollParent(el) { + let parent = el; + do { + parent = parent.parentElement; + if (parent.scrollHeight > parent.clientHeight) { + return parent; + } + } while (parent !== el.offsetParent); +} diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 8158fcb3..14cb53ac 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -44,9 +44,6 @@ export class TemplateView { this._render = render; this._eventListeners = null; this._bindings = null; - // this should become _subViews and also include templates. - // How do we know which ones we should update though? - // Wrapper class? this._subViews = null; this._root = null; this._boundUpdateFromValue = null; @@ -57,7 +54,7 @@ export class TemplateView { } _subscribe() { - if (typeof this._value.on === "function") { + if (typeof this._value?.on === "function") { this._boundUpdateFromValue = this._updateFromValue.bind(this); this._value.on("change", this._boundUpdateFromValue); } @@ -146,12 +143,19 @@ export class TemplateView { this._bindings.push(bindingFn); } - _addSubView(view) { + addSubView(view) { if (!this._subViews) { this._subViews = []; } this._subViews.push(view); } + + removeSubView(view) { + const idx = this._subViews.indexOf(view); + if (idx !== -1) { + this._subViews.splice(idx, 1); + } + } } // what is passed to render @@ -288,7 +292,7 @@ class TemplateBuilder { } catch (err) { return errorToDOM(err); } - this._templateView._addSubView(view); + this._templateView.addSubView(view); return root; } diff --git a/src/platform/web/ui/general/html.js b/src/platform/web/ui/general/html.js index 5ab33e86..a965a6ee 100644 --- a/src/platform/web/ui/general/html.js +++ b/src/platform/web/ui/general/html.js @@ -94,7 +94,7 @@ export const TAG_NAMES = { [HTML_NS]: [ "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "em", "span", "img", "section", "main", "article", "aside", - "pre", "button", "time", "input", "textarea", "label", "form"], + "pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"], [SVG_NS]: ["svg", "circle"] }; diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 62b55b15..32f3fc05 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -15,11 +15,14 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; +import {Popup} from "../../general/Popup.js"; +import {Menu} from "../../general/Menu.js"; export class MessageComposer extends TemplateView { constructor(viewModel) { super(viewModel); this._input = null; + this._attachmentPopup = null; } render(t, vm) { @@ -32,8 +35,8 @@ export class MessageComposer extends TemplateView { this._input, t.button({ className: "sendFile", - title: vm.i18n`Send file`, - onClick: () => vm.sendAttachment(), + title: vm.i18n`Pick attachment`, + onClick: evt => this._toggleAttachmentMenu(evt), }, vm.i18n`Send file`), t.button({ className: "send", @@ -56,4 +59,29 @@ export class MessageComposer extends TemplateView { this._trySend(); } } + + _toggleAttachmentMenu(evt) { + if (this._attachmentPopup && this._attachmentPopup.isOpen) { + this._attachmentPopup.close(); + } else { + const vm = this.value; + this._attachmentPopup = new Popup(new Menu([ + Menu.option(vm.i18n`Send picture`, () => vm.sendPicture()).setIcon("picture"), + Menu.option(vm.i18n`Send file`, () => vm.sendFile()).setIcon("file"), + ])); + this._attachmentPopup.trackInTemplateView(this); + this._attachmentPopup.showRelativeTo(evt.target, { + horizontal: { + relativeTo: "end", + align: "start", + after: 0 + }, + vertical: { + relativeTo: "end", + align: "start", + before: 8, + } + }); + } + } } diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 5d0f3fbe..9d946fce 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -19,6 +19,7 @@ import {GapView} from "./timeline/GapView.js"; import {TextMessageView} from "./timeline/TextMessageView.js"; import {ImageView} from "./timeline/ImageView.js"; import {FileView} from "./timeline/FileView.js"; +import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js"; function viewClassForEntry(entry) { @@ -30,6 +31,7 @@ function viewClassForEntry(entry) { return TextMessageView; case "image": return ImageView; case "file": return FileView; + case "missing-attachment": return MissingAttachmentView; } } diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index cb99dd0a..62760b3e 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -19,11 +19,17 @@ import {renderMessage} from "./common.js"; export class FileView extends TemplateView { render(t, vm) { - return renderMessage(t, vm, [ - t.p([ + if (vm.isPending) { + return renderMessage(t, vm, t.p([ + vm => vm.label, + " ", + t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`), + ])); + } else { + return renderMessage(t, vm, t.p([ t.button({className: "link", onClick: () => vm.download()}, vm => vm.label), - t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time) - ]) - ]); + t.time(vm.date + " " + vm.time) + ])); + } } } diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index eb060e34..855ab23f 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -31,18 +31,36 @@ export class ImageView extends TemplateView { // can slow down rendering, and was bleeding through the lightbox. spacerStyle = `height: ${vm.thumbnailHeight}px`; } + const img = t.img({ + loading: "lazy", + src: vm => vm.thumbnailUrl, + alt: vm => vm.label, + title: vm => vm.label, + style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` + }); + const children = [ + t.div({className: "spacer", style: spacerStyle}), + vm.isPending ? img : t.a({href: vm.lightboxUrl}, img), + t.time(vm.date + " " + vm.time), + ]; + if (vm.isPending) { + const cancel = t.button({onClick: () => vm.abortSending(), className: "link"}, vm.i18n`Cancel`); + const sendStatus = t.div({ + className: { + sendStatus: true, + hidden: vm => !vm.sendStatus + }, + }, [vm => vm.sendStatus, " ", cancel]); + const progress = t.progress({ + min: 0, + max: 100, + value: vm => vm.uploadPercentage, + className: {hidden: vm => !vm.isUploading} + }); + children.push(sendStatus, progress); + } return renderMessage(t, vm, [ - t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [ - t.div({className: "spacer", style: spacerStyle}), - t.img({ - loading: "lazy", - src: vm => vm.thumbnailUrl, - alt: vm => vm.label, - title: vm => vm.label, - style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` - }), - t.time(vm.date + " " + vm.time), - ]), + t.div({className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, children), t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) ]); } diff --git a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js new file mode 100644 index 00000000..8df90131 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -0,0 +1,25 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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 {TemplateView} from "../../../general/TemplateView.js"; +import {renderMessage} from "./common.js"; + +export class MissingAttachmentView extends TemplateView { + render(t, vm) { + const remove = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Remove`); + return renderMessage(t, vm, t.p([vm.label, " ", remove])); + } +} diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index 50b6a0cd..d49e845b 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -24,7 +24,7 @@ export function renderMessage(t, vm, children) { pending: vm.isPending, unverified: vm.isUnverified, continuation: vm => vm.isContinuation, - messageStatus: vm => vm.shape === "message-status", + messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment" || vm.shape === "file", }; const profile = t.div({className: "profile"}, [ diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index e350d806..0b8a4837 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -46,10 +46,32 @@ export class SettingsView extends TemplateView { row(vm.i18n`Session key`, vm.fingerprintKey, "code"), t.h3("Session Backup"), t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), + t.h3("Preferences"), + row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), t.h3("Application"), row(vm.i18n`Version`, version), row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), ]) ]); } + + _imageCompressionRange(t, vm) { + const step = 32; + const min = Math.ceil(vm.minSentImageSizeLimit / step) * step; + const max = (Math.floor(vm.maxSentImageSizeLimit / step) + 1) * step; + const updateSetting = evt => vm.setSentImageSizeLimit(parseInt(evt.target.value, 10)); + return [t.input({ + type: "range", + step, + min, + max, + value: vm => vm.sentImageSizeLimit || max, + onInput: updateSetting, + onChange: updateSetting, + }), " ", t.output(vm => { + return vm.sentImageSizeLimit ? + vm.i18n`resize to ${vm.sentImageSizeLimit}px` : + vm.i18n`no resizing`; + })]; + } }