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() {
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;
}

View File

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

View File

@ -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() {

View File

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

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() {
return this._entry.isPending;
}
abortSending() {
this._entry.pendingEvent?.abort();
}
// TilesCollection contract below
setUpdateEmit(emitUpdate) {
this.updateOptions({emitChange: paramName => {

View File

@ -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": {

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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() {

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 {hkdf} from "../../utils/crypto/hkdf.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 {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;
}
}

View File

@ -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() {

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.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) => {

View File

@ -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) {

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

View File

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

View File

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

View File

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

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._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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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,
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"}, [

View File

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