Merge pull request #199 from vector-im/bwindels/upload-images
Upload images
This commit is contained in:
commit
4cf66b8e61
38 changed files with 1498 additions and 236 deletions
378
prototypes/menu-relative.html
Normal file
378
prototypes/menu-relative.html
Normal 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>
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 => {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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("");
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -110,6 +110,7 @@ export class HomeServerApi {
|
|||
headers,
|
||||
body: encodedBody,
|
||||
timeout: options?.timeout,
|
||||
uploadProgress: options?.uploadProgress,
|
||||
format: "json" // response format
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
106
src/platform/web/dom/ImageHandle.js
Normal file
106
src/platform/web/dom/ImageHandle.js
Normal 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;
|
||||
}
|
37
src/platform/web/dom/SettingsStorage.js
Normal file
37
src/platform/web/dom/SettingsStorage.js
Normal 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}`);
|
||||
}
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -49,7 +49,3 @@ body.hydrogen {
|
|||
input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hydrogen > iframe.downloadSandbox {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
49
src/platform/web/ui/general/Menu.js
Normal file
49
src/platform/web/ui/general/Menu.js
Normal 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;
|
||||
}
|
||||
}
|
181
src/platform/web/ui/general/Popup.js
Normal file
181
src/platform/web/ui/general/Popup.js
Normal 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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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"]
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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]));
|
||||
}
|
||||
}
|
|
@ -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"}, [
|
||||
|
|
|
@ -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`;
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
|
Reference in a new issue