Merge pull request #199 from vector-im/bwindels/upload-images

Upload images
This commit is contained in:
Bruno Windels 2020-11-20 15:33:54 +00:00 committed by GitHub
commit 4cf66b8e61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1498 additions and 236 deletions

View File

@ -0,0 +1,378 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
.container {
display: grid;
grid-template: "left middle" 1fr /
200px 1fr;
height: 100vh;
}
.container .left {
display: grid;
grid-template:
"welcome" auto
"rooms" 1fr /
1fr;
min-height: 0;
}
.container .middle {
display: grid;
grid-template:
"header" auto
"timeline" 1fr
"composer" auto /
1fr;
min-height: 0;
position: relative;
}
.left { grid-area: left;}
.left p {
grid-area welcome;
display: flex;
}
.left ul {
grid-area: rooms;
min-height: 0;
overflow-y: auto;
}
.middle { grid-area: middle;}
.middle .header { grid-area: header;}
.middle .timeline {
grid-area: timeline;
min-height: 0;
overflow-y: auto;
}
.middle .composer {
grid-area: composer;
}
.header {
display: flex;
}
.header h2 {
flex: 1;
}
.composer {
display: flex;
}
.composer input {
display: block;
flex: 1;
}
.menu {
position: absolute;
border-radius: 8px;
box-shadow: 2px 2px 10px rgba(0,0,0,0.5);
padding: 16px;
background-color: white;
z-index: 1;
list-style: none;
margin: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="left">
<p>Welcome!<button></button></p>
<ul>
<li>Room xyz <button></button></li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz <button></button></li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz <button></button></li>
</ul>
</div>
<div class="middle">
<div class="header">
<h2>Room xyz</h2>
<button></button>
</div>
<ul class="timeline">
<li>Message abc</li>
<li>Message abc <button></button></li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc <button></button></li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc <button></button></li>
</ul>
<div class="composer">
<input type="text" name="">
<button></button>
</div>
</div>
</div>
<script type="text/javascript">
let menu;
function createMenu(options) {
const menu = document.createElement("ul");
menu.className = "menu";
for (const o of options) {
const li = document.createElement("li");
li.innerText = o;
menu.appendChild(li);
}
return menu;
}
function showMenu(evt) {
if (menu) {
menu = menu.close();
} else if (evt.target.tagName.toLowerCase() === "button") {
menu = showPopup(evt.target, createMenu(["Send file", "Save contact", "Send picture", "Foo the bar"]), {
horizontal: {
relativeTo: "end",
align: "start",
after: 0,
},
vertical: {
relativeTo: "end",
align: "end",
after: 10,
}
});
}
}
function showMenuInScroller(evt) {
if (!menu && evt.target.tagName.toLowerCase() === "button") {
evt.stopPropagation();
menu = showPopup(evt.target, createMenu(["Show reactions", "Share"]), {
horizontal: {
relativeTo: "start",
align: "end",
after: 10,
},
vertical: {
relativeTo: "start",
align: "center",
}
});
}
}
document.body.addEventListener("click", showMenu, false);
document.querySelector(".middle ul").addEventListener("click", showMenuInScroller, false);
document.querySelector(".left ul").addEventListener("click", showMenuInScroller, false);
function showPopup(target, popup, arrangement) {
targetAxes = elementToAxes(target);
if (!arrangement) {
arrangement = getAutoArrangement(targetAxes);
}
target.offsetParent.appendChild(popup);
const popupAxes = elementToAxes(popup);
const scrollerAxes = elementToAxes(findScrollParent(target));
const offsetParentAxes = elementToAxes(target.offsetParent);
function reposition() {
if (scrollerAxes && !isVisibleInScrollParent(targetAxes.vertical, scrollerAxes.vertical)) {
popupObj.close();
}
applyArrangement(
popupAxes.vertical,
targetAxes.vertical,
offsetParentAxes.vertical,
scrollerAxes?.vertical,
arrangement.vertical
);
applyArrangement(
popupAxes.horizontal,
targetAxes.horizontal,
offsetParentAxes.horizontal,
scrollerAxes?.horizontal,
arrangement.horizontal
);
}
reposition();
document.body.addEventListener("scroll", reposition, true);
const popupObj = {
close() {
document.body.removeEventListener("scroll", reposition, true);
popup.remove();
}
};
return popupObj;
}
function elementToAxes(element) {
if (element) {
return {
horizontal: new HorizontalAxis(element),
vertical: new VerticalAxis(element),
element
};
}
}
function findScrollParent(el) {
let parent = el;
do {
parent = parent.parentElement;
if (parent.scrollHeight > parent.clientHeight) {
return parent;
}
} while (parent !== el.offsetParent);
}
function isVisibleInScrollParent(targetAxis, scrollerAxis) {
// clipped at start?
if ((targetAxis.offsetStart + targetAxis.clientSize) < (
scrollerAxis.offsetStart +
scrollerAxis.scrollOffset
)) {
return false;
}
// clipped at end?
if (targetAxis.offsetStart > (
scrollerAxis.offsetStart +
scrollerAxis.clientSize +
scrollerAxis.scrollOffset
)) {
return false;
}
return true;
}
function applyArrangement(elAxis, targetAxis, offsetParentAxis, scrollerAxis, {relativeTo, align, before, after}) {
if (relativeTo === "end") {
let end = offsetParentAxis.clientSize - targetAxis.offsetStart;
if (align === "end") {
end -= elAxis.offsetSize;
} else if (align === "center") {
end -= ((elAxis.offsetSize / 2) - (targetAxis.offsetSize / 2));
}
if (typeof before === "number") {
end += before;
} else if (typeof after === "number") {
end -= (targetAxis.offsetSize + after);
}
elAxis.end = end;
} else if (relativeTo === "start") {
let scrollOffset = scrollerAxis?.scrollOffset || 0;
let start = targetAxis.offsetStart - scrollOffset;
if (align === "start") {
start -= elAxis.offsetSize;
} else if (align === "center") {
start -= ((elAxis.offsetSize / 2) - (targetAxis.offsetSize / 2));
}
if (typeof before === "number") {
start -= before;
} else if (typeof after === "number") {
start += (targetAxis.offsetSize + after);
}
elAxis.start = start;
} else {
throw new Error("unknown relativeTo: " + relativeTo);
}
}
class HorizontalAxis {
constructor(el) {
this.element = el;
}
get scrollOffset() {return this.element.scrollLeft;}
get clientSize() {return this.element.clientWidth;}
get offsetSize() {return this.element.offsetWidth;}
get offsetStart() {return this.element.offsetLeft;}
set start(value) {this.element.style.left = `${value}px`;}
set end(value) {this.element.style.right = `${value}px`;}
}
class VerticalAxis {
constructor(el) {
this.element = el;
}
get scrollOffset() {return this.element.scrollTop;}
get clientSize() {return this.element.clientHeight;}
get offsetSize() {return this.element.offsetHeight;}
get offsetStart() {return this.element.offsetTop;}
set start(value) {this.element.style.top = `${value}px`;}
set end(value) {this.element.style.bottom = `${value}px`;}
}
</script>
</body>
</html>

View File

@ -133,7 +133,8 @@ export class RoomTileViewModel extends ViewModel {
get avatarUrl() { get avatarUrl() {
if (this._room.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; return null;
} }

View File

@ -134,7 +134,8 @@ export class RoomViewModel extends ViewModel {
get avatarUrl() { get avatarUrl() {
if (this._room.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; return null;
} }
@ -164,21 +165,67 @@ export class RoomViewModel extends ViewModel {
return false; return false;
} }
async _sendFile() { async _pickAndSendFile() {
let file;
try { try {
file = await this.platform.openFile(); const file = await this.platform.openFile();
} catch (err) { if (!file) {
return; return;
} }
const attachment = this._room.uploadAttachment(file.blob, file.name); return this._sendFile(file);
} catch (err) {
console.error(err);
}
}
async _sendFile(file) {
const content = { const content = {
body: file.name, 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() { get composerViewModel() {
return this._composerVM; return this._composerVM;
} }
@ -204,8 +251,12 @@ class ComposerViewModel extends ViewModel {
return success; return success;
} }
sendAttachment() { sendPicture() {
this._roomVM._sendFile(); this._roomVM._pickAndSendPicture();
}
sendFile() {
this._roomVM._pickAndSendFile();
} }
get canSend() { 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
};
}

View File

@ -17,24 +17,17 @@ limitations under the License.
import {MessageTile} from "./MessageTile.js"; import {MessageTile} from "./MessageTile.js";
import {formatSize} from "../../../../../utils/formatSize.js"; import {formatSize} from "../../../../../utils/formatSize.js";
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
export class FileTile extends MessageTile { export class FileTile extends MessageTile {
constructor(options) { constructor(options) {
super(options); super(options);
this._error = null; this._downloadError = null;
this._downloading = false; 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() { async download() {
if (this._downloading || this._isUploading) { if (this._downloading || this.isPending) {
return; return;
} }
const content = this._getContent(); const content = this._getContent();
@ -46,7 +39,7 @@ export class FileTile extends MessageTile {
blob = await this._mediaRepository.downloadAttachment(content); blob = await this._mediaRepository.downloadAttachment(content);
this.platform.saveFileAs(blob, filename); this.platform.saveFileAs(blob, filename);
} catch (err) { } catch (err) {
this._error = err; this._downloadError = err;
} finally { } finally {
blob?.dispose(); blob?.dispose();
this._downloading = false; this._downloading = false;
@ -54,39 +47,40 @@ export class FileTile extends MessageTile {
this.emitChange("label"); 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() { get label() {
if (this._error) { if (this._downloadError) {
return `Could not decrypt file: ${this._error.message}`; return `Could not download file: ${this._downloadError.message}`;
}
if (this._entry.attachment?.error) {
return `Failed to upload: ${this._entry.attachment.error.message}`;
} }
const content = this._getContent(); const content = this._getContent();
const filename = content.body; const filename = content.body;
const size = formatSize(this.size);
if (this._isUploading) { if (this._entry.isPending) {
return this.i18n`Uploading ${filename} (${size})…`; const {pendingEvent} = this._entry;
} else if (this._downloading) { 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})…`; return this.i18n`Downloading ${filename} (${size})…`;
} else { } else {
return this.i18n`Download ${filename} (${size})`; return this.i18n`Download ${filename} (${size})`;
} }
} }
get error() {
return null;
} }
get shape() { get shape() {

View File

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import {MessageTile} from "./MessageTile.js"; import {MessageTile} from "./MessageTile.js";
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
const MAX_HEIGHT = 300; const MAX_HEIGHT = 300;
const MAX_WIDTH = 400; const MAX_WIDTH = 400;
@ -26,7 +26,9 @@ export class ImageTile extends MessageTile {
this._decryptedThumbail = null; this._decryptedThumbail = null;
this._decryptedImage = null; this._decryptedImage = null;
this._error = null; this._error = null;
this.load(); if (!this.isPending) {
this.tryLoadEncryptedThumbnail();
}
this._lightboxUrl = this.urlCreator.urlForSegments([ this._lightboxUrl = this.urlCreator.urlForSegments([
// ensure the right room is active if in grid view // ensure the right room is active if in grid view
this.navigation.segment("room", this._room.id), this.navigation.segment("room", this._room.id),
@ -43,7 +45,7 @@ export class ImageTile extends MessageTile {
return this.track(blob); return this.track(blob);
} }
async load() { async tryLoadEncryptedThumbnail() {
try { try {
const thumbnailFile = this._getContent().info?.thumbnail_file; const thumbnailFile = this._getContent().info?.thumbnail_file;
const file = this._getContent().file; const file = this._getContent().file;
@ -61,8 +63,39 @@ export class ImageTile extends MessageTile {
} }
get lightboxUrl() { get lightboxUrl() {
if (!this.isPending) {
return this._lightboxUrl; 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() { get thumbnailUrl() {
if (this._decryptedThumbail) { if (this._decryptedThumbail) {
@ -70,6 +103,10 @@ export class ImageTile extends MessageTile {
} else if (this._decryptedImage) { } else if (this._decryptedImage) {
return this._decryptedImage.url; 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; const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") { if (typeof mxcUrl === "string") {
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
@ -77,16 +114,6 @@ export class ImageTile extends MessageTile {
return ""; return "";
} }
async loadImageUrl() {
if (!this._decryptedImage) {
const file = this._getContent().file;
if (file) {
this._decryptedImage = await this._loadEncryptedFile(file);
}
}
return this._decryptedImage?.url || "";
}
_scaleFactor() { _scaleFactor() {
const info = this._getContent()?.info; const info = this._getContent()?.info;
const scaleHeightFactor = MAX_HEIGHT / info?.h; const scaleHeightFactor = MAX_HEIGHT / info?.h;

View File

@ -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.`;
}
}
}

View File

@ -46,6 +46,11 @@ export class SimpleTile extends ViewModel {
get isPending() { get isPending() {
return this._entry.isPending; return this._entry.isPending;
} }
abortSending() {
this._entry.pendingEvent?.abort();
}
// TilesCollection contract below // TilesCollection contract below
setUpdateEmit(emitUpdate) { setUpdateEmit(emitUpdate) {
this.updateOptions({emitChange: paramName => { this.updateOptions({emitChange: paramName => {

View File

@ -23,12 +23,15 @@ import {RoomNameTile} from "./tiles/RoomNameTile.js";
import {RoomMemberTile} from "./tiles/RoomMemberTile.js"; import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js"; import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js"; import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
export function tilesCreator(baseOptions) { export function tilesCreator(baseOptions) {
return function tilesCreator(entry, emitUpdate) { return function tilesCreator(entry, emitUpdate) {
const options = Object.assign({entry, emitUpdate}, baseOptions); const options = Object.assign({entry, emitUpdate}, baseOptions);
if (entry.isGap) { if (entry.isGap) {
return new GapTile(options); return new GapTile(options);
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
return new MissingAttachmentTile(options);
} else if (entry.eventType) { } else if (entry.eventType) {
switch (entry.eventType) { switch (entry.eventType) {
case "m.room.message": { case "m.room.message": {

View File

@ -36,10 +36,26 @@ export class SettingsViewModel extends ViewModel {
this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session}))); this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session})));
this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._closeUrl = this.urlCreator.urlUntilSegment("session");
this._estimate = null; 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() { async load() {
this._estimate = await this.platform.estimateStorageUsage(); this._estimate = await this.platform.estimateStorageUsage();
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
this.emitChange(""); this.emitChange("");
} }

View File

@ -65,7 +65,7 @@ export async function encryptAttachment(platform, blob) {
const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer}); const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer});
const digest = await crypto.digest("SHA-256", ciphertext); const digest = await crypto.digest("SHA-256", ciphertext);
return { return {
blob: platform.createBlob(ciphertext, blob.mimeType), blob: platform.createBlob(ciphertext, 'application/octet-stream'),
info: { info: {
v: "v2", v: "v2",
key, key,

View File

@ -110,6 +110,7 @@ export class HomeServerApi {
headers, headers,
body: encodedBody, body: encodedBody,
timeout: options?.timeout, timeout: options?.timeout,
uploadProgress: options?.uploadProgress,
format: "json" // response format format: "json" // response format
}); });

View File

@ -17,56 +17,31 @@ limitations under the License.
import {encryptAttachment} from "../e2ee/attachment.js"; import {encryptAttachment} from "../e2ee/attachment.js";
export class AttachmentUpload { export class AttachmentUpload {
constructor({filename, blob, hsApi, platform, isEncrypted}) { constructor({filename, blob, platform}) {
this._filename = filename; this._filename = filename;
// need to keep around for local preview while uploading
this._unencryptedBlob = blob; this._unencryptedBlob = blob;
this._isEncrypted = isEncrypted; this._transferredBlob = this._unencryptedBlob;
this._platform = platform; this._platform = platform;
this._hsApi = hsApi;
this._mxcUrl = null; this._mxcUrl = null;
this._transferredBlob = null;
this._encryptionInfo = null; this._encryptionInfo = null;
this._uploadPromise = null;
this._uploadRequest = null; this._uploadRequest = null;
this._aborted = false; this._aborted = false;
this._error = null; this._error = null;
this._sentBytes = 0;
} }
upload() { /** important to call after encrypt() if encryption is needed */
if (!this._uploadPromise) { get size() {
this._uploadPromise = this._upload(); return this._transferredBlob.size;
}
return this._uploadPromise;
} }
async _upload() { get sentBytes() {
try { return this._sentBytes;
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;
} }
/** @public */ /** @public */
abort() { abort() {
this._aborted = true;
this._uploadRequest?.abort(); this._uploadRequest?.abort();
} }
@ -75,34 +50,62 @@ export class AttachmentUpload {
return this._unencryptedBlob; return this._unencryptedBlob;
} }
get error() { /** @package */
return this._error; async encrypt() {
if (this._encryptionInfo) {
throw new Error("already encrypted");
}
const {info, blob} = await encryptAttachment(this._platform, this._transferredBlob);
this._transferredBlob = blob;
this._encryptionInfo = info;
} }
/** @package */ /** @package */
uploaded() { async upload(hsApi, progressCallback) {
if (!this._uploadPromise) { this._uploadRequest = hsApi.uploadAttachment(this._transferredBlob, this._filename, {
throw new Error("upload has not started yet"); uploadProgress: sentBytes => {
this._sentBytes = sentBytes;
progressCallback();
} }
return this._uploadPromise; });
const {content_uri} = await this._uploadRequest.response();
this._mxcUrl = content_uri;
} }
/** @package */ /** @package */
applyToContent(content) { applyToContent(urlPath, content) {
if (!this._mxcUrl) { if (!this._mxcUrl) {
throw new Error("upload has not finished"); throw new Error("upload has not finished");
} }
content.info = { let prefix = urlPath.substr(0, urlPath.lastIndexOf("url"));
size: this._transferredBlob.size, setPath(`${prefix}info.size`, content, this._transferredBlob.size);
mimetype: this._unencryptedBlob.mimeType, setPath(`${prefix}info.mimetype`, content, this._unencryptedBlob.mimeType);
}; if (this._encryptionInfo) {
if (this._isEncrypted) { setPath(`${prefix}file`, content, Object.assign(this._encryptionInfo, {
content.file = Object.assign(this._encryptionInfo, {
mimetype: this._unencryptedBlob.mimeType, mimetype: this._unencryptedBlob.mimeType,
url: this._mxcUrl url: this._mxcUrl
}); }));
} else { } 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;
} }

View File

@ -352,8 +352,8 @@ export class Room extends EventEmitter {
} }
/** @public */ /** @public */
sendEvent(eventType, content, attachment) { sendEvent(eventType, content, attachments) {
return this._sendQueue.enqueueEvent(eventType, content, attachment); return this._sendQueue.enqueueEvent(eventType, content, attachments);
} }
/** @public */ /** @public */
@ -633,16 +633,14 @@ export class Room extends EventEmitter {
} }
} }
uploadAttachment(blob, filename) { createAttachment(blob, filename) {
const attachment = new AttachmentUpload({blob, filename, return new AttachmentUpload({blob, filename, platform: this._platform});
hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted});
attachment.upload();
return attachment;
} }
dispose() { dispose() {
this._roomEncryption?.dispose(); this._roomEncryption?.dispose();
this._timeline?.dispose(); this._timeline?.dispose();
this._sendQueue.dispose();
} }
} }

View File

@ -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 See the License for the specific language governing permissions and
limitations under the License. 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 { export class PendingEvent {
constructor(data, attachment) { constructor({data, remove, emitUpdate, attachments}) {
this._data = data; 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; } get roomId() { return this._data.roomId; }
@ -25,14 +49,129 @@ export class PendingEvent {
get eventType() { return this._data.eventType; } get eventType() { return this._data.eventType; }
get txnId() { return this._data.txnId; } get txnId() { return this._data.txnId; }
get remoteId() { return this._data.remoteId; } get remoteId() { return this._data.remoteId; }
set remoteId(value) { this._data.remoteId = value; }
get content() { return this._data.content; } get content() { return this._data.content; }
get needsEncryption() { return this._data.needsEncryption; }
get data() { return this._data; } 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) { setEncrypted(type, content) {
this._data.eventType = type; this._data.encryptedEventType = type;
this._data.content = content; this._data.encryptedContent = content;
this._data.needsEncryption = false; 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();
}
}
}
} }

View File

@ -29,13 +29,22 @@ export class SendQueue {
if (pendingEvents.length) { if (pendingEvents.length) {
console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents); 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._isSending = false;
this._offline = false; this._offline = false;
this._amountSent = 0;
this._roomEncryption = null; 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) { enableEncryption(roomEncryption) {
this._roomEncryption = roomEncryption; this._roomEncryption = roomEncryption;
} }
@ -43,51 +52,41 @@ export class SendQueue {
async _sendLoop() { async _sendLoop() {
this._isSending = true; this._isSending = true;
try { try {
console.log("start sending", this._amountSent, "<", this._pendingEvents.length); for (let i = 0; i < this._pendingEvents.length; i += 1) {
while (this._amountSent < this._pendingEvents.length) { const pendingEvent = this._pendingEvents.get(i);
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 { try {
await attachment.uploaded(); await this._sendEvent(pendingEvent);
} catch (err) { } catch(err) {
console.log("upload failed, skip sending message", pendingEvent); if (err instanceof ConnectionError) {
this._amountSent += 1; this._offline = true;
continue; break;
} else {
pendingEvent.setError(err);
} }
attachment.applyToContent(pendingEvent.content); }
}
} 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) { if (pendingEvent.needsEncryption) {
pendingEvent.setEncrypting();
const {type, content} = await this._roomEncryption.encrypt( const {type, content} = await this._roomEncryption.encrypt(
pendingEvent.eventType, pendingEvent.content, this._hsApi); pendingEvent.eventType, pendingEvent.content, this._hsApi);
pendingEvent.setEncrypted(type, content); pendingEvent.setEncrypted(type, content);
await this._tryUpdateEvent(pendingEvent); await this._tryUpdateEvent(pendingEvent);
} }
console.log("really sending now"); if (pendingEvent.needsSending) {
const response = await this._hsApi.send( await pendingEvent.send(this._hsApi);
pendingEvent.roomId, console.log("writing remoteId");
pendingEvent.eventType,
pendingEvent.txnId,
pendingEvent.content
).response();
pendingEvent.remoteId = response.event_id;
//
console.log("writing remoteId now");
await this._tryUpdateEvent(pendingEvent); 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;
} }
} }
@ -110,13 +109,28 @@ export class SendQueue {
return removed; 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) { emitRemovals(pendingEvents) {
for (const pendingEvent of pendingEvents) { for (const pendingEvent of pendingEvents) {
const idx = this._pendingEvents.array.indexOf(pendingEvent); const idx = this._pendingEvents.array.indexOf(pendingEvent);
if (idx !== -1) { if (idx !== -1) {
this._amountSent -= 1;
this._pendingEvents.remove(idx); this._pendingEvents.remove(idx);
} }
pendingEvent.dispose();
} }
} }
@ -127,8 +141,8 @@ export class SendQueue {
} }
} }
async enqueueEvent(eventType, content, attachment) { async enqueueEvent(eventType, content, attachments) {
const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment); const pendingEvent = await this._createAndStoreEvent(eventType, content, attachments);
this._pendingEvents.set(pendingEvent); this._pendingEvents.set(pendingEvent);
console.log("added to _pendingEvents set", this._pendingEvents.length); console.log("added to _pendingEvents set", this._pendingEvents.length);
if (!this._isSending && !this._offline) { if (!this._isSending && !this._offline) {
@ -161,7 +175,7 @@ export class SendQueue {
await txn.complete(); await txn.complete();
} }
async _createAndStoreEvent(eventType, content, attachment) { async _createAndStoreEvent(eventType, content, attachments) {
console.log("_createAndStoreEvent"); console.log("_createAndStoreEvent");
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
let pendingEvent; let pendingEvent;
@ -171,14 +185,15 @@ export class SendQueue {
const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0; const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0;
console.log("_createAndStoreEvent got maxQueueIndex", maxQueueIndex); console.log("_createAndStoreEvent got maxQueueIndex", maxQueueIndex);
const queueIndex = maxQueueIndex + 1; const queueIndex = maxQueueIndex + 1;
pendingEvent = new PendingEvent({ pendingEvent = this._createPendingEvent({
roomId: this._roomId, roomId: this._roomId,
queueIndex, queueIndex,
eventType, eventType,
content, content,
txnId: makeTxnId(), txnId: makeTxnId(),
needsEncryption: !!this._roomEncryption needsEncryption: !!this._roomEncryption,
}, attachment); needsUpload: !!attachments
}, attachments);
console.log("_createAndStoreEvent: adding to pendingEventsStore"); console.log("_createAndStoreEvent: adding to pendingEventsStore");
pendingEventsStore.add(pendingEvent.data); pendingEventsStore.add(pendingEvent.data);
} catch (err) { } catch (err) {
@ -188,4 +203,10 @@ export class SendQueue {
await txn.complete(); await txn.complete();
return pendingEvent; return pendingEvent;
} }
dispose() {
for (const pe in this._pendingEvents.array) {
pe.dispose();
}
}
} }

View File

@ -64,8 +64,8 @@ export class PendingEventEntry extends BaseEntry {
return this._pendingEvent.txnId; return this._pendingEvent.txnId;
} }
get attachment() { get pendingEvent() {
return this._pendingEvent.attachment; return this._pendingEvent;
} }
notifyUpdate() { notifyUpdate() {

View File

@ -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 aesjs from "../../../lib/aes-js/index.js";
import {hkdf} from "../../utils/crypto/hkdf.js"; import {hkdf} from "../../utils/crypto/hkdf.js";
import {Platform as ModernPlatform} from "./Platform.js"; import {Platform as ModernPlatform} from "./Platform.js";

View File

@ -18,6 +18,7 @@ import {createFetchRequest} from "./dom/request/fetch.js";
import {xhrRequest} from "./dom/request/xhr.js"; import {xhrRequest} from "./dom/request/xhr.js";
import {StorageFactory} from "../../matrix/storage/idb/StorageFactory.js"; import {StorageFactory} from "../../matrix/storage/idb/StorageFactory.js";
import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionInfoStorage.js"; import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionInfoStorage.js";
import {SettingsStorage} from "./dom/SettingsStorage.js";
import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js"; import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js";
import {RootView} from "./ui/RootView.js"; import {RootView} from "./ui/RootView.js";
import {Clock} from "./dom/Clock.js"; import {Clock} from "./dom/Clock.js";
@ -28,6 +29,7 @@ import {Crypto} from "./dom/Crypto.js";
import {estimateStorageUsage} from "./dom/StorageEstimate.js"; import {estimateStorageUsage} from "./dom/StorageEstimate.js";
import {WorkerPool} from "./dom/WorkerPool.js"; import {WorkerPool} from "./dom/WorkerPool.js";
import {BlobHandle} from "./dom/BlobHandle.js"; import {BlobHandle} from "./dom/BlobHandle.js";
import {hasReadPixelPermission, ImageHandle} from "./dom/ImageHandle.js";
import {downloadInIframe} from "./dom/download.js"; import {downloadInIframe} from "./dom/download.js";
function addScript(src) { function addScript(src) {
@ -77,7 +79,6 @@ async function loadOlmWorker(paths) {
return olmWorker; return olmWorker;
} }
export class Platform { export class Platform {
constructor(container, paths, cryptoExtras = null) { constructor(container, paths, cryptoExtras = null) {
this._paths = paths; this._paths = paths;
@ -93,6 +94,7 @@ export class Platform {
this.crypto = new Crypto(cryptoExtras); this.crypto = new Crypto(cryptoExtras);
this.storageFactory = new StorageFactory(this._serviceWorkerHandler); this.storageFactory = new StorageFactory(this._serviceWorkerHandler);
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_");
this.estimateStorageUsage = estimateStorageUsage; this.estimateStorageUsage = estimateStorageUsage;
this.random = Math.random; this.random = Math.random;
if (typeof fetch === "function") { if (typeof fetch === "function") {
@ -156,9 +158,9 @@ export class Platform {
const file = input.files[0]; const file = input.files[0];
this._container.removeChild(input); this._container.removeChild(input);
if (file) { if (file) {
resolve({name: file.name, blob: BlobHandle.fromFile(file)}); resolve({name: file.name, blob: BlobHandle.fromBlob(file)});
} else { } else {
reject(new Error("No file selected")); resolve();
} }
} }
input.addEventListener("change", checkFile, true); input.addEventListener("change", checkFile, true);
@ -168,4 +170,16 @@ export class Platform {
input.click(); input.click();
return promise; return promise;
} }
async loadImage(blob) {
return ImageHandle.fromBlob(blob);
}
hasReadPixelPermission() {
return hasReadPixelPermission();
}
get devicePixelRatio() {
return window.devicePixelRatio || 1;
}
} }

View File

@ -84,9 +84,9 @@ export class BlobHandle {
return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer); 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 // ok to not filter mimetypes as these are local files
return new BlobHandle(file); return new BlobHandle(blob);
} }
get nativeBlob() { get nativeBlob() {

View File

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

View File

@ -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}`);
}
}

View File

@ -20,7 +20,7 @@ export async function downloadInIframe(container, iframeSrc, blob, filename) {
iframe = document.createElement("iframe"); iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-scripts allow-downloads allow-downloads-without-user-activation"); iframe.setAttribute("sandbox", "allow-scripts allow-downloads allow-downloads-without-user-activation");
iframe.setAttribute("src", iframeSrc); iframe.setAttribute("src", iframeSrc);
iframe.className = "downloadSandbox"; iframe.className = "hidden";
container.appendChild(iframe); container.appendChild(iframe);
let detach; let detach;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {

View File

@ -21,6 +21,7 @@ import {
} from "../../../../matrix/error.js"; } from "../../../../matrix/error.js";
import {abortOnTimeout} from "./timeout.js"; import {abortOnTimeout} from "./timeout.js";
import {addCacheBuster} from "./common.js"; import {addCacheBuster} from "./common.js";
import {xhrRequest} from "./xhr.js";
class RequestResult { class RequestResult {
constructor(promise, controller) { constructor(promise, controller) {
@ -51,7 +52,12 @@ class RequestResult {
} }
export function createFetchRequest(createTimeout) { 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; const controller = typeof AbortController === "function" ? new AbortController() : null;
// if a BlobHandle, take native blob // if a BlobHandle, take native blob
if (body?.nativeBlob) { if (body?.nativeBlob) {

View File

@ -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(); const xhr = new XMLHttpRequest();
xhr.open(method, url); xhr.open(method, url);
@ -45,18 +45,20 @@ function send(url, {method, headers, timeout, body, format}) {
} }
if (headers) { if (headers) {
for(const [name, value] of headers.entries()) { for(const [name, value] of headers.entries()) {
try {
xhr.setRequestHeader(name, value); xhr.setRequestHeader(name, value);
} catch (err) {
console.info(`Could not set ${name} header: ${err.message}`);
}
} }
} }
if (timeout) { if (timeout) {
xhr.timeout = timeout; xhr.timeout = timeout;
} }
// if a BlobHandle, take native blob if (uploadProgress) {
if (body?.nativeBlob) { xhr.upload.addEventListener("progress", evt => uploadProgress(evt.loaded));
body = body.nativeBlob;
} }
xhr.send(body || null);
return xhr; return xhr;
} }
@ -71,12 +73,12 @@ function xhrAsPromise(xhr, method, url) {
} }
export function xhrRequest(url, options) { export function xhrRequest(url, options) {
const {cache, format} = options; let {cache, format, body, method} = options;
if (!cache) { if (!cache) {
url = addCacheBuster(url); url = addCacheBuster(url);
} }
const xhr = send(url, options); const xhr = createXhr(url, options);
const promise = xhrAsPromise(xhr, options.method, url).then(xhr => { const promise = xhrAsPromise(xhr, method, url).then(xhr => {
const {status} = xhr; const {status} = xhr;
let body = null; let body = null;
if (format === "buffer") { if (format === "buffer") {
@ -86,5 +88,12 @@ export function xhrRequest(url, options) {
} }
return {status, body}; return {status, body};
}); });
// if a BlobHandle, take native blob
if (body?.nativeBlob) {
body = body.nativeBlob;
}
xhr.send(body || null);
return new RequestResult(promise, xhr); return new RequestResult(promise, xhr);
} }

View File

@ -96,6 +96,8 @@ main {
width: 100%; width: 100%;
/* otherwise we don't get scrollbars and the content grows as large as it can */ /* otherwise we don't get scrollbars and the content grows as large as it can */
min-height: 0; min-height: 0;
/* make popups relative to this element so changing the left panel width doesn't affect their position */
position: relative;
} }
.RoomView { .RoomView {
@ -109,12 +111,11 @@ main {
} }
.lightbox { .lightbox {
/* cover left and middle panel, not status view position: absolute;
use numeric positions because named grid areas top: 0;
are not present in mobile layout */ bottom: 0;
grid-area: 2 / 1 / 3 / 3; left: 0;
/* this should not be necessary, but chrome seems to have a bug when there are scrollbars in other grid items, right: 0;
it seems to put the scroll areas on top of the other grid items unless they have a z-index */
z-index: 1; z-index: 1;
} }
@ -164,6 +165,11 @@ main {
pointer-events: none; pointer-events: none;
} }
.menu {
position: absolute;
z-index: 2;
}
.Settings { .Settings {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -49,7 +49,3 @@ body.hydrogen {
input::-ms-clear { input::-ms-clear {
display: none; display: none;
} }
.hydrogen > iframe.downloadSandbox {
display: none;
}

View File

@ -316,6 +316,7 @@ a {
.SessionStatusView button.link { .SessionStatusView button.link {
color: currentcolor; color: currentcolor;
text-align: left;
} }
.SessionStatusView > .end { .SessionStatusView > .end {
@ -556,11 +557,17 @@ ul.Timeline > li.messageStatus .message-container > p {
.message-container .picture { .message-container .picture {
display: grid; display: grid;
text-decoration: none;
margin-top: 4px; margin-top: 4px;
width: 100%; 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, /* .spacer grows with an inline padding-top to the size of the image,
so the timeline doesn't jump when the image loads */ so the timeline doesn't jump when the image loads */
.message-container .picture > * { .message-container .picture > * {
@ -568,24 +575,41 @@ so the timeline doesn't jump when the image loads */
grid-column: 1; grid-column: 1;
} }
.message-container .picture > img { .message-container .picture img {
width: 100%; width: 100%;
height: auto; height: auto;
/* for IE11 to still scale even though the spacer is too tall */ /* for IE11 to still scale even though the spacer is too tall */
align-self: start; align-self: start;
border-radius: 4px; border-radius: 4px;
display: block;
} }
/* stretch the image (to the spacer) on platforms /* stretch the image (to the spacer) on platforms
where we can trust the spacer to always have the correct height, where we can trust the spacer to always have the correct height,
otherwise the image starts with height 0 and with loading=lazy otherwise the image starts with height 0 and with loading=lazy
only loads when the top comes into view*/ 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; 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 { .message-container .picture > time {
align-self: end; align-self: end;
justify-self: end; justify-self: end;
}
.message-container .picture > time,
.message-container .picture > .sendStatus {
color: #2e2f32; color: #2e2f32;
display: block; display: block;
padding: 2px; padding: 2px;
@ -653,6 +677,7 @@ only loads when the top comes into view*/
.Settings .row .content { .Settings .row .content {
margin-left: 4px; margin-left: 4px;
flex: 1;
} }
.Settings .row.code .content { .Settings .row.code .content {
@ -664,6 +689,12 @@ only loads when the top comes into view*/
margin: 0 8px; margin: 0 8px;
} }
.Settings .row .content input[type=range] {
width: 100%;
max-width: 300px;
min-width: 160px;
}
.Settings .row { .Settings .row {
margin: 4px 0px; margin: 4px 0px;
display: flex; display: flex;
@ -762,4 +793,31 @@ button.link {
width: 200px; 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;
}

View File

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

View File

@ -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);
}

View File

@ -44,9 +44,6 @@ export class TemplateView {
this._render = render; this._render = render;
this._eventListeners = null; this._eventListeners = null;
this._bindings = 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._subViews = null;
this._root = null; this._root = null;
this._boundUpdateFromValue = null; this._boundUpdateFromValue = null;
@ -57,7 +54,7 @@ export class TemplateView {
} }
_subscribe() { _subscribe() {
if (typeof this._value.on === "function") { if (typeof this._value?.on === "function") {
this._boundUpdateFromValue = this._updateFromValue.bind(this); this._boundUpdateFromValue = this._updateFromValue.bind(this);
this._value.on("change", this._boundUpdateFromValue); this._value.on("change", this._boundUpdateFromValue);
} }
@ -146,12 +143,19 @@ export class TemplateView {
this._bindings.push(bindingFn); this._bindings.push(bindingFn);
} }
_addSubView(view) { addSubView(view) {
if (!this._subViews) { if (!this._subViews) {
this._subViews = []; this._subViews = [];
} }
this._subViews.push(view); 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 // what is passed to render
@ -288,7 +292,7 @@ class TemplateBuilder {
} catch (err) { } catch (err) {
return errorToDOM(err); return errorToDOM(err);
} }
this._templateView._addSubView(view); this._templateView.addSubView(view);
return root; return root;
} }

View File

@ -94,7 +94,7 @@ export const TAG_NAMES = {
[HTML_NS]: [ [HTML_NS]: [
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
"p", "strong", "em", "span", "img", "section", "main", "article", "aside", "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"] [SVG_NS]: ["svg", "circle"]
}; };

View File

@ -15,11 +15,14 @@ limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js";
export class MessageComposer extends TemplateView { export class MessageComposer extends TemplateView {
constructor(viewModel) { constructor(viewModel) {
super(viewModel); super(viewModel);
this._input = null; this._input = null;
this._attachmentPopup = null;
} }
render(t, vm) { render(t, vm) {
@ -32,8 +35,8 @@ export class MessageComposer extends TemplateView {
this._input, this._input,
t.button({ t.button({
className: "sendFile", className: "sendFile",
title: vm.i18n`Send file`, title: vm.i18n`Pick attachment`,
onClick: () => vm.sendAttachment(), onClick: evt => this._toggleAttachmentMenu(evt),
}, vm.i18n`Send file`), }, vm.i18n`Send file`),
t.button({ t.button({
className: "send", className: "send",
@ -56,4 +59,29 @@ export class MessageComposer extends TemplateView {
this._trySend(); 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,
}
});
}
}
} }

View File

@ -19,6 +19,7 @@ import {GapView} from "./timeline/GapView.js";
import {TextMessageView} from "./timeline/TextMessageView.js"; import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js"; import {ImageView} from "./timeline/ImageView.js";
import {FileView} from "./timeline/FileView.js"; import {FileView} from "./timeline/FileView.js";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js";
function viewClassForEntry(entry) { function viewClassForEntry(entry) {
@ -30,6 +31,7 @@ function viewClassForEntry(entry) {
return TextMessageView; return TextMessageView;
case "image": return ImageView; case "image": return ImageView;
case "file": return FileView; case "file": return FileView;
case "missing-attachment": return MissingAttachmentView;
} }
} }

View File

@ -19,11 +19,17 @@ import {renderMessage} from "./common.js";
export class FileView extends TemplateView { export class FileView extends TemplateView {
render(t, vm) { render(t, vm) {
return renderMessage(t, vm, [ if (vm.isPending) {
t.p([ 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.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)
]) ]));
]); }
} }
} }

View File

@ -31,18 +31,36 @@ export class ImageView extends TemplateView {
// can slow down rendering, and was bleeding through the lightbox. // can slow down rendering, and was bleeding through the lightbox.
spacerStyle = `height: ${vm.thumbnailHeight}px`; spacerStyle = `height: ${vm.thumbnailHeight}px`;
} }
return renderMessage(t, vm, [ const img = t.img({
t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [
t.div({className: "spacer", style: spacerStyle}),
t.img({
loading: "lazy", loading: "lazy",
src: vm => vm.thumbnailUrl, src: vm => vm.thumbnailUrl,
alt: vm => vm.label, alt: vm => vm.label,
title: vm => vm.label, title: vm => vm.label,
style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` 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), 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.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))) t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
]); ]);
} }

View File

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

View File

@ -24,7 +24,7 @@ export function renderMessage(t, vm, children) {
pending: vm.isPending, pending: vm.isPending,
unverified: vm.isUnverified, unverified: vm.isUnverified,
continuation: vm => vm.isContinuation, 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"}, [ const profile = t.div({className: "profile"}, [

View File

@ -46,10 +46,32 @@ export class SettingsView extends TemplateView {
row(vm.i18n`Session key`, vm.fingerprintKey, "code"), row(vm.i18n`Session key`, vm.fingerprintKey, "code"),
t.h3("Session Backup"), t.h3("Session Backup"),
t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)),
t.h3("Preferences"),
row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
t.h3("Application"), t.h3("Application"),
row(vm.i18n`Version`, version), row(vm.i18n`Version`, version),
row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), 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`;
})];
}
} }