Merge pull request #196 from vector-im/bwindels/file-uploads
File uploads
This commit is contained in:
commit
6bd5692517
21 changed files with 537 additions and 57 deletions
23
prototypes/ie11-textdecoder.html
Normal file
23
prototypes/ie11-textdecoder.html
Normal 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>
|
|
@ -164,6 +164,21 @@ export class RoomViewModel extends ViewModel {
|
||||||
return false;
|
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() {
|
get composerViewModel() {
|
||||||
return this._composerVM;
|
return this._composerVM;
|
||||||
}
|
}
|
||||||
|
@ -189,6 +204,10 @@ class ComposerViewModel extends ViewModel {
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendAttachment() {
|
||||||
|
this._roomVM._sendFile();
|
||||||
|
}
|
||||||
|
|
||||||
get canSend() {
|
get canSend() {
|
||||||
return !this._isEmpty;
|
return !this._isEmpty;
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,37 +23,62 @@ export class FileTile extends MessageTile {
|
||||||
super(options);
|
super(options);
|
||||||
this._error = null;
|
this._error = 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) {
|
if (this._downloading || this._isUploading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = this._getContent();
|
const content = this._getContent();
|
||||||
const filename = content.body;
|
const filename = content.body;
|
||||||
this._downloading = true;
|
this._downloading = true;
|
||||||
this.emitChange("label");
|
this.emitChange("label");
|
||||||
let bufferHandle;
|
let blob;
|
||||||
try {
|
try {
|
||||||
bufferHandle = await this._mediaRepository.downloadAttachment(content);
|
blob = await this._mediaRepository.downloadAttachment(content);
|
||||||
this.platform.offerSaveBufferHandle(bufferHandle, filename);
|
this.platform.saveFileAs(blob, filename);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._error = err;
|
this._error = err;
|
||||||
} finally {
|
} finally {
|
||||||
bufferHandle?.dispose();
|
blob?.dispose();
|
||||||
this._downloading = false;
|
this._downloading = false;
|
||||||
}
|
}
|
||||||
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._error) {
|
||||||
return `Could not decrypt file: ${this._error.message}`;
|
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 content = this._getContent();
|
||||||
const filename = content.body;
|
const filename = content.body;
|
||||||
const size = formatSize(content.info?.size);
|
const size = formatSize(this.size);
|
||||||
if (this._downloading) {
|
if (this._isUploading) {
|
||||||
|
return this.i18n`Uploading ${filename} (${size})…`;
|
||||||
|
} else 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})`;
|
||||||
|
|
|
@ -35,12 +35,12 @@ export class ImageTile extends MessageTile {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadEncryptedFile(file) {
|
async _loadEncryptedFile(file) {
|
||||||
const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file, true);
|
const blob = await this._mediaRepository.downloadEncryptedFile(file, true);
|
||||||
if (this.isDisposed) {
|
if (this.isDisposed) {
|
||||||
bufferHandle.dispose();
|
blob.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return this.track(bufferHandle);
|
return this.track(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
|
|
@ -362,7 +362,7 @@ export class Session {
|
||||||
pendingEvents,
|
pendingEvents,
|
||||||
user: this._user,
|
user: this._user,
|
||||||
createRoomEncryption: this._createRoomEncryption,
|
createRoomEncryption: this._createRoomEncryption,
|
||||||
clock: this._platform.clock
|
platform: this._platform
|
||||||
});
|
});
|
||||||
this._rooms.add(roomId, room);
|
this._rooms.add(roomId, room);
|
||||||
return room;
|
return room;
|
||||||
|
|
|
@ -56,3 +56,33 @@ export async function decryptAttachment(crypto, ciphertextBuffer, info) {
|
||||||
});
|
});
|
||||||
return decryptedBuffer;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
export class HomeServerApi {
|
||||||
constructor({homeServer, accessToken, request, createTimeout, reconnector}) {
|
constructor({homeServer, accessToken, request, createTimeout, reconnector}) {
|
||||||
// store these both in a closure somehow so it's harder to get at in case of XSS?
|
// 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) {
|
_baseRequest(method, url, queryParams, body, options, accessToken) {
|
||||||
const queryString = encodeQueryParams(queryParams);
|
const queryString = encodeQueryParams(queryParams);
|
||||||
url = `${url}?${queryString}`;
|
url = `${url}?${queryString}`;
|
||||||
let bodyString;
|
let encodedBody;
|
||||||
const headers = new Map();
|
const headers = new Map();
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
}
|
}
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
if (body) {
|
if (body) {
|
||||||
headers.set("Content-Type", "application/json");
|
const encoded = encodeBody(body);
|
||||||
bodyString = JSON.stringify(body);
|
headers.set("Content-Type", encoded.mimeType);
|
||||||
|
headers.set("Content-Length", encoded.length);
|
||||||
|
encodedBody = encoded.body;
|
||||||
}
|
}
|
||||||
const requestResult = this._requestFn(url, {
|
const requestResult = this._requestFn(url, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: bodyString,
|
body: encodedBody,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
format: "json"
|
format: "json" // response format
|
||||||
});
|
});
|
||||||
|
|
||||||
const wrapper = new RequestWrapper(method, url, requestResult);
|
const wrapper = new RequestWrapper(method, url, requestResult);
|
||||||
|
@ -198,6 +220,10 @@ export class HomeServerApi {
|
||||||
roomKeyForRoomAndSession(version, roomId, sessionId, options = null) {
|
roomKeyForRoomAndSession(version, roomId, sessionId, options = null) {
|
||||||
return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options);
|
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() {
|
export function tests() {
|
||||||
|
|
|
@ -56,13 +56,13 @@ export class MediaRepository {
|
||||||
const url = this.mxcUrl(fileEntry.url);
|
const url = this.mxcUrl(fileEntry.url);
|
||||||
const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response();
|
const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response();
|
||||||
const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry);
|
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) {
|
async downloadPlaintextFile(mxcUrl, mimetype, cache = false) {
|
||||||
const url = this.mxcUrl(mxcUrl);
|
const url = this.mxcUrl(mxcUrl);
|
||||||
const {body: buffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response();
|
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) {
|
async downloadAttachment(content, cache = false) {
|
||||||
|
@ -72,5 +72,4 @@ export class MediaRepository {
|
||||||
return this.downloadPlaintextFile(content.url, content.info?.mimetype, cache);
|
return this.downloadPlaintextFile(content.url, content.info?.mimetype, cache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
108
src/matrix/room/AttachmentUpload.js
Normal file
108
src/matrix/room/AttachmentUpload.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,11 +30,13 @@ import {EventEntry} from "./timeline/entries/EventEntry.js";
|
||||||
import {EventKey} from "./timeline/EventKey.js";
|
import {EventKey} from "./timeline/EventKey.js";
|
||||||
import {Direction} from "./timeline/Direction.js";
|
import {Direction} from "./timeline/Direction.js";
|
||||||
import {ObservedEventMap} from "./ObservedEventMap.js";
|
import {ObservedEventMap} from "./ObservedEventMap.js";
|
||||||
|
import {AttachmentUpload} from "./AttachmentUpload.js";
|
||||||
import {DecryptionSource} from "../e2ee/common.js";
|
import {DecryptionSource} from "../e2ee/common.js";
|
||||||
|
|
||||||
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
|
|
||||||
export class Room extends EventEmitter {
|
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();
|
super();
|
||||||
this._roomId = roomId;
|
this._roomId = roomId;
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
|
@ -52,7 +54,7 @@ export class Room extends EventEmitter {
|
||||||
this._createRoomEncryption = createRoomEncryption;
|
this._createRoomEncryption = createRoomEncryption;
|
||||||
this._roomEncryption = null;
|
this._roomEncryption = null;
|
||||||
this._getSyncToken = getSyncToken;
|
this._getSyncToken = getSyncToken;
|
||||||
this._clock = clock;
|
this._platform = platform;
|
||||||
this._observedEvents = null;
|
this._observedEvents = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -350,10 +352,11 @@ export class Room extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
sendEvent(eventType, content) {
|
sendEvent(eventType, content, attachment) {
|
||||||
return this._sendQueue.enqueueEvent(eventType, content);
|
return this._sendQueue.enqueueEvent(eventType, content, attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
async ensureMessageKeyIsShared() {
|
async ensureMessageKeyIsShared() {
|
||||||
return this._roomEncryption?.ensureMessageKeyIsShared(this._hsApi);
|
return this._roomEncryption?.ensureMessageKeyIsShared(this._hsApi);
|
||||||
}
|
}
|
||||||
|
@ -569,7 +572,7 @@ export class Room extends EventEmitter {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
user: this._user,
|
user: this._user,
|
||||||
clock: this._clock
|
clock: this._platform.clock
|
||||||
});
|
});
|
||||||
if (this._roomEncryption) {
|
if (this._roomEncryption) {
|
||||||
this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
|
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() {
|
dispose() {
|
||||||
this._roomEncryption?.dispose();
|
this._roomEncryption?.dispose();
|
||||||
this._timeline?.dispose();
|
this._timeline?.dispose();
|
||||||
|
|
|
@ -15,8 +15,9 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class PendingEvent {
|
export class PendingEvent {
|
||||||
constructor(data) {
|
constructor(data, attachment) {
|
||||||
this._data = data;
|
this._data = data;
|
||||||
|
this.attachment = attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
get roomId() { return this._data.roomId; }
|
get roomId() { return this._data.roomId; }
|
||||||
|
|
|
@ -51,6 +51,17 @@ export class SendQueue {
|
||||||
this._amountSent += 1;
|
this._amountSent += 1;
|
||||||
continue;
|
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) {
|
if (pendingEvent.needsEncryption) {
|
||||||
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);
|
||||||
|
@ -116,8 +127,8 @@ export class SendQueue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueEvent(eventType, content) {
|
async enqueueEvent(eventType, content, attachment) {
|
||||||
const pendingEvent = await this._createAndStoreEvent(eventType, content);
|
const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment);
|
||||||
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) {
|
||||||
|
@ -150,7 +161,7 @@ export class SendQueue {
|
||||||
await txn.complete();
|
await txn.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _createAndStoreEvent(eventType, content) {
|
async _createAndStoreEvent(eventType, content, attachment) {
|
||||||
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;
|
||||||
|
@ -167,7 +178,7 @@ export class SendQueue {
|
||||||
content,
|
content,
|
||||||
txnId: makeTxnId(),
|
txnId: makeTxnId(),
|
||||||
needsEncryption: !!this._roomEncryption
|
needsEncryption: !!this._roomEncryption
|
||||||
});
|
}, attachment);
|
||||||
console.log("_createAndStoreEvent: adding to pendingEventsStore");
|
console.log("_createAndStoreEvent: adding to pendingEventsStore");
|
||||||
pendingEventsStore.add(pendingEvent.data);
|
pendingEventsStore.add(pendingEvent.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -64,6 +64,10 @@ export class PendingEventEntry extends BaseEntry {
|
||||||
return this._pendingEvent.txnId;
|
return this._pendingEvent.txnId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get attachment() {
|
||||||
|
return this._pendingEvent.attachment;
|
||||||
|
}
|
||||||
|
|
||||||
notifyUpdate() {
|
notifyUpdate() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js";
|
||||||
import {Crypto} from "./dom/Crypto.js";
|
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 {BufferHandle} from "./dom/BufferHandle.js";
|
import {BlobHandle} from "./dom/BlobHandle.js";
|
||||||
import {downloadInIframe} from "./dom/download.js";
|
import {downloadInIframe} from "./dom/download.js";
|
||||||
|
|
||||||
function addScript(src) {
|
function addScript(src) {
|
||||||
|
@ -131,15 +131,41 @@ export class Platform {
|
||||||
this._serviceWorkerHandler?.setNavigation(navigation);
|
this._serviceWorkerHandler?.setNavigation(navigation);
|
||||||
}
|
}
|
||||||
|
|
||||||
createBufferHandle(buffer, mimetype) {
|
createBlob(buffer, mimetype) {
|
||||||
return new BufferHandle(buffer, mimetype);
|
return BlobHandle.fromBuffer(buffer, mimetype);
|
||||||
}
|
}
|
||||||
|
|
||||||
offerSaveBufferHandle(bufferHandle, filename) {
|
saveFileAs(blobHandle, filename) {
|
||||||
if (navigator.msSaveBlob) {
|
if (navigator.msSaveBlob) {
|
||||||
navigator.msSaveBlob(bufferHandle.blob, filename);
|
navigator.msSaveBlob(blobHandle.nativeBlob, filename);
|
||||||
} else {
|
} 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,23 +69,73 @@ const ALLOWED_BLOB_MIMETYPES = {
|
||||||
'audio/x-flac': true,
|
'audio/x-flac': true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class BufferHandle {
|
export class BlobHandle {
|
||||||
constructor(buffer, mimetype) {
|
constructor(blob, buffer = null) {
|
||||||
|
this._blob = blob;
|
||||||
|
this._buffer = buffer;
|
||||||
|
this._url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromBuffer(buffer, mimetype) {
|
||||||
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
|
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
|
||||||
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
|
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
|
||||||
mimetype = 'application/octet-stream';
|
mimetype = 'application/octet-stream';
|
||||||
}
|
}
|
||||||
this.blob = new Blob([buffer], {type: mimetype});
|
return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer);
|
||||||
this._url = null;
|
}
|
||||||
|
|
||||||
|
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() {
|
get url() {
|
||||||
if (!this._url) {
|
if (!this._url) {
|
||||||
this._url = URL.createObjectURL(this.blob);
|
this._url = URL.createObjectURL(this._blob);
|
||||||
}
|
}
|
||||||
return this._url;
|
return this._url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this._blob.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType() {
|
||||||
|
return this._blob.type;
|
||||||
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this._url) {
|
if (this._url) {
|
||||||
URL.revokeObjectURL(this._url);
|
URL.revokeObjectURL(this._url);
|
|
@ -153,8 +153,9 @@ class DeriveCrypto {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AESCrypto {
|
class AESCrypto {
|
||||||
constructor(subtleCrypto) {
|
constructor(subtleCrypto, crypto) {
|
||||||
this._subtleCrypto = subtleCrypto;
|
this._subtleCrypto = subtleCrypto;
|
||||||
|
this._crypto = crypto;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* [decrypt description]
|
* [decrypt description]
|
||||||
|
@ -197,14 +198,116 @@ class AESCrypto {
|
||||||
throw new Error(`Could not decrypt with AES-CTR: ${err.message}`);
|
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";
|
import base64 from "../../../../lib/base64-arraybuffer/index.js";
|
||||||
|
|
||||||
class AESLegacyCrypto {
|
class AESLegacyCrypto {
|
||||||
constructor(aesjs) {
|
constructor(aesjs, crypto) {
|
||||||
this._aesjs = aesjs;
|
this._aesjs = aesjs;
|
||||||
|
this._crypto = crypto;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* [decrypt description]
|
* [decrypt description]
|
||||||
|
@ -219,24 +322,39 @@ class AESLegacyCrypto {
|
||||||
throw new Error(`Unsupported counter length: ${counterLength}`);
|
throw new Error(`Unsupported counter length: ${counterLength}`);
|
||||||
}
|
}
|
||||||
if (jwkKey) {
|
if (jwkKey) {
|
||||||
if (jwkKey.alg !== "A256CTR") {
|
key = jwkKeyToRaw(jwkKey);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
const aesjs = this._aesjs;
|
const aesjs = this._aesjs;
|
||||||
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
|
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
|
||||||
return aesCtr.decrypt(new Uint8Array(data));
|
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) {
|
function hashName(name) {
|
||||||
|
@ -254,9 +372,9 @@ export class Crypto {
|
||||||
// not exactly guaranteeing AES-CTR support
|
// not exactly guaranteeing AES-CTR support
|
||||||
// but in practice IE11 doesn't have this
|
// but in practice IE11 doesn't have this
|
||||||
if (!subtleCrypto.deriveBits && cryptoExtras?.aesjs) {
|
if (!subtleCrypto.deriveBits && cryptoExtras?.aesjs) {
|
||||||
this.aes = new AESLegacyCrypto(cryptoExtras.aesjs);
|
this.aes = new AESLegacyCrypto(cryptoExtras.aesjs, crypto);
|
||||||
} else {
|
} else {
|
||||||
this.aes = new AESCrypto(subtleCrypto);
|
this.aes = new AESCrypto(subtleCrypto, crypto);
|
||||||
}
|
}
|
||||||
this.hmac = new HMACCrypto(subtleCrypto);
|
this.hmac = new HMACCrypto(subtleCrypto);
|
||||||
this.derive = new DeriveCrypto(subtleCrypto, this, cryptoExtras);
|
this.derive = new DeriveCrypto(subtleCrypto, this, cryptoExtras);
|
||||||
|
|
|
@ -53,6 +53,10 @@ 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, {method, headers, body, timeout, format, cache = false}) {
|
||||||
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||||
|
// if a BlobHandle, take native blob
|
||||||
|
if (body?.nativeBlob) {
|
||||||
|
body = body.nativeBlob;
|
||||||
|
}
|
||||||
let options = {method, body};
|
let options = {method, body};
|
||||||
if (controller) {
|
if (controller) {
|
||||||
options = Object.assign(options, {
|
options = Object.assign(options, {
|
||||||
|
|
|
@ -52,6 +52,10 @@ function send(url, {method, headers, timeout, body, format}) {
|
||||||
xhr.timeout = timeout;
|
xhr.timeout = timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if a BlobHandle, take native blob
|
||||||
|
if (body?.nativeBlob) {
|
||||||
|
body = body.nativeBlob;
|
||||||
|
}
|
||||||
xhr.send(body || null);
|
xhr.send(body || null);
|
||||||
|
|
||||||
return xhr;
|
return xhr;
|
||||||
|
|
|
@ -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 |
|
@ -470,6 +470,20 @@ a {
|
||||||
background-position: center;
|
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 {
|
.MessageComposer > button.send:disabled {
|
||||||
background-color: #E3E8F0;
|
background-color: #E3E8F0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,11 @@ export class MessageComposer extends TemplateView {
|
||||||
});
|
});
|
||||||
return t.div({className: "MessageComposer"}, [
|
return t.div({className: "MessageComposer"}, [
|
||||||
this._input,
|
this._input,
|
||||||
|
t.button({
|
||||||
|
className: "sendFile",
|
||||||
|
title: vm.i18n`Send file`,
|
||||||
|
onClick: () => vm.sendAttachment(),
|
||||||
|
}, vm.i18n`Send file`),
|
||||||
t.button({
|
t.button({
|
||||||
className: "send",
|
className: "send",
|
||||||
title: vm.i18n`Send`,
|
title: vm.i18n`Send`,
|
||||||
|
|
Reference in a new issue