mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-23 03:25:12 +01:00
Merge pull request #199 from vector-im/bwindels/upload-images
Upload images
This commit is contained in:
commit
4cf66b8e61
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() {
|
get avatarUrl() {
|
||||||
if (this._room.avatarUrl) {
|
if (this._room.avatarUrl) {
|
||||||
return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, 32, 32, "crop");
|
const size = 32 * this.platform.devicePixelRatio;
|
||||||
|
return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -134,7 +134,8 @@ export class RoomViewModel extends ViewModel {
|
|||||||
|
|
||||||
get avatarUrl() {
|
get avatarUrl() {
|
||||||
if (this._room.avatarUrl) {
|
if (this._room.avatarUrl) {
|
||||||
return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, 32, 32, "crop");
|
const size = 32 * this.platform.devicePixelRatio;
|
||||||
|
return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -164,21 +165,67 @@ export class RoomViewModel extends ViewModel {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _sendFile() {
|
async _pickAndSendFile() {
|
||||||
let file;
|
|
||||||
try {
|
try {
|
||||||
file = await this.platform.openFile();
|
const file = await this.platform.openFile();
|
||||||
} catch (err) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const attachment = this._room.uploadAttachment(file.blob, file.name);
|
return this._sendFile(file);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sendFile(file) {
|
||||||
const content = {
|
const content = {
|
||||||
body: file.name,
|
body: file.name,
|
||||||
msgtype: "m.file",
|
msgtype: "m.file"
|
||||||
};
|
};
|
||||||
await this._room.sendEvent("m.room.message", content, attachment);
|
await this._room.sendEvent("m.room.message", content, {
|
||||||
|
"url": this._room.createAttachment(file.blob, file.name)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _pickAndSendPicture() {
|
||||||
|
try {
|
||||||
|
if (!this.platform.hasReadPixelPermission()) {
|
||||||
|
alert("Please allow canvas image data access, so we can scale your images down.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = await this.platform.openFile("image/*");
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file.blob.mimeType.startsWith("image/")) {
|
||||||
|
return this._sendFile(file);
|
||||||
|
}
|
||||||
|
let image = await this.platform.loadImage(file.blob);
|
||||||
|
const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
|
||||||
|
if (limit && image.maxDimension > limit) {
|
||||||
|
image = await image.scale(limit);
|
||||||
|
}
|
||||||
|
const content = {
|
||||||
|
body: file.name,
|
||||||
|
msgtype: "m.image",
|
||||||
|
info: imageToInfo(image)
|
||||||
|
};
|
||||||
|
const attachments = {
|
||||||
|
"url": this._room.createAttachment(image.blob, file.name),
|
||||||
|
};
|
||||||
|
if (image.maxDimension > 600) {
|
||||||
|
const thumbnail = await image.scale(400);
|
||||||
|
content.info.thumbnail_info = imageToInfo(thumbnail);
|
||||||
|
attachments["info.thumbnail_url"] =
|
||||||
|
this._room.createAttachment(thumbnail.blob, file.name);
|
||||||
|
}
|
||||||
|
await this._room.sendEvent("m.room.message", content, attachments);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
get composerViewModel() {
|
get composerViewModel() {
|
||||||
return this._composerVM;
|
return this._composerVM;
|
||||||
}
|
}
|
||||||
@ -204,8 +251,12 @@ class ComposerViewModel extends ViewModel {
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAttachment() {
|
sendPicture() {
|
||||||
this._roomVM._sendFile();
|
this._roomVM._pickAndSendPicture();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFile() {
|
||||||
|
this._roomVM._pickAndSendFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
get canSend() {
|
get canSend() {
|
||||||
@ -223,3 +274,12 @@ class ComposerViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageToInfo(image) {
|
||||||
|
return {
|
||||||
|
w: image.width,
|
||||||
|
h: image.height,
|
||||||
|
mimetype: image.blob.mimeType,
|
||||||
|
size: image.blob.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -17,24 +17,17 @@ limitations under the License.
|
|||||||
|
|
||||||
import {MessageTile} from "./MessageTile.js";
|
import {MessageTile} from "./MessageTile.js";
|
||||||
import {formatSize} from "../../../../../utils/formatSize.js";
|
import {formatSize} from "../../../../../utils/formatSize.js";
|
||||||
|
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
|
||||||
|
|
||||||
export class FileTile extends MessageTile {
|
export class FileTile extends MessageTile {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
this._error = null;
|
this._downloadError = null;
|
||||||
this._downloading = false;
|
this._downloading = false;
|
||||||
if (this._isUploading) {
|
|
||||||
// should really do this with an ObservableValue and waitFor to prevent leaks when the promise never resolves
|
|
||||||
this._entry.attachment.uploaded().finally(() => {
|
|
||||||
if (!this.isDisposed) {
|
|
||||||
this.emitChange("label");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async download() {
|
async download() {
|
||||||
if (this._downloading || this._isUploading) {
|
if (this._downloading || this.isPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = this._getContent();
|
const content = this._getContent();
|
||||||
@ -46,7 +39,7 @@ export class FileTile extends MessageTile {
|
|||||||
blob = await this._mediaRepository.downloadAttachment(content);
|
blob = await this._mediaRepository.downloadAttachment(content);
|
||||||
this.platform.saveFileAs(blob, filename);
|
this.platform.saveFileAs(blob, filename);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._error = err;
|
this._downloadError = err;
|
||||||
} finally {
|
} finally {
|
||||||
blob?.dispose();
|
blob?.dispose();
|
||||||
this._downloading = false;
|
this._downloading = false;
|
||||||
@ -54,39 +47,40 @@ export class FileTile extends MessageTile {
|
|||||||
this.emitChange("label");
|
this.emitChange("label");
|
||||||
}
|
}
|
||||||
|
|
||||||
get size() {
|
|
||||||
if (this._isUploading) {
|
|
||||||
return this._entry.attachment.localPreview.size;
|
|
||||||
} else {
|
|
||||||
return this._getContent().info?.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get _isUploading() {
|
|
||||||
return this._entry.attachment && !this._entry.attachment.isUploaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
get label() {
|
get label() {
|
||||||
if (this._error) {
|
if (this._downloadError) {
|
||||||
return `Could not decrypt file: ${this._error.message}`;
|
return `Could not download file: ${this._downloadError.message}`;
|
||||||
}
|
|
||||||
if (this._entry.attachment?.error) {
|
|
||||||
return `Failed to upload: ${this._entry.attachment.error.message}`;
|
|
||||||
}
|
}
|
||||||
const content = this._getContent();
|
const content = this._getContent();
|
||||||
const filename = content.body;
|
const filename = content.body;
|
||||||
const size = formatSize(this.size);
|
|
||||||
if (this._isUploading) {
|
if (this._entry.isPending) {
|
||||||
return this.i18n`Uploading ${filename} (${size})…`;
|
const {pendingEvent} = this._entry;
|
||||||
} else if (this._downloading) {
|
switch (pendingEvent?.status) {
|
||||||
|
case SendStatus.Waiting:
|
||||||
|
return this.i18n`Waiting to send ${filename}…`;
|
||||||
|
case SendStatus.EncryptingAttachments:
|
||||||
|
case SendStatus.Encrypting:
|
||||||
|
return this.i18n`Encrypting ${filename}…`;
|
||||||
|
case SendStatus.UploadingAttachments:{
|
||||||
|
const percent = Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100);
|
||||||
|
return this.i18n`Uploading ${filename}: ${percent}%`;
|
||||||
|
}
|
||||||
|
case SendStatus.Sending:
|
||||||
|
return this.i18n`Sending ${filename}…`;
|
||||||
|
case SendStatus.Error:
|
||||||
|
return this.i18n`Error: could not send ${filename}: ${pendingEvent.error.message}`;
|
||||||
|
default:
|
||||||
|
return `Unknown send status for ${filename}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const size = formatSize(this._getContent().info?.size);
|
||||||
|
if (this._downloading) {
|
||||||
return this.i18n`Downloading ${filename} (${size})…`;
|
return this.i18n`Downloading ${filename} (${size})…`;
|
||||||
} else {
|
} else {
|
||||||
return this.i18n`Download ${filename} (${size})`;
|
return this.i18n`Download ${filename} (${size})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get error() {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get shape() {
|
get shape() {
|
||||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {MessageTile} from "./MessageTile.js";
|
import {MessageTile} from "./MessageTile.js";
|
||||||
|
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
|
||||||
const MAX_HEIGHT = 300;
|
const MAX_HEIGHT = 300;
|
||||||
const MAX_WIDTH = 400;
|
const MAX_WIDTH = 400;
|
||||||
|
|
||||||
@ -26,7 +26,9 @@ export class ImageTile extends MessageTile {
|
|||||||
this._decryptedThumbail = null;
|
this._decryptedThumbail = null;
|
||||||
this._decryptedImage = null;
|
this._decryptedImage = null;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this.load();
|
if (!this.isPending) {
|
||||||
|
this.tryLoadEncryptedThumbnail();
|
||||||
|
}
|
||||||
this._lightboxUrl = this.urlCreator.urlForSegments([
|
this._lightboxUrl = this.urlCreator.urlForSegments([
|
||||||
// ensure the right room is active if in grid view
|
// ensure the right room is active if in grid view
|
||||||
this.navigation.segment("room", this._room.id),
|
this.navigation.segment("room", this._room.id),
|
||||||
@ -43,7 +45,7 @@ export class ImageTile extends MessageTile {
|
|||||||
return this.track(blob);
|
return this.track(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async tryLoadEncryptedThumbnail() {
|
||||||
try {
|
try {
|
||||||
const thumbnailFile = this._getContent().info?.thumbnail_file;
|
const thumbnailFile = this._getContent().info?.thumbnail_file;
|
||||||
const file = this._getContent().file;
|
const file = this._getContent().file;
|
||||||
@ -61,8 +63,39 @@ export class ImageTile extends MessageTile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get lightboxUrl() {
|
get lightboxUrl() {
|
||||||
|
if (!this.isPending) {
|
||||||
return this._lightboxUrl;
|
return this._lightboxUrl;
|
||||||
}
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isUploading() {
|
||||||
|
return this.isPending && this._entry.pendingEvent.status === SendStatus.UploadingAttachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
get uploadPercentage() {
|
||||||
|
const {pendingEvent} = this._entry;
|
||||||
|
return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
get sendStatus() {
|
||||||
|
const {pendingEvent} = this._entry;
|
||||||
|
switch (pendingEvent?.status) {
|
||||||
|
case SendStatus.Waiting:
|
||||||
|
return this.i18n`Waiting…`;
|
||||||
|
case SendStatus.EncryptingAttachments:
|
||||||
|
case SendStatus.Encrypting:
|
||||||
|
return this.i18n`Encrypting…`;
|
||||||
|
case SendStatus.UploadingAttachments:
|
||||||
|
return this.i18n`Uploading…`;
|
||||||
|
case SendStatus.Sending:
|
||||||
|
return this.i18n`Sending…`;
|
||||||
|
case SendStatus.Error:
|
||||||
|
return this.i18n`Error: ${pendingEvent.error.message}`;
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get thumbnailUrl() {
|
get thumbnailUrl() {
|
||||||
if (this._decryptedThumbail) {
|
if (this._decryptedThumbail) {
|
||||||
@ -70,6 +103,10 @@ export class ImageTile extends MessageTile {
|
|||||||
} else if (this._decryptedImage) {
|
} else if (this._decryptedImage) {
|
||||||
return this._decryptedImage.url;
|
return this._decryptedImage.url;
|
||||||
}
|
}
|
||||||
|
if (this._entry.isPending) {
|
||||||
|
const attachment = this._entry.pendingEvent.getAttachment("url");
|
||||||
|
return attachment && attachment.localPreview.url;
|
||||||
|
}
|
||||||
const mxcUrl = this._getContent()?.url;
|
const mxcUrl = this._getContent()?.url;
|
||||||
if (typeof mxcUrl === "string") {
|
if (typeof mxcUrl === "string") {
|
||||||
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
|
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
|
||||||
@ -77,16 +114,6 @@ export class ImageTile extends MessageTile {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadImageUrl() {
|
|
||||||
if (!this._decryptedImage) {
|
|
||||||
const file = this._getContent().file;
|
|
||||||
if (file) {
|
|
||||||
this._decryptedImage = await this._loadEncryptedFile(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this._decryptedImage?.url || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
_scaleFactor() {
|
_scaleFactor() {
|
||||||
const info = this._getContent()?.info;
|
const info = this._getContent()?.info;
|
||||||
const scaleHeightFactor = MAX_HEIGHT / info?.h;
|
const scaleHeightFactor = MAX_HEIGHT / info?.h;
|
||||||
|
@ -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() {
|
get isPending() {
|
||||||
return this._entry.isPending;
|
return this._entry.isPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abortSending() {
|
||||||
|
this._entry.pendingEvent?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
// TilesCollection contract below
|
// TilesCollection contract below
|
||||||
setUpdateEmit(emitUpdate) {
|
setUpdateEmit(emitUpdate) {
|
||||||
this.updateOptions({emitChange: paramName => {
|
this.updateOptions({emitChange: paramName => {
|
||||||
|
@ -23,12 +23,15 @@ import {RoomNameTile} from "./tiles/RoomNameTile.js";
|
|||||||
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
|
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
|
||||||
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
||||||
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
||||||
|
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
|
||||||
|
|
||||||
export function tilesCreator(baseOptions) {
|
export function tilesCreator(baseOptions) {
|
||||||
return function tilesCreator(entry, emitUpdate) {
|
return function tilesCreator(entry, emitUpdate) {
|
||||||
const options = Object.assign({entry, emitUpdate}, baseOptions);
|
const options = Object.assign({entry, emitUpdate}, baseOptions);
|
||||||
if (entry.isGap) {
|
if (entry.isGap) {
|
||||||
return new GapTile(options);
|
return new GapTile(options);
|
||||||
|
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
|
||||||
|
return new MissingAttachmentTile(options);
|
||||||
} else if (entry.eventType) {
|
} else if (entry.eventType) {
|
||||||
switch (entry.eventType) {
|
switch (entry.eventType) {
|
||||||
case "m.room.message": {
|
case "m.room.message": {
|
||||||
|
@ -36,10 +36,26 @@ export class SettingsViewModel extends ViewModel {
|
|||||||
this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session})));
|
this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session})));
|
||||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||||
this._estimate = null;
|
this._estimate = null;
|
||||||
|
|
||||||
|
this.sentImageSizeLimit = null;
|
||||||
|
this.minSentImageSizeLimit = 400;
|
||||||
|
this.maxSentImageSizeLimit = 4000;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSentImageSizeLimit(size) {
|
||||||
|
if (size > this.maxSentImageSizeLimit || size < this.minSentImageSizeLimit) {
|
||||||
|
this.sentImageSizeLimit = null;
|
||||||
|
this.platform.settingsStorage.remove("sentImageSizeLimit");
|
||||||
|
} else {
|
||||||
|
this.sentImageSizeLimit = Math.round(size);
|
||||||
|
this.platform.settingsStorage.setInt("sentImageSizeLimit", size);
|
||||||
|
}
|
||||||
|
this.emitChange("sentImageSizeLimit");
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this._estimate = await this.platform.estimateStorageUsage();
|
this._estimate = await this.platform.estimateStorageUsage();
|
||||||
|
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
|
||||||
this.emitChange("");
|
this.emitChange("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ export async function encryptAttachment(platform, blob) {
|
|||||||
const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer});
|
const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer});
|
||||||
const digest = await crypto.digest("SHA-256", ciphertext);
|
const digest = await crypto.digest("SHA-256", ciphertext);
|
||||||
return {
|
return {
|
||||||
blob: platform.createBlob(ciphertext, blob.mimeType),
|
blob: platform.createBlob(ciphertext, 'application/octet-stream'),
|
||||||
info: {
|
info: {
|
||||||
v: "v2",
|
v: "v2",
|
||||||
key,
|
key,
|
||||||
|
@ -110,6 +110,7 @@ export class HomeServerApi {
|
|||||||
headers,
|
headers,
|
||||||
body: encodedBody,
|
body: encodedBody,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
|
uploadProgress: options?.uploadProgress,
|
||||||
format: "json" // response format
|
format: "json" // response format
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -17,56 +17,31 @@ limitations under the License.
|
|||||||
import {encryptAttachment} from "../e2ee/attachment.js";
|
import {encryptAttachment} from "../e2ee/attachment.js";
|
||||||
|
|
||||||
export class AttachmentUpload {
|
export class AttachmentUpload {
|
||||||
constructor({filename, blob, hsApi, platform, isEncrypted}) {
|
constructor({filename, blob, platform}) {
|
||||||
this._filename = filename;
|
this._filename = filename;
|
||||||
|
// need to keep around for local preview while uploading
|
||||||
this._unencryptedBlob = blob;
|
this._unencryptedBlob = blob;
|
||||||
this._isEncrypted = isEncrypted;
|
this._transferredBlob = this._unencryptedBlob;
|
||||||
this._platform = platform;
|
this._platform = platform;
|
||||||
this._hsApi = hsApi;
|
|
||||||
this._mxcUrl = null;
|
this._mxcUrl = null;
|
||||||
this._transferredBlob = null;
|
|
||||||
this._encryptionInfo = null;
|
this._encryptionInfo = null;
|
||||||
this._uploadPromise = null;
|
|
||||||
this._uploadRequest = null;
|
this._uploadRequest = null;
|
||||||
this._aborted = false;
|
this._aborted = false;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
|
this._sentBytes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
upload() {
|
/** important to call after encrypt() if encryption is needed */
|
||||||
if (!this._uploadPromise) {
|
get size() {
|
||||||
this._uploadPromise = this._upload();
|
return this._transferredBlob.size;
|
||||||
}
|
|
||||||
return this._uploadPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _upload() {
|
get sentBytes() {
|
||||||
try {
|
return this._sentBytes;
|
||||||
let transferredBlob = this._unencryptedBlob;
|
|
||||||
if (this._isEncrypted) {
|
|
||||||
const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob);
|
|
||||||
transferredBlob = blob;
|
|
||||||
this._encryptionInfo = info;
|
|
||||||
}
|
|
||||||
if (this._aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename);
|
|
||||||
const {content_uri} = await this._uploadRequest.response();
|
|
||||||
this._mxcUrl = content_uri;
|
|
||||||
this._transferredBlob = transferredBlob;
|
|
||||||
} catch (err) {
|
|
||||||
this._error = err;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get isUploaded() {
|
|
||||||
return !!this._transferredBlob;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
abort() {
|
abort() {
|
||||||
this._aborted = true;
|
|
||||||
this._uploadRequest?.abort();
|
this._uploadRequest?.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,34 +50,62 @@ export class AttachmentUpload {
|
|||||||
return this._unencryptedBlob;
|
return this._unencryptedBlob;
|
||||||
}
|
}
|
||||||
|
|
||||||
get error() {
|
/** @package */
|
||||||
return this._error;
|
async encrypt() {
|
||||||
|
if (this._encryptionInfo) {
|
||||||
|
throw new Error("already encrypted");
|
||||||
|
}
|
||||||
|
const {info, blob} = await encryptAttachment(this._platform, this._transferredBlob);
|
||||||
|
this._transferredBlob = blob;
|
||||||
|
this._encryptionInfo = info;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @package */
|
/** @package */
|
||||||
uploaded() {
|
async upload(hsApi, progressCallback) {
|
||||||
if (!this._uploadPromise) {
|
this._uploadRequest = hsApi.uploadAttachment(this._transferredBlob, this._filename, {
|
||||||
throw new Error("upload has not started yet");
|
uploadProgress: sentBytes => {
|
||||||
|
this._sentBytes = sentBytes;
|
||||||
|
progressCallback();
|
||||||
}
|
}
|
||||||
return this._uploadPromise;
|
});
|
||||||
|
const {content_uri} = await this._uploadRequest.response();
|
||||||
|
this._mxcUrl = content_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @package */
|
/** @package */
|
||||||
applyToContent(content) {
|
applyToContent(urlPath, content) {
|
||||||
if (!this._mxcUrl) {
|
if (!this._mxcUrl) {
|
||||||
throw new Error("upload has not finished");
|
throw new Error("upload has not finished");
|
||||||
}
|
}
|
||||||
content.info = {
|
let prefix = urlPath.substr(0, urlPath.lastIndexOf("url"));
|
||||||
size: this._transferredBlob.size,
|
setPath(`${prefix}info.size`, content, this._transferredBlob.size);
|
||||||
mimetype: this._unencryptedBlob.mimeType,
|
setPath(`${prefix}info.mimetype`, content, this._unencryptedBlob.mimeType);
|
||||||
};
|
if (this._encryptionInfo) {
|
||||||
if (this._isEncrypted) {
|
setPath(`${prefix}file`, content, Object.assign(this._encryptionInfo, {
|
||||||
content.file = Object.assign(this._encryptionInfo, {
|
|
||||||
mimetype: this._unencryptedBlob.mimeType,
|
mimetype: this._unencryptedBlob.mimeType,
|
||||||
url: this._mxcUrl
|
url: this._mxcUrl
|
||||||
});
|
}));
|
||||||
} else {
|
} else {
|
||||||
content.url = this._mxcUrl;
|
setPath(`${prefix}url`, content, this._mxcUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._unencryptedBlob.dispose();
|
||||||
|
this._transferredBlob.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPath(path, content, value) {
|
||||||
|
const parts = path.split(".");
|
||||||
|
let obj = content;
|
||||||
|
for (let i = 0; i < (parts.length - 1); i += 1) {
|
||||||
|
const key = parts[i];
|
||||||
|
if (!obj[key]) {
|
||||||
|
obj[key] = {};
|
||||||
|
}
|
||||||
|
obj = obj[key];
|
||||||
|
}
|
||||||
|
const propKey = parts[parts.length - 1];
|
||||||
|
obj[propKey] = value;
|
||||||
}
|
}
|
||||||
|
@ -352,8 +352,8 @@ export class Room extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
sendEvent(eventType, content, attachment) {
|
sendEvent(eventType, content, attachments) {
|
||||||
return this._sendQueue.enqueueEvent(eventType, content, attachment);
|
return this._sendQueue.enqueueEvent(eventType, content, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
@ -633,16 +633,14 @@ export class Room extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadAttachment(blob, filename) {
|
createAttachment(blob, filename) {
|
||||||
const attachment = new AttachmentUpload({blob, filename,
|
return new AttachmentUpload({blob, filename, platform: this._platform});
|
||||||
hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted});
|
|
||||||
attachment.upload();
|
|
||||||
return attachment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._roomEncryption?.dispose();
|
this._roomEncryption?.dispose();
|
||||||
this._timeline?.dispose();
|
this._timeline?.dispose();
|
||||||
|
this._sendQueue.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,11 +13,35 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
import {createEnum} from "../../../utils/enum.js";
|
||||||
|
import {AbortError} from "../../../utils/error.js";
|
||||||
|
|
||||||
|
export const SendStatus = createEnum(
|
||||||
|
"Waiting",
|
||||||
|
"EncryptingAttachments",
|
||||||
|
"UploadingAttachments",
|
||||||
|
"Encrypting",
|
||||||
|
"Sending",
|
||||||
|
"Sent",
|
||||||
|
"Error",
|
||||||
|
);
|
||||||
|
|
||||||
export class PendingEvent {
|
export class PendingEvent {
|
||||||
constructor(data, attachment) {
|
constructor({data, remove, emitUpdate, attachments}) {
|
||||||
this._data = data;
|
this._data = data;
|
||||||
this.attachment = attachment;
|
this._attachments = attachments;
|
||||||
|
this._emitUpdate = () => {
|
||||||
|
console.log("PendingEvent status", this.status, this._attachments && Object.entries(this._attachments).map(([key, a]) => `${key}: ${a.sentBytes}/${a.size}`));
|
||||||
|
emitUpdate();
|
||||||
|
};
|
||||||
|
this._removeFromQueueCallback = remove;
|
||||||
|
this._aborted = false;
|
||||||
|
this._status = SendStatus.Waiting;
|
||||||
|
this._sendRequest = null;
|
||||||
|
this._attachmentsTotalBytes = 0;
|
||||||
|
if (this._attachments) {
|
||||||
|
this._attachmentsTotalBytes = Object.values(this._attachments).reduce((t, a) => t + a.size, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get roomId() { return this._data.roomId; }
|
get roomId() { return this._data.roomId; }
|
||||||
@ -25,14 +49,129 @@ export class PendingEvent {
|
|||||||
get eventType() { return this._data.eventType; }
|
get eventType() { return this._data.eventType; }
|
||||||
get txnId() { return this._data.txnId; }
|
get txnId() { return this._data.txnId; }
|
||||||
get remoteId() { return this._data.remoteId; }
|
get remoteId() { return this._data.remoteId; }
|
||||||
set remoteId(value) { this._data.remoteId = value; }
|
|
||||||
get content() { return this._data.content; }
|
get content() { return this._data.content; }
|
||||||
get needsEncryption() { return this._data.needsEncryption; }
|
|
||||||
get data() { return this._data; }
|
get data() { return this._data; }
|
||||||
|
|
||||||
|
getAttachment(key) {
|
||||||
|
return this._attachments && this._attachments[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
get needsSending() {
|
||||||
|
return !this.remoteId && !this.aborted;
|
||||||
|
}
|
||||||
|
|
||||||
|
get needsEncryption() {
|
||||||
|
return this._data.needsEncryption && !this.aborted;
|
||||||
|
}
|
||||||
|
|
||||||
|
get needsUpload() {
|
||||||
|
return this._data.needsUpload && !this.aborted;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMissingAttachments() {
|
||||||
|
return this.needsUpload && !this._attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEncrypting() {
|
||||||
|
this._status = SendStatus.Encrypting;
|
||||||
|
this._emitUpdate("status");
|
||||||
|
}
|
||||||
|
|
||||||
setEncrypted(type, content) {
|
setEncrypted(type, content) {
|
||||||
this._data.eventType = type;
|
this._data.encryptedEventType = type;
|
||||||
this._data.content = content;
|
this._data.encryptedContent = content;
|
||||||
this._data.needsEncryption = false;
|
this._data.needsEncryption = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError(error) {
|
||||||
|
this._status = SendStatus.Error;
|
||||||
|
this._error = error;
|
||||||
|
this._emitUpdate("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
get status() { return this._status; }
|
||||||
|
get error() { return this._error; }
|
||||||
|
|
||||||
|
get attachmentsTotalBytes() {
|
||||||
|
return this._attachmentsTotalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
get attachmentsSentBytes() {
|
||||||
|
return this._attachments && Object.values(this._attachments).reduce((t, a) => t + a.sentBytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadAttachments(hsApi) {
|
||||||
|
if (!this.needsUpload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._attachments) {
|
||||||
|
throw new Error("attachments missing");
|
||||||
|
}
|
||||||
|
if (this.needsEncryption) {
|
||||||
|
this._status = SendStatus.EncryptingAttachments;
|
||||||
|
this._emitUpdate("status");
|
||||||
|
for (const attachment of Object.values(this._attachments)) {
|
||||||
|
await attachment.encrypt();
|
||||||
|
if (this.aborted) {
|
||||||
|
throw new AbortError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._status = SendStatus.UploadingAttachments;
|
||||||
|
this._emitUpdate("status");
|
||||||
|
const entries = Object.entries(this._attachments);
|
||||||
|
// upload smallest attachments first
|
||||||
|
entries.sort(([, a1], [, a2]) => a1.size - a2.size);
|
||||||
|
for (const [urlPath, attachment] of entries) {
|
||||||
|
await attachment.upload(hsApi, () => {
|
||||||
|
this._emitUpdate("attachmentsSentBytes");
|
||||||
|
});
|
||||||
|
attachment.applyToContent(urlPath, this.content);
|
||||||
|
}
|
||||||
|
this._data.needsUpload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
if (!this._aborted) {
|
||||||
|
this._aborted = true;
|
||||||
|
if (this._attachments) {
|
||||||
|
for (const attachment of Object.values(this._attachments)) {
|
||||||
|
attachment.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._sendRequest?.abort();
|
||||||
|
this._removeFromQueueCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get aborted() {
|
||||||
|
return this._aborted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(hsApi) {
|
||||||
|
console.log(`sending event ${this.eventType} in ${this.roomId}`);
|
||||||
|
this._status = SendStatus.Sending;
|
||||||
|
this._emitUpdate("status");
|
||||||
|
const eventType = this._data.encryptedEventType || this._data.eventType;
|
||||||
|
const content = this._data.encryptedContent || this._data.content;
|
||||||
|
this._sendRequest = hsApi.send(
|
||||||
|
this.roomId,
|
||||||
|
eventType,
|
||||||
|
this.txnId,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
const response = await this._sendRequest.response();
|
||||||
|
this._sendRequest = null;
|
||||||
|
this._data.remoteId = response.event_id;
|
||||||
|
this._status = SendStatus.Sent;
|
||||||
|
this._emitUpdate("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._attachments) {
|
||||||
|
for (const attachment of Object.values(this._attachments)) {
|
||||||
|
attachment.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,13 +29,22 @@ export class SendQueue {
|
|||||||
if (pendingEvents.length) {
|
if (pendingEvents.length) {
|
||||||
console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents);
|
console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents);
|
||||||
}
|
}
|
||||||
this._pendingEvents.setManyUnsorted(pendingEvents.map(data => new PendingEvent(data)));
|
this._pendingEvents.setManyUnsorted(pendingEvents.map(data => this._createPendingEvent(data)));
|
||||||
this._isSending = false;
|
this._isSending = false;
|
||||||
this._offline = false;
|
this._offline = false;
|
||||||
this._amountSent = 0;
|
|
||||||
this._roomEncryption = null;
|
this._roomEncryption = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_createPendingEvent(data, attachments = null) {
|
||||||
|
const pendingEvent = new PendingEvent({
|
||||||
|
data,
|
||||||
|
remove: () => this._removeEvent(pendingEvent),
|
||||||
|
emitUpdate: () => this._pendingEvents.set(pendingEvent),
|
||||||
|
attachments
|
||||||
|
});
|
||||||
|
return pendingEvent;
|
||||||
|
}
|
||||||
|
|
||||||
enableEncryption(roomEncryption) {
|
enableEncryption(roomEncryption) {
|
||||||
this._roomEncryption = roomEncryption;
|
this._roomEncryption = roomEncryption;
|
||||||
}
|
}
|
||||||
@ -43,51 +52,41 @@ export class SendQueue {
|
|||||||
async _sendLoop() {
|
async _sendLoop() {
|
||||||
this._isSending = true;
|
this._isSending = true;
|
||||||
try {
|
try {
|
||||||
console.log("start sending", this._amountSent, "<", this._pendingEvents.length);
|
for (let i = 0; i < this._pendingEvents.length; i += 1) {
|
||||||
while (this._amountSent < this._pendingEvents.length) {
|
const pendingEvent = this._pendingEvents.get(i);
|
||||||
const pendingEvent = this._pendingEvents.get(this._amountSent);
|
|
||||||
console.log("trying to send", pendingEvent.content.body);
|
|
||||||
if (pendingEvent.remoteId) {
|
|
||||||
this._amountSent += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (pendingEvent.attachment) {
|
|
||||||
const {attachment} = pendingEvent;
|
|
||||||
try {
|
try {
|
||||||
await attachment.uploaded();
|
await this._sendEvent(pendingEvent);
|
||||||
} catch (err) {
|
} catch(err) {
|
||||||
console.log("upload failed, skip sending message", pendingEvent);
|
if (err instanceof ConnectionError) {
|
||||||
this._amountSent += 1;
|
this._offline = true;
|
||||||
continue;
|
break;
|
||||||
|
} else {
|
||||||
|
pendingEvent.setError(err);
|
||||||
}
|
}
|
||||||
attachment.applyToContent(pendingEvent.content);
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._isSending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sendEvent(pendingEvent) {
|
||||||
|
if (pendingEvent.needsUpload) {
|
||||||
|
await pendingEvent.uploadAttachments(this._hsApi);
|
||||||
|
console.log("attachments upload, content is now", pendingEvent.content);
|
||||||
|
await this._tryUpdateEvent(pendingEvent);
|
||||||
}
|
}
|
||||||
if (pendingEvent.needsEncryption) {
|
if (pendingEvent.needsEncryption) {
|
||||||
|
pendingEvent.setEncrypting();
|
||||||
const {type, content} = await this._roomEncryption.encrypt(
|
const {type, content} = await this._roomEncryption.encrypt(
|
||||||
pendingEvent.eventType, pendingEvent.content, this._hsApi);
|
pendingEvent.eventType, pendingEvent.content, this._hsApi);
|
||||||
pendingEvent.setEncrypted(type, content);
|
pendingEvent.setEncrypted(type, content);
|
||||||
await this._tryUpdateEvent(pendingEvent);
|
await this._tryUpdateEvent(pendingEvent);
|
||||||
}
|
}
|
||||||
console.log("really sending now");
|
if (pendingEvent.needsSending) {
|
||||||
const response = await this._hsApi.send(
|
await pendingEvent.send(this._hsApi);
|
||||||
pendingEvent.roomId,
|
console.log("writing remoteId");
|
||||||
pendingEvent.eventType,
|
|
||||||
pendingEvent.txnId,
|
|
||||||
pendingEvent.content
|
|
||||||
).response();
|
|
||||||
pendingEvent.remoteId = response.event_id;
|
|
||||||
//
|
|
||||||
console.log("writing remoteId now");
|
|
||||||
await this._tryUpdateEvent(pendingEvent);
|
await this._tryUpdateEvent(pendingEvent);
|
||||||
console.log("keep sending?", this._amountSent, "<", this._pendingEvents.length);
|
|
||||||
this._amountSent += 1;
|
|
||||||
}
|
|
||||||
} catch(err) {
|
|
||||||
if (err instanceof ConnectionError) {
|
|
||||||
this._offline = true;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this._isSending = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,13 +109,28 @@ export class SendQueue {
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _removeEvent(pendingEvent) {
|
||||||
|
const idx = this._pendingEvents.array.indexOf(pendingEvent);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
|
||||||
|
try {
|
||||||
|
txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex);
|
||||||
|
} catch (err) {
|
||||||
|
txn.abort();
|
||||||
|
}
|
||||||
|
await txn.complete();
|
||||||
|
this._pendingEvents.remove(idx);
|
||||||
|
}
|
||||||
|
pendingEvent.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
emitRemovals(pendingEvents) {
|
emitRemovals(pendingEvents) {
|
||||||
for (const pendingEvent of pendingEvents) {
|
for (const pendingEvent of pendingEvents) {
|
||||||
const idx = this._pendingEvents.array.indexOf(pendingEvent);
|
const idx = this._pendingEvents.array.indexOf(pendingEvent);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
this._amountSent -= 1;
|
|
||||||
this._pendingEvents.remove(idx);
|
this._pendingEvents.remove(idx);
|
||||||
}
|
}
|
||||||
|
pendingEvent.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,8 +141,8 @@ export class SendQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueEvent(eventType, content, attachment) {
|
async enqueueEvent(eventType, content, attachments) {
|
||||||
const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment);
|
const pendingEvent = await this._createAndStoreEvent(eventType, content, attachments);
|
||||||
this._pendingEvents.set(pendingEvent);
|
this._pendingEvents.set(pendingEvent);
|
||||||
console.log("added to _pendingEvents set", this._pendingEvents.length);
|
console.log("added to _pendingEvents set", this._pendingEvents.length);
|
||||||
if (!this._isSending && !this._offline) {
|
if (!this._isSending && !this._offline) {
|
||||||
@ -161,7 +175,7 @@ export class SendQueue {
|
|||||||
await txn.complete();
|
await txn.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _createAndStoreEvent(eventType, content, attachment) {
|
async _createAndStoreEvent(eventType, content, attachments) {
|
||||||
console.log("_createAndStoreEvent");
|
console.log("_createAndStoreEvent");
|
||||||
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
|
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
|
||||||
let pendingEvent;
|
let pendingEvent;
|
||||||
@ -171,14 +185,15 @@ export class SendQueue {
|
|||||||
const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0;
|
const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0;
|
||||||
console.log("_createAndStoreEvent got maxQueueIndex", maxQueueIndex);
|
console.log("_createAndStoreEvent got maxQueueIndex", maxQueueIndex);
|
||||||
const queueIndex = maxQueueIndex + 1;
|
const queueIndex = maxQueueIndex + 1;
|
||||||
pendingEvent = new PendingEvent({
|
pendingEvent = this._createPendingEvent({
|
||||||
roomId: this._roomId,
|
roomId: this._roomId,
|
||||||
queueIndex,
|
queueIndex,
|
||||||
eventType,
|
eventType,
|
||||||
content,
|
content,
|
||||||
txnId: makeTxnId(),
|
txnId: makeTxnId(),
|
||||||
needsEncryption: !!this._roomEncryption
|
needsEncryption: !!this._roomEncryption,
|
||||||
}, attachment);
|
needsUpload: !!attachments
|
||||||
|
}, attachments);
|
||||||
console.log("_createAndStoreEvent: adding to pendingEventsStore");
|
console.log("_createAndStoreEvent: adding to pendingEventsStore");
|
||||||
pendingEventsStore.add(pendingEvent.data);
|
pendingEventsStore.add(pendingEvent.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -188,4 +203,10 @@ export class SendQueue {
|
|||||||
await txn.complete();
|
await txn.complete();
|
||||||
return pendingEvent;
|
return pendingEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const pe in this._pendingEvents.array) {
|
||||||
|
pe.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,8 +64,8 @@ export class PendingEventEntry extends BaseEntry {
|
|||||||
return this._pendingEvent.txnId;
|
return this._pendingEvent.txnId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get attachment() {
|
get pendingEvent() {
|
||||||
return this._pendingEvent.attachment;
|
return this._pendingEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyUpdate() {
|
notifyUpdate() {
|
||||||
|
@ -1,3 +1,19 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
import aesjs from "../../../lib/aes-js/index.js";
|
import aesjs from "../../../lib/aes-js/index.js";
|
||||||
import {hkdf} from "../../utils/crypto/hkdf.js";
|
import {hkdf} from "../../utils/crypto/hkdf.js";
|
||||||
import {Platform as ModernPlatform} from "./Platform.js";
|
import {Platform as ModernPlatform} from "./Platform.js";
|
||||||
|
@ -18,6 +18,7 @@ import {createFetchRequest} from "./dom/request/fetch.js";
|
|||||||
import {xhrRequest} from "./dom/request/xhr.js";
|
import {xhrRequest} from "./dom/request/xhr.js";
|
||||||
import {StorageFactory} from "../../matrix/storage/idb/StorageFactory.js";
|
import {StorageFactory} from "../../matrix/storage/idb/StorageFactory.js";
|
||||||
import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionInfoStorage.js";
|
import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionInfoStorage.js";
|
||||||
|
import {SettingsStorage} from "./dom/SettingsStorage.js";
|
||||||
import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js";
|
import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js";
|
||||||
import {RootView} from "./ui/RootView.js";
|
import {RootView} from "./ui/RootView.js";
|
||||||
import {Clock} from "./dom/Clock.js";
|
import {Clock} from "./dom/Clock.js";
|
||||||
@ -28,6 +29,7 @@ import {Crypto} from "./dom/Crypto.js";
|
|||||||
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
|
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
|
||||||
import {WorkerPool} from "./dom/WorkerPool.js";
|
import {WorkerPool} from "./dom/WorkerPool.js";
|
||||||
import {BlobHandle} from "./dom/BlobHandle.js";
|
import {BlobHandle} from "./dom/BlobHandle.js";
|
||||||
|
import {hasReadPixelPermission, ImageHandle} from "./dom/ImageHandle.js";
|
||||||
import {downloadInIframe} from "./dom/download.js";
|
import {downloadInIframe} from "./dom/download.js";
|
||||||
|
|
||||||
function addScript(src) {
|
function addScript(src) {
|
||||||
@ -77,7 +79,6 @@ async function loadOlmWorker(paths) {
|
|||||||
return olmWorker;
|
return olmWorker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class Platform {
|
export class Platform {
|
||||||
constructor(container, paths, cryptoExtras = null) {
|
constructor(container, paths, cryptoExtras = null) {
|
||||||
this._paths = paths;
|
this._paths = paths;
|
||||||
@ -93,6 +94,7 @@ export class Platform {
|
|||||||
this.crypto = new Crypto(cryptoExtras);
|
this.crypto = new Crypto(cryptoExtras);
|
||||||
this.storageFactory = new StorageFactory(this._serviceWorkerHandler);
|
this.storageFactory = new StorageFactory(this._serviceWorkerHandler);
|
||||||
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
|
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
|
||||||
|
this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_");
|
||||||
this.estimateStorageUsage = estimateStorageUsage;
|
this.estimateStorageUsage = estimateStorageUsage;
|
||||||
this.random = Math.random;
|
this.random = Math.random;
|
||||||
if (typeof fetch === "function") {
|
if (typeof fetch === "function") {
|
||||||
@ -156,9 +158,9 @@ export class Platform {
|
|||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
this._container.removeChild(input);
|
this._container.removeChild(input);
|
||||||
if (file) {
|
if (file) {
|
||||||
resolve({name: file.name, blob: BlobHandle.fromFile(file)});
|
resolve({name: file.name, blob: BlobHandle.fromBlob(file)});
|
||||||
} else {
|
} else {
|
||||||
reject(new Error("No file selected"));
|
resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.addEventListener("change", checkFile, true);
|
input.addEventListener("change", checkFile, true);
|
||||||
@ -168,4 +170,16 @@ export class Platform {
|
|||||||
input.click();
|
input.click();
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadImage(blob) {
|
||||||
|
return ImageHandle.fromBlob(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasReadPixelPermission() {
|
||||||
|
return hasReadPixelPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
get devicePixelRatio() {
|
||||||
|
return window.devicePixelRatio || 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,9 +84,9 @@ export class BlobHandle {
|
|||||||
return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer);
|
return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromFile(file) {
|
static fromBlob(blob) {
|
||||||
// ok to not filter mimetypes as these are local files
|
// ok to not filter mimetypes as these are local files
|
||||||
return new BlobHandle(file);
|
return new BlobHandle(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
get nativeBlob() {
|
get nativeBlob() {
|
||||||
|
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 = document.createElement("iframe");
|
||||||
iframe.setAttribute("sandbox", "allow-scripts allow-downloads allow-downloads-without-user-activation");
|
iframe.setAttribute("sandbox", "allow-scripts allow-downloads allow-downloads-without-user-activation");
|
||||||
iframe.setAttribute("src", iframeSrc);
|
iframe.setAttribute("src", iframeSrc);
|
||||||
iframe.className = "downloadSandbox";
|
iframe.className = "hidden";
|
||||||
container.appendChild(iframe);
|
container.appendChild(iframe);
|
||||||
let detach;
|
let detach;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
} from "../../../../matrix/error.js";
|
} from "../../../../matrix/error.js";
|
||||||
import {abortOnTimeout} from "./timeout.js";
|
import {abortOnTimeout} from "./timeout.js";
|
||||||
import {addCacheBuster} from "./common.js";
|
import {addCacheBuster} from "./common.js";
|
||||||
|
import {xhrRequest} from "./xhr.js";
|
||||||
|
|
||||||
class RequestResult {
|
class RequestResult {
|
||||||
constructor(promise, controller) {
|
constructor(promise, controller) {
|
||||||
@ -51,7 +52,12 @@ class RequestResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createFetchRequest(createTimeout) {
|
export function createFetchRequest(createTimeout) {
|
||||||
return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) {
|
return function fetchRequest(url, requestOptions) {
|
||||||
|
// fetch doesn't do upload progress yet, delegate to xhr
|
||||||
|
if (requestOptions?.uploadProgress) {
|
||||||
|
return xhrRequest(url, requestOptions);
|
||||||
|
}
|
||||||
|
let {method, headers, body, timeout, format, cache = false} = requestOptions;
|
||||||
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||||
// if a BlobHandle, take native blob
|
// if a BlobHandle, take native blob
|
||||||
if (body?.nativeBlob) {
|
if (body?.nativeBlob) {
|
||||||
|
@ -35,7 +35,7 @@ class RequestResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(url, {method, headers, timeout, body, format}) {
|
function createXhr(url, {method, headers, timeout, format, uploadProgress}) {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open(method, url);
|
xhr.open(method, url);
|
||||||
|
|
||||||
@ -45,18 +45,20 @@ function send(url, {method, headers, timeout, body, format}) {
|
|||||||
}
|
}
|
||||||
if (headers) {
|
if (headers) {
|
||||||
for(const [name, value] of headers.entries()) {
|
for(const [name, value] of headers.entries()) {
|
||||||
|
try {
|
||||||
xhr.setRequestHeader(name, value);
|
xhr.setRequestHeader(name, value);
|
||||||
|
} catch (err) {
|
||||||
|
console.info(`Could not set ${name} header: ${err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
xhr.timeout = timeout;
|
xhr.timeout = timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if a BlobHandle, take native blob
|
if (uploadProgress) {
|
||||||
if (body?.nativeBlob) {
|
xhr.upload.addEventListener("progress", evt => uploadProgress(evt.loaded));
|
||||||
body = body.nativeBlob;
|
|
||||||
}
|
}
|
||||||
xhr.send(body || null);
|
|
||||||
|
|
||||||
return xhr;
|
return xhr;
|
||||||
}
|
}
|
||||||
@ -71,12 +73,12 @@ function xhrAsPromise(xhr, method, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function xhrRequest(url, options) {
|
export function xhrRequest(url, options) {
|
||||||
const {cache, format} = options;
|
let {cache, format, body, method} = options;
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
url = addCacheBuster(url);
|
url = addCacheBuster(url);
|
||||||
}
|
}
|
||||||
const xhr = send(url, options);
|
const xhr = createXhr(url, options);
|
||||||
const promise = xhrAsPromise(xhr, options.method, url).then(xhr => {
|
const promise = xhrAsPromise(xhr, method, url).then(xhr => {
|
||||||
const {status} = xhr;
|
const {status} = xhr;
|
||||||
let body = null;
|
let body = null;
|
||||||
if (format === "buffer") {
|
if (format === "buffer") {
|
||||||
@ -86,5 +88,12 @@ export function xhrRequest(url, options) {
|
|||||||
}
|
}
|
||||||
return {status, body};
|
return {status, body};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// if a BlobHandle, take native blob
|
||||||
|
if (body?.nativeBlob) {
|
||||||
|
body = body.nativeBlob;
|
||||||
|
}
|
||||||
|
xhr.send(body || null);
|
||||||
|
|
||||||
return new RequestResult(promise, xhr);
|
return new RequestResult(promise, xhr);
|
||||||
}
|
}
|
||||||
|
@ -96,6 +96,8 @@ main {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
/* otherwise we don't get scrollbars and the content grows as large as it can */
|
/* otherwise we don't get scrollbars and the content grows as large as it can */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
/* make popups relative to this element so changing the left panel width doesn't affect their position */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomView {
|
.RoomView {
|
||||||
@ -109,12 +111,11 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lightbox {
|
.lightbox {
|
||||||
/* cover left and middle panel, not status view
|
position: absolute;
|
||||||
use numeric positions because named grid areas
|
top: 0;
|
||||||
are not present in mobile layout */
|
bottom: 0;
|
||||||
grid-area: 2 / 1 / 3 / 3;
|
left: 0;
|
||||||
/* this should not be necessary, but chrome seems to have a bug when there are scrollbars in other grid items,
|
right: 0;
|
||||||
it seems to put the scroll areas on top of the other grid items unless they have a z-index */
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +165,11 @@ main {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.Settings {
|
.Settings {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -49,7 +49,3 @@ body.hydrogen {
|
|||||||
input::-ms-clear {
|
input::-ms-clear {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hydrogen > iframe.downloadSandbox {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
@ -316,6 +316,7 @@ a {
|
|||||||
|
|
||||||
.SessionStatusView button.link {
|
.SessionStatusView button.link {
|
||||||
color: currentcolor;
|
color: currentcolor;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.SessionStatusView > .end {
|
.SessionStatusView > .end {
|
||||||
@ -556,11 +557,17 @@ ul.Timeline > li.messageStatus .message-container > p {
|
|||||||
|
|
||||||
.message-container .picture {
|
.message-container .picture {
|
||||||
display: grid;
|
display: grid;
|
||||||
text-decoration: none;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.message-container .picture > a {
|
||||||
|
text-decoration: none;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* .spacer grows with an inline padding-top to the size of the image,
|
/* .spacer grows with an inline padding-top to the size of the image,
|
||||||
so the timeline doesn't jump when the image loads */
|
so the timeline doesn't jump when the image loads */
|
||||||
.message-container .picture > * {
|
.message-container .picture > * {
|
||||||
@ -568,24 +575,41 @@ so the timeline doesn't jump when the image loads */
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container .picture > img {
|
.message-container .picture img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
/* for IE11 to still scale even though the spacer is too tall */
|
/* for IE11 to still scale even though the spacer is too tall */
|
||||||
align-self: start;
|
align-self: start;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
/* stretch the image (to the spacer) on platforms
|
/* stretch the image (to the spacer) on platforms
|
||||||
where we can trust the spacer to always have the correct height,
|
where we can trust the spacer to always have the correct height,
|
||||||
otherwise the image starts with height 0 and with loading=lazy
|
otherwise the image starts with height 0 and with loading=lazy
|
||||||
only loads when the top comes into view*/
|
only loads when the top comes into view*/
|
||||||
.hydrogen:not(.legacy) .message-container .picture > img {
|
.hydrogen:not(.legacy) .message-container .picture img {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-container .picture > .sendStatus {
|
||||||
|
align-self: end;
|
||||||
|
justify-self: start;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container .picture > progress {
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
.message-container .picture > time {
|
.message-container .picture > time {
|
||||||
align-self: end;
|
align-self: end;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container .picture > time,
|
||||||
|
.message-container .picture > .sendStatus {
|
||||||
color: #2e2f32;
|
color: #2e2f32;
|
||||||
display: block;
|
display: block;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
@ -653,6 +677,7 @@ only loads when the top comes into view*/
|
|||||||
|
|
||||||
.Settings .row .content {
|
.Settings .row .content {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Settings .row.code .content {
|
.Settings .row.code .content {
|
||||||
@ -664,6 +689,12 @@ only loads when the top comes into view*/
|
|||||||
margin: 0 8px;
|
margin: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Settings .row .content input[type=range] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
.Settings .row {
|
.Settings .row {
|
||||||
margin: 4px 0px;
|
margin: 4px 0px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -762,4 +793,31 @@ button.link {
|
|||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 2px 2px 10px rgba(0,0,0,0.5);
|
||||||
|
padding: 4px;
|
||||||
|
background-color: white;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 32px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:focus {
|
||||||
|
background-color: #03B381;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:hover {
|
||||||
|
background-color: #03B381;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
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._render = render;
|
||||||
this._eventListeners = null;
|
this._eventListeners = null;
|
||||||
this._bindings = null;
|
this._bindings = null;
|
||||||
// this should become _subViews and also include templates.
|
|
||||||
// How do we know which ones we should update though?
|
|
||||||
// Wrapper class?
|
|
||||||
this._subViews = null;
|
this._subViews = null;
|
||||||
this._root = null;
|
this._root = null;
|
||||||
this._boundUpdateFromValue = null;
|
this._boundUpdateFromValue = null;
|
||||||
@ -57,7 +54,7 @@ export class TemplateView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_subscribe() {
|
_subscribe() {
|
||||||
if (typeof this._value.on === "function") {
|
if (typeof this._value?.on === "function") {
|
||||||
this._boundUpdateFromValue = this._updateFromValue.bind(this);
|
this._boundUpdateFromValue = this._updateFromValue.bind(this);
|
||||||
this._value.on("change", this._boundUpdateFromValue);
|
this._value.on("change", this._boundUpdateFromValue);
|
||||||
}
|
}
|
||||||
@ -146,12 +143,19 @@ export class TemplateView {
|
|||||||
this._bindings.push(bindingFn);
|
this._bindings.push(bindingFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
_addSubView(view) {
|
addSubView(view) {
|
||||||
if (!this._subViews) {
|
if (!this._subViews) {
|
||||||
this._subViews = [];
|
this._subViews = [];
|
||||||
}
|
}
|
||||||
this._subViews.push(view);
|
this._subViews.push(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeSubView(view) {
|
||||||
|
const idx = this._subViews.indexOf(view);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this._subViews.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// what is passed to render
|
// what is passed to render
|
||||||
@ -288,7 +292,7 @@ class TemplateBuilder {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorToDOM(err);
|
return errorToDOM(err);
|
||||||
}
|
}
|
||||||
this._templateView._addSubView(view);
|
this._templateView.addSubView(view);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,7 +94,7 @@ export const TAG_NAMES = {
|
|||||||
[HTML_NS]: [
|
[HTML_NS]: [
|
||||||
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
|
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
|
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
|
||||||
"pre", "button", "time", "input", "textarea", "label", "form"],
|
"pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"],
|
||||||
[SVG_NS]: ["svg", "circle"]
|
[SVG_NS]: ["svg", "circle"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -15,11 +15,14 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {TemplateView} from "../../general/TemplateView.js";
|
import {TemplateView} from "../../general/TemplateView.js";
|
||||||
|
import {Popup} from "../../general/Popup.js";
|
||||||
|
import {Menu} from "../../general/Menu.js";
|
||||||
|
|
||||||
export class MessageComposer extends TemplateView {
|
export class MessageComposer extends TemplateView {
|
||||||
constructor(viewModel) {
|
constructor(viewModel) {
|
||||||
super(viewModel);
|
super(viewModel);
|
||||||
this._input = null;
|
this._input = null;
|
||||||
|
this._attachmentPopup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
@ -32,8 +35,8 @@ export class MessageComposer extends TemplateView {
|
|||||||
this._input,
|
this._input,
|
||||||
t.button({
|
t.button({
|
||||||
className: "sendFile",
|
className: "sendFile",
|
||||||
title: vm.i18n`Send file`,
|
title: vm.i18n`Pick attachment`,
|
||||||
onClick: () => vm.sendAttachment(),
|
onClick: evt => this._toggleAttachmentMenu(evt),
|
||||||
}, vm.i18n`Send file`),
|
}, vm.i18n`Send file`),
|
||||||
t.button({
|
t.button({
|
||||||
className: "send",
|
className: "send",
|
||||||
@ -56,4 +59,29 @@ export class MessageComposer extends TemplateView {
|
|||||||
this._trySend();
|
this._trySend();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_toggleAttachmentMenu(evt) {
|
||||||
|
if (this._attachmentPopup && this._attachmentPopup.isOpen) {
|
||||||
|
this._attachmentPopup.close();
|
||||||
|
} else {
|
||||||
|
const vm = this.value;
|
||||||
|
this._attachmentPopup = new Popup(new Menu([
|
||||||
|
Menu.option(vm.i18n`Send picture`, () => vm.sendPicture()).setIcon("picture"),
|
||||||
|
Menu.option(vm.i18n`Send file`, () => vm.sendFile()).setIcon("file"),
|
||||||
|
]));
|
||||||
|
this._attachmentPopup.trackInTemplateView(this);
|
||||||
|
this._attachmentPopup.showRelativeTo(evt.target, {
|
||||||
|
horizontal: {
|
||||||
|
relativeTo: "end",
|
||||||
|
align: "start",
|
||||||
|
after: 0
|
||||||
|
},
|
||||||
|
vertical: {
|
||||||
|
relativeTo: "end",
|
||||||
|
align: "start",
|
||||||
|
before: 8,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ import {GapView} from "./timeline/GapView.js";
|
|||||||
import {TextMessageView} from "./timeline/TextMessageView.js";
|
import {TextMessageView} from "./timeline/TextMessageView.js";
|
||||||
import {ImageView} from "./timeline/ImageView.js";
|
import {ImageView} from "./timeline/ImageView.js";
|
||||||
import {FileView} from "./timeline/FileView.js";
|
import {FileView} from "./timeline/FileView.js";
|
||||||
|
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
|
||||||
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
||||||
|
|
||||||
function viewClassForEntry(entry) {
|
function viewClassForEntry(entry) {
|
||||||
@ -30,6 +31,7 @@ function viewClassForEntry(entry) {
|
|||||||
return TextMessageView;
|
return TextMessageView;
|
||||||
case "image": return ImageView;
|
case "image": return ImageView;
|
||||||
case "file": return FileView;
|
case "file": return FileView;
|
||||||
|
case "missing-attachment": return MissingAttachmentView;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,11 +19,17 @@ import {renderMessage} from "./common.js";
|
|||||||
|
|
||||||
export class FileView extends TemplateView {
|
export class FileView extends TemplateView {
|
||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
return renderMessage(t, vm, [
|
if (vm.isPending) {
|
||||||
t.p([
|
return renderMessage(t, vm, t.p([
|
||||||
|
vm => vm.label,
|
||||||
|
" ",
|
||||||
|
t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
return renderMessage(t, vm, t.p([
|
||||||
t.button({className: "link", onClick: () => vm.download()}, vm => vm.label),
|
t.button({className: "link", onClick: () => vm.download()}, vm => vm.label),
|
||||||
t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)
|
t.time(vm.date + " " + vm.time)
|
||||||
])
|
]));
|
||||||
]);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,18 +31,36 @@ export class ImageView extends TemplateView {
|
|||||||
// can slow down rendering, and was bleeding through the lightbox.
|
// can slow down rendering, and was bleeding through the lightbox.
|
||||||
spacerStyle = `height: ${vm.thumbnailHeight}px`;
|
spacerStyle = `height: ${vm.thumbnailHeight}px`;
|
||||||
}
|
}
|
||||||
return renderMessage(t, vm, [
|
const img = t.img({
|
||||||
t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [
|
|
||||||
t.div({className: "spacer", style: spacerStyle}),
|
|
||||||
t.img({
|
|
||||||
loading: "lazy",
|
loading: "lazy",
|
||||||
src: vm => vm.thumbnailUrl,
|
src: vm => vm.thumbnailUrl,
|
||||||
alt: vm => vm.label,
|
alt: vm => vm.label,
|
||||||
title: vm => vm.label,
|
title: vm => vm.label,
|
||||||
style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;`
|
style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;`
|
||||||
}),
|
});
|
||||||
|
const children = [
|
||||||
|
t.div({className: "spacer", style: spacerStyle}),
|
||||||
|
vm.isPending ? img : t.a({href: vm.lightboxUrl}, img),
|
||||||
t.time(vm.date + " " + vm.time),
|
t.time(vm.date + " " + vm.time),
|
||||||
]),
|
];
|
||||||
|
if (vm.isPending) {
|
||||||
|
const cancel = t.button({onClick: () => vm.abortSending(), className: "link"}, vm.i18n`Cancel`);
|
||||||
|
const sendStatus = t.div({
|
||||||
|
className: {
|
||||||
|
sendStatus: true,
|
||||||
|
hidden: vm => !vm.sendStatus
|
||||||
|
},
|
||||||
|
}, [vm => vm.sendStatus, " ", cancel]);
|
||||||
|
const progress = t.progress({
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
value: vm => vm.uploadPercentage,
|
||||||
|
className: {hidden: vm => !vm.isUploading}
|
||||||
|
});
|
||||||
|
children.push(sendStatus, progress);
|
||||||
|
}
|
||||||
|
return renderMessage(t, vm, [
|
||||||
|
t.div({className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, children),
|
||||||
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
|
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
pending: vm.isPending,
|
||||||
unverified: vm.isUnverified,
|
unverified: vm.isUnverified,
|
||||||
continuation: vm => vm.isContinuation,
|
continuation: vm => vm.isContinuation,
|
||||||
messageStatus: vm => vm.shape === "message-status",
|
messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment" || vm.shape === "file",
|
||||||
};
|
};
|
||||||
|
|
||||||
const profile = t.div({className: "profile"}, [
|
const profile = t.div({className: "profile"}, [
|
||||||
|
@ -46,10 +46,32 @@ export class SettingsView extends TemplateView {
|
|||||||
row(vm.i18n`Session key`, vm.fingerprintKey, "code"),
|
row(vm.i18n`Session key`, vm.fingerprintKey, "code"),
|
||||||
t.h3("Session Backup"),
|
t.h3("Session Backup"),
|
||||||
t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)),
|
t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)),
|
||||||
|
t.h3("Preferences"),
|
||||||
|
row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
|
||||||
t.h3("Application"),
|
t.h3("Application"),
|
||||||
row(vm.i18n`Version`, version),
|
row(vm.i18n`Version`, version),
|
||||||
row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
|
row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_imageCompressionRange(t, vm) {
|
||||||
|
const step = 32;
|
||||||
|
const min = Math.ceil(vm.minSentImageSizeLimit / step) * step;
|
||||||
|
const max = (Math.floor(vm.maxSentImageSizeLimit / step) + 1) * step;
|
||||||
|
const updateSetting = evt => vm.setSentImageSizeLimit(parseInt(evt.target.value, 10));
|
||||||
|
return [t.input({
|
||||||
|
type: "range",
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
value: vm => vm.sentImageSizeLimit || max,
|
||||||
|
onInput: updateSetting,
|
||||||
|
onChange: updateSetting,
|
||||||
|
}), " ", t.output(vm => {
|
||||||
|
return vm.sentImageSizeLimit ?
|
||||||
|
vm.i18n`resize to ${vm.sentImageSizeLimit}px` :
|
||||||
|
vm.i18n`no resizing`;
|
||||||
|
})];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user