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
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+
+
+
+
+
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+
+
+
+
+
+
+
+
+
+
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`;
+ })];
+ }
}