Merge pull request #196 from vector-im/bwindels/file-uploads

File uploads
This commit is contained in:
Bruno Windels 2020-11-11 12:19:32 +00:00 committed by GitHub
commit 6bd5692517
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 537 additions and 57 deletions

View file

@ -0,0 +1,23 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript">
const bytes = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
const buffer = new Uint8Array(bytes.length);
for (let i = 0; i < buffer.length; i += 1) {
buffer[i] = bytes[i];
}
const blob = new Blob([buffer], {type: "text/plain"});
const reader = new FileReader();
reader.addEventListener("load", function(evt) {
const result = evt.target.result;
console.log("result", result);
});
reader.addEventListener("error", function(evt) {reject(evt.target.error);});
reader.readAsText(blob, "utf-8");
</script>
</body>
</html>

View file

@ -164,6 +164,21 @@ export class RoomViewModel extends ViewModel {
return false;
}
async _sendFile() {
let file;
try {
file = await this.platform.openFile();
} catch (err) {
return;
}
const attachment = this._room.uploadAttachment(file.blob, file.name);
const content = {
body: file.name,
msgtype: "m.file",
};
await this._room.sendEvent("m.room.message", content, attachment);
}
get composerViewModel() {
return this._composerVM;
}
@ -189,6 +204,10 @@ class ComposerViewModel extends ViewModel {
return success;
}
sendAttachment() {
this._roomVM._sendFile();
}
get canSend() {
return !this._isEmpty;
}

View file

@ -23,37 +23,62 @@ export class FileTile extends MessageTile {
super(options);
this._error = null;
this._downloading = false;
if (this._isUploading) {
// should really do this with an ObservableValue and waitFor to prevent leaks when the promise never resolves
this._entry.attachment.uploaded().finally(() => {
if (!this.isDisposed) {
this.emitChange("label");
}
});
}
}
async download() {
if (this._downloading) {
if (this._downloading || this._isUploading) {
return;
}
const content = this._getContent();
const filename = content.body;
this._downloading = true;
this.emitChange("label");
let bufferHandle;
let blob;
try {
bufferHandle = await this._mediaRepository.downloadAttachment(content);
this.platform.offerSaveBufferHandle(bufferHandle, filename);
blob = await this._mediaRepository.downloadAttachment(content);
this.platform.saveFileAs(blob, filename);
} catch (err) {
this._error = err;
} finally {
bufferHandle?.dispose();
blob?.dispose();
this._downloading = false;
}
this.emitChange("label");
}
get size() {
if (this._isUploading) {
return this._entry.attachment.localPreview.size;
} else {
return this._getContent().info?.size;
}
}
get _isUploading() {
return this._entry.attachment && !this._entry.attachment.isUploaded;
}
get label() {
if (this._error) {
return `Could not decrypt file: ${this._error.message}`;
}
if (this._entry.attachment?.error) {
return `Failed to upload: ${this._entry.attachment.error.message}`;
}
const content = this._getContent();
const filename = content.body;
const size = formatSize(content.info?.size);
if (this._downloading) {
const size = formatSize(this.size);
if (this._isUploading) {
return this.i18n`Uploading ${filename} (${size})…`;
} else if (this._downloading) {
return this.i18n`Downloading ${filename} (${size})…`;
} else {
return this.i18n`Download ${filename} (${size})`;

View file

@ -35,12 +35,12 @@ export class ImageTile extends MessageTile {
}
async _loadEncryptedFile(file) {
const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file, true);
const blob = await this._mediaRepository.downloadEncryptedFile(file, true);
if (this.isDisposed) {
bufferHandle.dispose();
blob.dispose();
return;
}
return this.track(bufferHandle);
return this.track(blob);
}
async load() {

View file

@ -362,7 +362,7 @@ export class Session {
pendingEvents,
user: this._user,
createRoomEncryption: this._createRoomEncryption,
clock: this._platform.clock
platform: this._platform
});
this._rooms.add(roomId, room);
return room;

View file

@ -56,3 +56,33 @@ export async function decryptAttachment(crypto, ciphertextBuffer, info) {
});
return decryptedBuffer;
}
export async function encryptAttachment(platform, blob) {
const {crypto} = platform;
const iv = await crypto.aes.generateIV();
const key = await crypto.aes.generateKey("jwk", 256);
const buffer = await blob.readAsBuffer();
const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer});
const digest = await crypto.digest("SHA-256", ciphertext);
return {
blob: platform.createBlob(ciphertext, blob.mimeType),
info: {
v: "v2",
key,
iv: encodeUnpaddedBase64(iv),
hashes: {
sha256: encodeUnpaddedBase64(digest)
}
}
};
}
function encodeUnpaddedBase64(buffer) {
const str = base64.encode(buffer);
const paddingIdx = str.indexOf("=");
if (paddingIdx !== -1) {
return str.substr(0, paddingIdx);
} else {
return str;
}
}

View file

@ -55,6 +55,26 @@ class RequestWrapper {
}
}
function encodeBody(body) {
if (body.nativeBlob && body.mimeType) {
const blob = body;
return {
mimeType: blob.mimeType,
body: blob, // will be unwrapped in request fn
length: blob.size
};
} else if (typeof body === "object") {
const json = JSON.stringify(body);
return {
mimeType: "application/json",
body: json,
length: body.length
};
} else {
throw new Error("Unknown body type: " + body);
}
}
export class HomeServerApi {
constructor({homeServer, accessToken, request, createTimeout, reconnector}) {
// store these both in a closure somehow so it's harder to get at in case of XSS?
@ -73,22 +93,24 @@ export class HomeServerApi {
_baseRequest(method, url, queryParams, body, options, accessToken) {
const queryString = encodeQueryParams(queryParams);
url = `${url}?${queryString}`;
let bodyString;
let encodedBody;
const headers = new Map();
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
headers.set("Accept", "application/json");
if (body) {
headers.set("Content-Type", "application/json");
bodyString = JSON.stringify(body);
const encoded = encodeBody(body);
headers.set("Content-Type", encoded.mimeType);
headers.set("Content-Length", encoded.length);
encodedBody = encoded.body;
}
const requestResult = this._requestFn(url, {
method,
headers,
body: bodyString,
body: encodedBody,
timeout: options?.timeout,
format: "json"
format: "json" // response format
});
const wrapper = new RequestWrapper(method, url, requestResult);
@ -198,6 +220,10 @@ export class HomeServerApi {
roomKeyForRoomAndSession(version, roomId, sessionId, options = null) {
return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options);
}
uploadAttachment(blob, filename, options = null) {
return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options);
}
}
export function tests() {

View file

@ -56,13 +56,13 @@ export class MediaRepository {
const url = this.mxcUrl(fileEntry.url);
const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response();
const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry);
return this._platform.createBufferHandle(decryptedBuffer, fileEntry.mimetype);
return this._platform.createBlob(decryptedBuffer, fileEntry.mimetype);
}
async downloadPlaintextFile(mxcUrl, mimetype, cache = false) {
const url = this.mxcUrl(mxcUrl);
const {body: buffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response();
return this._platform.createBufferHandle(buffer, mimetype);
return this._platform.createBlob(buffer, mimetype);
}
async downloadAttachment(content, cache = false) {
@ -72,5 +72,4 @@ export class MediaRepository {
return this.downloadPlaintextFile(content.url, content.info?.mimetype, cache);
}
}
}

View file

@ -0,0 +1,108 @@
/*
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 {encryptAttachment} from "../e2ee/attachment.js";
export class AttachmentUpload {
constructor({filename, blob, hsApi, platform, isEncrypted}) {
this._filename = filename;
this._unencryptedBlob = blob;
this._isEncrypted = isEncrypted;
this._platform = platform;
this._hsApi = hsApi;
this._mxcUrl = null;
this._transferredBlob = null;
this._encryptionInfo = null;
this._uploadPromise = null;
this._uploadRequest = null;
this._aborted = false;
this._error = null;
}
upload() {
if (!this._uploadPromise) {
this._uploadPromise = this._upload();
}
return this._uploadPromise;
}
async _upload() {
try {
let transferredBlob = this._unencryptedBlob;
if (this._isEncrypted) {
const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob);
transferredBlob = blob;
this._encryptionInfo = info;
}
if (this._aborted) {
return;
}
this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename);
const {content_uri} = await this._uploadRequest.response();
this._mxcUrl = content_uri;
this._transferredBlob = transferredBlob;
} catch (err) {
this._error = err;
throw err;
}
}
get isUploaded() {
return !!this._transferredBlob;
}
/** @public */
abort() {
this._aborted = true;
this._uploadRequest?.abort();
}
/** @public */
get localPreview() {
return this._unencryptedBlob;
}
get error() {
return this._error;
}
/** @package */
uploaded() {
if (!this._uploadPromise) {
throw new Error("upload has not started yet");
}
return this._uploadPromise;
}
/** @package */
applyToContent(content) {
if (!this._mxcUrl) {
throw new Error("upload has not finished");
}
content.info = {
size: this._transferredBlob.size,
mimetype: this._unencryptedBlob.mimeType,
};
if (this._isEncrypted) {
content.file = Object.assign(this._encryptionInfo, {
mimetype: this._unencryptedBlob.mimeType,
url: this._mxcUrl
});
} else {
content.url = this._mxcUrl;
}
}
}

View file

@ -30,11 +30,13 @@ import {EventEntry} from "./timeline/entries/EventEntry.js";
import {EventKey} from "./timeline/EventKey.js";
import {Direction} from "./timeline/Direction.js";
import {ObservedEventMap} from "./ObservedEventMap.js";
import {AttachmentUpload} from "./AttachmentUpload.js";
import {DecryptionSource} from "../e2ee/common.js";
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
export class Room extends EventEmitter {
constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, clock}) {
constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, platform}) {
super();
this._roomId = roomId;
this._storage = storage;
@ -52,7 +54,7 @@ export class Room extends EventEmitter {
this._createRoomEncryption = createRoomEncryption;
this._roomEncryption = null;
this._getSyncToken = getSyncToken;
this._clock = clock;
this._platform = platform;
this._observedEvents = null;
}
@ -350,10 +352,11 @@ export class Room extends EventEmitter {
}
/** @public */
sendEvent(eventType, content) {
return this._sendQueue.enqueueEvent(eventType, content);
sendEvent(eventType, content, attachment) {
return this._sendQueue.enqueueEvent(eventType, content, attachment);
}
/** @public */
async ensureMessageKeyIsShared() {
return this._roomEncryption?.ensureMessageKeyIsShared(this._hsApi);
}
@ -569,7 +572,7 @@ export class Room extends EventEmitter {
}
},
user: this._user,
clock: this._clock
clock: this._platform.clock
});
if (this._roomEncryption) {
this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
@ -630,6 +633,13 @@ export class Room extends EventEmitter {
}
}
uploadAttachment(blob, filename) {
const attachment = new AttachmentUpload({blob, filename,
hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted});
attachment.upload();
return attachment;
}
dispose() {
this._roomEncryption?.dispose();
this._timeline?.dispose();

View file

@ -15,8 +15,9 @@ limitations under the License.
*/
export class PendingEvent {
constructor(data) {
constructor(data, attachment) {
this._data = data;
this.attachment = attachment;
}
get roomId() { return this._data.roomId; }

View file

@ -51,6 +51,17 @@ export class SendQueue {
this._amountSent += 1;
continue;
}
if (pendingEvent.attachment) {
const {attachment} = pendingEvent;
try {
await attachment.uploaded();
} catch (err) {
console.log("upload failed, skip sending message", pendingEvent);
this._amountSent += 1;
continue;
}
attachment.applyToContent(pendingEvent.content);
}
if (pendingEvent.needsEncryption) {
const {type, content} = await this._roomEncryption.encrypt(
pendingEvent.eventType, pendingEvent.content, this._hsApi);
@ -116,8 +127,8 @@ export class SendQueue {
}
}
async enqueueEvent(eventType, content) {
const pendingEvent = await this._createAndStoreEvent(eventType, content);
async enqueueEvent(eventType, content, attachment) {
const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment);
this._pendingEvents.set(pendingEvent);
console.log("added to _pendingEvents set", this._pendingEvents.length);
if (!this._isSending && !this._offline) {
@ -150,7 +161,7 @@ export class SendQueue {
await txn.complete();
}
async _createAndStoreEvent(eventType, content) {
async _createAndStoreEvent(eventType, content, attachment) {
console.log("_createAndStoreEvent");
const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
let pendingEvent;
@ -167,7 +178,7 @@ export class SendQueue {
content,
txnId: makeTxnId(),
needsEncryption: !!this._roomEncryption
});
}, attachment);
console.log("_createAndStoreEvent: adding to pendingEventsStore");
pendingEventsStore.add(pendingEvent.data);
} catch (err) {

View file

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

View file

@ -27,7 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js";
import {Crypto} from "./dom/Crypto.js";
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
import {WorkerPool} from "./dom/WorkerPool.js";
import {BufferHandle} from "./dom/BufferHandle.js";
import {BlobHandle} from "./dom/BlobHandle.js";
import {downloadInIframe} from "./dom/download.js";
function addScript(src) {
@ -131,15 +131,41 @@ export class Platform {
this._serviceWorkerHandler?.setNavigation(navigation);
}
createBufferHandle(buffer, mimetype) {
return new BufferHandle(buffer, mimetype);
createBlob(buffer, mimetype) {
return BlobHandle.fromBuffer(buffer, mimetype);
}
offerSaveBufferHandle(bufferHandle, filename) {
saveFileAs(blobHandle, filename) {
if (navigator.msSaveBlob) {
navigator.msSaveBlob(bufferHandle.blob, filename);
navigator.msSaveBlob(blobHandle.nativeBlob, filename);
} else {
downloadInIframe(this._container, this._paths.downloadSandbox, bufferHandle.blob, filename);
downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle.nativeBlob, filename);
}
}
openFile(mimeType = null) {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.className = "hidden";
if (mimeType) {
input.setAttribute("accept", mimeType);
}
const promise = new Promise((resolve, reject) => {
const checkFile = () => {
input.removeEventListener("change", checkFile, true);
const file = input.files[0];
this._container.removeChild(input);
if (file) {
resolve({name: file.name, blob: BlobHandle.fromFile(file)});
} else {
reject(new Error("No file selected"));
}
}
input.addEventListener("change", checkFile, true);
});
// IE11 needs the input to be attached to the document
this._container.appendChild(input);
input.click();
return promise;
}
}

View file

@ -69,23 +69,73 @@ const ALLOWED_BLOB_MIMETYPES = {
'audio/x-flac': true,
};
export class BufferHandle {
constructor(buffer, mimetype) {
export class BlobHandle {
constructor(blob, buffer = null) {
this._blob = blob;
this._buffer = buffer;
this._url = null;
}
static fromBuffer(buffer, mimetype) {
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
mimetype = 'application/octet-stream';
}
this.blob = new Blob([buffer], {type: mimetype});
this._url = null;
return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer);
}
static fromFile(file) {
// ok to not filter mimetypes as these are local files
return new BlobHandle(file);
}
get nativeBlob() {
return this._blob;
}
async readAsBuffer() {
if (this._buffer) {
return this._buffer;
} else {
const reader = new FileReader();
const promise = new Promise((resolve, reject) => {
reader.addEventListener("load", evt => resolve(evt.target.result));
reader.addEventListener("error", evt => reject(evt.target.error));
});
reader.readAsArrayBuffer(this._blob);
return promise;
}
}
async readAsText() {
if (this._buffer) {
return this._buffer;
} else {
const reader = new FileReader();
const promise = new Promise((resolve, reject) => {
reader.addEventListener("load", evt => resolve(evt.target.result));
reader.addEventListener("error", evt => reject(evt.target.error));
});
reader.readAsText(this._blob, "utf-8");
return promise;
}
}
get url() {
if (!this._url) {
this._url = URL.createObjectURL(this.blob);
this._url = URL.createObjectURL(this._blob);
}
return this._url;
}
get size() {
return this._blob.size;
}
get mimeType() {
return this._blob.type;
}
dispose() {
if (this._url) {
URL.revokeObjectURL(this._url);

View file

@ -153,8 +153,9 @@ class DeriveCrypto {
}
class AESCrypto {
constructor(subtleCrypto) {
constructor(subtleCrypto, crypto) {
this._subtleCrypto = subtleCrypto;
this._crypto = crypto;
}
/**
* [decrypt description]
@ -197,14 +198,116 @@ class AESCrypto {
throw new Error(`Could not decrypt with AES-CTR: ${err.message}`);
}
}
async encryptCTR({key, jwkKey, iv, data}) {
const opts = {
name: "AES-CTR",
counter: iv,
length: 64,
};
let aesKey;
const selectedKey = key || jwkKey;
const format = jwkKey ? "jwk" : "raw";
try {
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
format,
selectedKey,
opts,
false,
['encrypt'],
), "importKey");
} catch (err) {
throw new Error(`Could not import key for AES-CTR encryption: ${err.message}`);
}
try {
const ciphertext = await subtleCryptoResult(this._subtleCrypto.encrypt(
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
opts,
aesKey,
data,
), "encrypt");
return new Uint8Array(ciphertext);
} catch (err) {
throw new Error(`Could not encrypt with AES-CTR: ${err.message}`);
}
}
/**
* Generate a CTR key
* @param {String} format "raw" or "jwk"
* @param {Number} length 128 or 256
* @return {Promise<Object>} an object for jwk, or a BufferSource for raw
*/
async generateKey(format, length = 256) {
const cryptoKey = await subtleCryptoResult(this._subtleCrypto.generateKey(
{"name": "AES-CTR", length}, true, ["encrypt", "decrypt"]));
return subtleCryptoResult(this._subtleCrypto.exportKey(format, cryptoKey));
}
async generateIV() {
return generateIV(this._crypto);
}
}
function generateIV(crypto) {
const randomBytes = crypto.getRandomValues(new Uint8Array(8));
const ivArray = new Uint8Array(16);
for (let i = 0; i < randomBytes.length; i += 1) {
ivArray[i] = randomBytes[i];
}
return ivArray;
}
function jwkKeyToRaw(jwkKey) {
if (jwkKey.alg !== "A256CTR") {
throw new Error(`Unknown algorithm: ${jwkKey.alg}`);
}
if (!jwkKey.key_ops.includes("decrypt")) {
throw new Error(`decrypt missing from key_ops`);
}
if (jwkKey.kty !== "oct") {
throw new Error(`Invalid key type, "oct" expected: ${jwkKey.kty}`);
}
// convert base64-url to normal base64
const base64UrlKey = jwkKey.k;
const base64Key = base64UrlKey.replace(/-/g, "+").replace(/_/g, "/");
return base64.decode(base64Key);
}
function encodeUnpaddedBase64(buffer) {
const str = base64.encode(buffer);
const paddingIdx = str.indexOf("=");
if (paddingIdx !== -1) {
return str.substr(0, paddingIdx);
} else {
return str;
}
}
function encodeUrlBase64(buffer) {
const unpadded = encodeUnpaddedBase64(buffer);
return unpadded.replace(/\+/g, "-").replace(/\//g, "_");
}
function rawKeyToJwk(key) {
return {
"alg": "A256CTR",
"ext": true,
"k": encodeUrlBase64(key),
"key_ops": [
"encrypt",
"decrypt"
],
"kty": "oct"
};
}
import base64 from "../../../../lib/base64-arraybuffer/index.js";
class AESLegacyCrypto {
constructor(aesjs) {
constructor(aesjs, crypto) {
this._aesjs = aesjs;
this._crypto = crypto;
}
/**
* [decrypt description]
@ -219,24 +322,39 @@ class AESLegacyCrypto {
throw new Error(`Unsupported counter length: ${counterLength}`);
}
if (jwkKey) {
if (jwkKey.alg !== "A256CTR") {
throw new Error(`Unknown algorithm: ${jwkKey.alg}`);
}
if (!jwkKey.key_ops.includes("decrypt")) {
throw new Error(`decrypt missing from key_ops`);
}
if (jwkKey.kty !== "oct") {
throw new Error(`Invalid key type, "oct" expected: ${jwkKey.kty}`);
}
// convert base64-url to normal base64
const base64UrlKey = jwkKey.k;
const base64Key = base64UrlKey.replace(/-/g, "+").replace(/_/g, "/");
key = base64.decode(base64Key);
key = jwkKeyToRaw(jwkKey);
}
const aesjs = this._aesjs;
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
return aesCtr.decrypt(new Uint8Array(data));
}
async encryptCTR({key, jwkKey, iv, data}) {
if (jwkKey) {
key = jwkKeyToRaw(jwkKey);
}
const aesjs = this._aesjs;
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
return aesCtr.encrypt(new Uint8Array(data));
}
/**
* Generate a CTR key
* @param {String} format "raw" or "jwk"
* @param {Number} length 128 or 256
* @return {Promise<Object>} an object for jwk, or a BufferSource for raw
*/
async generateKey(format, length = 256) {
let key = crypto.getRandomValues(new Uint8Array(length / 8));
if (format === "jwk") {
key = rawKeyToJwk(key);
}
return key;
}
async generateIV() {
return generateIV(this._crypto);
}
}
function hashName(name) {
@ -254,9 +372,9 @@ export class Crypto {
// not exactly guaranteeing AES-CTR support
// but in practice IE11 doesn't have this
if (!subtleCrypto.deriveBits && cryptoExtras?.aesjs) {
this.aes = new AESLegacyCrypto(cryptoExtras.aesjs);
this.aes = new AESLegacyCrypto(cryptoExtras.aesjs, crypto);
} else {
this.aes = new AESCrypto(subtleCrypto);
this.aes = new AESCrypto(subtleCrypto, crypto);
}
this.hmac = new HMACCrypto(subtleCrypto);
this.derive = new DeriveCrypto(subtleCrypto, this, cryptoExtras);

View file

@ -53,6 +53,10 @@ class RequestResult {
export function createFetchRequest(createTimeout) {
return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) {
const controller = typeof AbortController === "function" ? new AbortController() : null;
// if a BlobHandle, take native blob
if (body?.nativeBlob) {
body = body.nativeBlob;
}
let options = {method, body};
if (controller) {
options = Object.assign(options, {

View file

@ -52,6 +52,10 @@ function send(url, {method, headers, timeout, body, format}) {
xhr.timeout = timeout;
}
// if a BlobHandle, take native blob
if (body?.nativeBlob) {
body = body.nativeBlob;
}
xhr.send(body || null);
return xhr;

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.099 9.51084L10.4407 17.1692C8.48696 19.1229 5.31938 19.1229 3.36567 17.1692C1.41196 15.2155 1.41196 12.0479 3.36567 10.0942L11.024 2.43584C12.3265 1.13337 14.4382 1.13337 15.7407 2.43584C17.0431 3.73831 17.0431 5.85003 15.7407 7.1525L8.074 14.8108C7.42277 15.4621 6.36691 15.4621 5.71567 14.8108C5.06444 14.1596 5.06444 13.1037 5.71567 12.4525L12.7907 5.38584" stroke="#61708B" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View file

@ -470,6 +470,20 @@ a {
background-position: center;
}
.MessageComposer > button.sendFile {
width: 32px;
height: 32px;
display: block;
border: none;
text-indent: 200%;
white-space: nowrap;
overflow: hidden;
background-color: transparent;
background-image: url('icons/paperclip.svg');
background-repeat: no-repeat;
background-position: center;
}
.MessageComposer > button.send:disabled {
background-color: #E3E8F0;
}

View file

@ -30,6 +30,11 @@ export class MessageComposer extends TemplateView {
});
return t.div({className: "MessageComposer"}, [
this._input,
t.button({
className: "sendFile",
title: vm.i18n`Send file`,
onClick: () => vm.sendAttachment(),
}, vm.i18n`Send file`),
t.button({
className: "send",
title: vm.i18n`Send`,