Merge pull request #175 from vector-im/bwindels/decrypt-images
Decrypt images
This commit is contained in:
commit
6d13709fcc
19 changed files with 372 additions and 65 deletions
|
@ -95,6 +95,9 @@ export class TilesCollection extends BaseObservableList {
|
|||
|
||||
onUnsubscribeLast() {
|
||||
this._entrySubscription = this._entrySubscription();
|
||||
for(let i = 0; i < this._tiles.length; i+= 1) {
|
||||
this._tiles[i].dispose();
|
||||
}
|
||||
this._tiles = null;
|
||||
}
|
||||
|
||||
|
@ -147,7 +150,8 @@ export class TilesCollection extends BaseObservableList {
|
|||
if (action.shouldReplace) {
|
||||
const newTile = this._tileCreator(entry);
|
||||
if (newTile) {
|
||||
this._replaceTile(tileIdx, tile, newTile);
|
||||
this._replaceTile(tileIdx, tile, newTile, action.updateParams);
|
||||
newTile.setUpdateEmit(this._emitSpontanousUpdate);
|
||||
} else {
|
||||
this._removeTile(tileIdx, tile);
|
||||
}
|
||||
|
@ -172,7 +176,7 @@ export class TilesCollection extends BaseObservableList {
|
|||
// merge with neighbours? ... hard to imagine use case for this ...
|
||||
}
|
||||
|
||||
_replaceTile(tileIdx, existingTile, newTile) {
|
||||
_replaceTile(tileIdx, existingTile, newTile, updateParams) {
|
||||
existingTile.dispose();
|
||||
const prevTile = this._getTileAtIdx(tileIdx - 1);
|
||||
const nextTile = this._getTileAtIdx(tileIdx + 1);
|
||||
|
@ -181,7 +185,7 @@ export class TilesCollection extends BaseObservableList {
|
|||
newTile.updatePreviousSibling(prevTile);
|
||||
newTile.updateNextSibling(nextTile);
|
||||
nextTile?.updatePreviousSibling(newTile);
|
||||
this.emitUpdate(tileIdx, newTile, null);
|
||||
this.emitUpdate(tileIdx, newTile, updateParams);
|
||||
}
|
||||
|
||||
_removeTile(tileIdx, tile) {
|
||||
|
|
|
@ -50,7 +50,7 @@ export class UpdateAction {
|
|||
return new UpdateAction(false, false, false, null);
|
||||
}
|
||||
|
||||
static Replace() {
|
||||
return new UpdateAction(false, false, true, null);
|
||||
static Replace(params) {
|
||||
return new UpdateAction(false, false, true, params);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,8 @@ export class EncryptedEventTile extends MessageTile {
|
|||
const parentResult = super.updateEntry(entry, params);
|
||||
// event got decrypted, recreate the tile and replace this one with it
|
||||
if (entry.eventType !== "m.room.encrypted") {
|
||||
return UpdateAction.Replace();
|
||||
// the "shape" parameter trigger tile recreation in TimelineList
|
||||
return UpdateAction.Replace("shape");
|
||||
} else {
|
||||
return parentResult;
|
||||
}
|
||||
|
@ -38,7 +39,7 @@ export class EncryptedEventTile extends MessageTile {
|
|||
if (code === "MEGOLM_NO_SESSION") {
|
||||
return this.i18n`The sender hasn't sent us the key for this message yet.`;
|
||||
} else {
|
||||
return decryptionError?.message || this.i18n`"Could not decrypt message because of unknown reason."`;
|
||||
return decryptionError?.message || this.i18n`Could not decrypt message because of unknown reason.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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.
|
||||
|
@ -20,20 +21,60 @@ const MAX_HEIGHT = 300;
|
|||
const MAX_WIDTH = 400;
|
||||
|
||||
export class ImageTile extends MessageTile {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._decryptedThumbail = null;
|
||||
this._decryptedImage = null;
|
||||
this._error = null;
|
||||
this.load();
|
||||
}
|
||||
|
||||
async _loadEncryptedFile(file) {
|
||||
const buffer = await this._mediaRepository.downloadEncryptedFile(file);
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
return this.track(this.platform.createBufferURL(buffer, file.mimetype));
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const thumbnailFile = this._getContent().info?.thumbnail_file;
|
||||
const file = this._getContent().file;
|
||||
if (thumbnailFile) {
|
||||
this._decryptedThumbail = await this._loadEncryptedFile(thumbnailFile);
|
||||
this.emitChange("thumbnailUrl");
|
||||
} else if (file) {
|
||||
this._decryptedImage = await this._loadEncryptedFile(file);
|
||||
this.emitChange("thumbnailUrl");
|
||||
}
|
||||
} catch (err) {
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
}
|
||||
}
|
||||
|
||||
get thumbnailUrl() {
|
||||
if (this._decryptedThumbail) {
|
||||
return this._decryptedThumbail.url;
|
||||
} else if (this._decryptedImage) {
|
||||
return this._decryptedImage.url;
|
||||
}
|
||||
const mxcUrl = this._getContent()?.url;
|
||||
if (typeof mxcUrl === "string") {
|
||||
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
|
||||
}
|
||||
return null;
|
||||
return "";
|
||||
}
|
||||
|
||||
get url() {
|
||||
const mxcUrl = this._getContent()?.url;
|
||||
if (typeof mxcUrl === "string") {
|
||||
return this._mediaRepository.mxcUrl(mxcUrl);
|
||||
async loadImageUrl() {
|
||||
if (!this._decryptedImage) {
|
||||
const file = this._getContent().file;
|
||||
if (file) {
|
||||
this._decryptedImage = await this._loadEncryptedFile(file);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return this._decryptedImage?.url || "";
|
||||
}
|
||||
|
||||
_scaleFactor() {
|
||||
|
@ -59,6 +100,13 @@ export class ImageTile extends MessageTile {
|
|||
return this._getContent().body;
|
||||
}
|
||||
|
||||
get error() {
|
||||
if (this._error) {
|
||||
return `Could not decrypt image: ${this._error.message}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get shape() {
|
||||
return "image";
|
||||
}
|
||||
|
|
|
@ -18,9 +18,9 @@ import {UpdateAction} from "../UpdateAction.js";
|
|||
import {ViewModel} from "../../../../ViewModel.js";
|
||||
|
||||
export class SimpleTile extends ViewModel {
|
||||
constructor({entry}) {
|
||||
super();
|
||||
this._entry = entry;
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._entry = options.entry;
|
||||
}
|
||||
// view model props for all subclasses
|
||||
// hmmm, could also do instanceof ... ?
|
||||
|
|
|
@ -165,14 +165,19 @@ export class SessionContainer {
|
|||
}
|
||||
this._requestScheduler = new RequestScheduler({hsApi, clock});
|
||||
this._requestScheduler.start();
|
||||
const mediaRepository = new MediaRepository({
|
||||
homeServer: sessionInfo.homeServer,
|
||||
crypto: this._platform.crypto,
|
||||
request: this._platform.request,
|
||||
});
|
||||
this._session = new Session({
|
||||
storage: this._storage,
|
||||
sessionInfo: filteredSessionInfo,
|
||||
hsApi: this._requestScheduler.hsApi,
|
||||
olm,
|
||||
olmWorker,
|
||||
mediaRepository,
|
||||
platform: this._platform,
|
||||
mediaRepository: new MediaRepository(sessionInfo.homeServer)
|
||||
});
|
||||
await this._session.load();
|
||||
if (isNewLogin) {
|
||||
|
|
58
src/matrix/e2ee/attachment.js
Normal file
58
src/matrix/e2ee/attachment.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
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 base64 from "../../../lib/base64-arraybuffer/index.js";
|
||||
|
||||
/**
|
||||
* Decrypt an attachment.
|
||||
* @param {ArrayBuffer} ciphertextBuffer The encrypted attachment data buffer.
|
||||
* @param {Object} info The information needed to decrypt the attachment.
|
||||
* @param {Object} info.key AES-CTR JWK key object.
|
||||
* @param {string} info.iv Base64 encoded 16 byte AES-CTR IV.
|
||||
* @param {string} info.hashes.sha256 Base64 encoded SHA-256 hash of the ciphertext.
|
||||
* @return {Promise} A promise that resolves with an ArrayBuffer when the attachment is decrypted.
|
||||
*/
|
||||
export async function decryptAttachment(crypto, ciphertextBuffer, info) {
|
||||
if (info === undefined || info.key === undefined || info.iv === undefined
|
||||
|| info.hashes === undefined || info.hashes.sha256 === undefined) {
|
||||
throw new Error("Invalid info. Missing info.key, info.iv or info.hashes.sha256 key");
|
||||
}
|
||||
|
||||
var ivArray = base64.decode(info.iv);
|
||||
// re-encode to not deal with padded vs unpadded
|
||||
var expectedSha256base64 = base64.encode(base64.decode(info.hashes.sha256));
|
||||
// Check the sha256 hash
|
||||
const digestResult = await crypto.digest("SHA-256", ciphertextBuffer);
|
||||
if (base64.encode(new Uint8Array(digestResult)) != expectedSha256base64) {
|
||||
throw new Error("Mismatched SHA-256 digest");
|
||||
}
|
||||
var counterLength;
|
||||
if (info.v == "v1" || info.v == "v2") {
|
||||
// Version 1 and 2 use a 64 bit counter.
|
||||
counterLength = 64;
|
||||
} else {
|
||||
// Version 0 uses a 128 bit counter.
|
||||
counterLength = 128;
|
||||
}
|
||||
|
||||
const decryptedBuffer = await crypto.aes.decryptCTR({
|
||||
jwkKey: info.key,
|
||||
iv: ivArray,
|
||||
data: ciphertextBuffer,
|
||||
counterLength
|
||||
});
|
||||
return decryptedBuffer;
|
||||
}
|
|
@ -75,7 +75,8 @@ export class HomeServerApi {
|
|||
method,
|
||||
headers,
|
||||
body: bodyString,
|
||||
timeout: options?.timeout
|
||||
timeout: options?.timeout,
|
||||
format: "json"
|
||||
});
|
||||
|
||||
const wrapper = new RequestWrapper(method, url, requestResult);
|
||||
|
|
|
@ -15,17 +15,20 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {encodeQueryParams} from "./common.js";
|
||||
import {decryptAttachment} from "../e2ee/attachment.js";
|
||||
|
||||
export class MediaRepository {
|
||||
constructor(homeserver) {
|
||||
this._homeserver = homeserver;
|
||||
constructor({homeServer, crypto, request}) {
|
||||
this._homeServer = homeServer;
|
||||
this._crypto = crypto;
|
||||
this._request = request;
|
||||
}
|
||||
|
||||
mxcUrlThumbnail(url, width, height, method) {
|
||||
const parts = this._parseMxcUrl(url);
|
||||
if (parts) {
|
||||
const [serverName, mediaId] = parts;
|
||||
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||
const httpUrl = `${this._homeServer}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||
return httpUrl + "?" + encodeQueryParams({width, height, method});
|
||||
}
|
||||
return null;
|
||||
|
@ -35,7 +38,7 @@ export class MediaRepository {
|
|||
const parts = this._parseMxcUrl(url);
|
||||
if (parts) {
|
||||
const [serverName, mediaId] = parts;
|
||||
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||
return `${this._homeServer}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -49,4 +52,11 @@ export class MediaRepository {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadEncryptedFile(fileEntry) {
|
||||
const url = this.mxcUrl(fileEntry.url);
|
||||
const {body: encryptedBuffer} = await this._request(url, {method: "GET", format: "buffer", cache: true}).response();
|
||||
const decryptedBuffer = await decryptAttachment(this._crypto, encryptedBuffer, fileEntry);
|
||||
return decryptedBuffer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,8 +64,11 @@ export class SecretStorage {
|
|||
throw new Error("Bad MAC");
|
||||
}
|
||||
|
||||
const plaintextBytes = await this._crypto.aes.decrypt(
|
||||
aesKey, base64.decode(encryptedData.iv), ciphertextBytes);
|
||||
const plaintextBytes = await this._crypto.aes.decryptCTR({
|
||||
key: aesKey,
|
||||
iv: base64.decode(encryptedData.iv),
|
||||
data: ciphertextBytes
|
||||
});
|
||||
|
||||
return textDecoder.decode(plaintextBytes);
|
||||
}
|
||||
|
|
|
@ -27,6 +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 {BufferURL} from "./dom/BufferURL.js";
|
||||
|
||||
function addScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
@ -127,4 +128,8 @@ export class Platform {
|
|||
setNavigation(navigation) {
|
||||
this._serviceWorkerHandler?.setNavigation(navigation);
|
||||
}
|
||||
|
||||
createBufferURL(buffer, mimetype) {
|
||||
return new BufferURL(buffer, mimetype);
|
||||
}
|
||||
}
|
||||
|
|
86
src/platform/web/dom/BufferURL.js
Normal file
86
src/platform/web/dom/BufferURL.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
|
||||
// WARNING: We have to be very careful about what mime-types we allow into blobs.
|
||||
//
|
||||
// This means that the content is rendered using the origin of the script which
|
||||
// called createObjectURL(), and so if the content contains any scripting then it
|
||||
// will pose a XSS vulnerability when the browser renders it. This is particularly
|
||||
// bad if the user right-clicks the URI and pastes it into a new window or tab,
|
||||
// as the blob will then execute with access to Element's full JS environment(!)
|
||||
//
|
||||
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
|
||||
// for details.
|
||||
//
|
||||
// We mitigate this by only allowing mime-types into blobs which we know don't
|
||||
// contain any scripting, and instantiate all others as application/octet-stream
|
||||
// regardless of what mime-type the event claimed. Even if the payload itself
|
||||
// is some malicious HTML, the fact we instantiate it with a media mimetype or
|
||||
// application/octet-stream means the browser doesn't try to render it as such.
|
||||
//
|
||||
// One interesting edge case is image/svg+xml, which empirically *is* rendered
|
||||
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
|
||||
// *even if the mimetype is application/octet-stream*. However, empirically JS
|
||||
// in the SVG isn't executed in this scenario, so we seem to be okay.
|
||||
//
|
||||
// Tested on Chrome 65 and Firefox 60
|
||||
//
|
||||
// The list below is taken mainly from
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
|
||||
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
|
||||
// events, so we pick the ones which HTML5 browsers should be able to display
|
||||
//
|
||||
// For the record, mime-types which must NEVER enter this list below include:
|
||||
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
|
||||
|
||||
const ALLOWED_BLOB_MIMETYPES = {
|
||||
'image/jpeg': true,
|
||||
'image/gif': true,
|
||||
'image/png': true,
|
||||
|
||||
'video/mp4': true,
|
||||
'video/webm': true,
|
||||
'video/ogg': true,
|
||||
|
||||
'audio/mp4': true,
|
||||
'audio/webm': true,
|
||||
'audio/aac': true,
|
||||
'audio/mpeg': true,
|
||||
'audio/ogg': true,
|
||||
'audio/wave': true,
|
||||
'audio/wav': true,
|
||||
'audio/x-wav': true,
|
||||
'audio/x-pn-wav': true,
|
||||
'audio/flac': true,
|
||||
'audio/x-flac': true,
|
||||
};
|
||||
|
||||
export class BufferURL {
|
||||
constructor(buffer, mimetype) {
|
||||
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
|
||||
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
|
||||
mimetype = 'application/octet-stream';
|
||||
}
|
||||
const blob = new Blob([buffer], {type: mimetype});
|
||||
this.url = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
URL.revokeObjectURL(this.url);
|
||||
this.url = null;
|
||||
}
|
||||
}
|
|
@ -159,21 +159,25 @@ class AESCrypto {
|
|||
/**
|
||||
* [decrypt description]
|
||||
* @param {BufferSource} key [description]
|
||||
* @param {Object} jwkKey [description]
|
||||
* @param {BufferSource} iv [description]
|
||||
* @param {BufferSource} ciphertext [description]
|
||||
* @param {BufferSource} data [description]
|
||||
* @param {Number} counterLength the size of the counter, in bits
|
||||
* @return {BufferSource} [description]
|
||||
*/
|
||||
async decrypt(key, iv, ciphertext) {
|
||||
async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) {
|
||||
const opts = {
|
||||
name: "AES-CTR",
|
||||
counter: iv,
|
||||
length: 64,
|
||||
length: counterLength,
|
||||
};
|
||||
let aesKey;
|
||||
try {
|
||||
const selectedKey = key || jwkKey;
|
||||
const format = jwkKey ? "jwk" : "raw";
|
||||
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
|
||||
'raw',
|
||||
key,
|
||||
format,
|
||||
selectedKey,
|
||||
opts,
|
||||
false,
|
||||
['decrypt'],
|
||||
|
@ -186,7 +190,7 @@ class AESCrypto {
|
|||
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
|
||||
opts,
|
||||
aesKey,
|
||||
ciphertext,
|
||||
data,
|
||||
), "decrypt");
|
||||
return new Uint8Array(plaintext);
|
||||
} catch (err) {
|
||||
|
@ -196,6 +200,8 @@ class AESCrypto {
|
|||
}
|
||||
|
||||
|
||||
import base64 from "../../../../lib/base64-arraybuffer/index.js";
|
||||
|
||||
class AESLegacyCrypto {
|
||||
constructor(aesjs) {
|
||||
this._aesjs = aesjs;
|
||||
|
@ -205,12 +211,31 @@ class AESLegacyCrypto {
|
|||
* @param {BufferSource} key [description]
|
||||
* @param {BufferSource} iv [description]
|
||||
* @param {BufferSource} ciphertext [description]
|
||||
* @param {Number} counterLength the size of the counter, in bits
|
||||
* @return {BufferSource} [description]
|
||||
*/
|
||||
async decrypt(key, iv, ciphertext) {
|
||||
async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) {
|
||||
if (counterLength !== 64) {
|
||||
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);
|
||||
}
|
||||
const aesjs = this._aesjs;
|
||||
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
|
||||
return aesCtr.decrypt(new Uint8Array(ciphertext));
|
||||
return aesCtr.decrypt(new Uint8Array(data));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -51,14 +51,17 @@ class RequestResult {
|
|||
}
|
||||
|
||||
export function createFetchRequest(createTimeout) {
|
||||
return function fetchRequest(url, options) {
|
||||
return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) {
|
||||
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||
let options = {method, body};
|
||||
if (controller) {
|
||||
options = Object.assign(options, {
|
||||
signal: controller.signal
|
||||
});
|
||||
}
|
||||
url = addCacheBuster(url);
|
||||
if (!cache) {
|
||||
url = addCacheBuster(url);
|
||||
}
|
||||
options = Object.assign(options, {
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
|
@ -76,18 +79,22 @@ export function createFetchRequest(createTimeout) {
|
|||
// cache: "no-store",
|
||||
cache: "default",
|
||||
});
|
||||
if (options.headers) {
|
||||
const headers = new Headers();
|
||||
for(const [name, value] of options.headers.entries()) {
|
||||
headers.append(name, value);
|
||||
if (headers) {
|
||||
const fetchHeaders = new Headers();
|
||||
for(const [name, value] of headers.entries()) {
|
||||
fetchHeaders.append(name, value);
|
||||
}
|
||||
options.headers = headers;
|
||||
options.headers = fetchHeaders;
|
||||
}
|
||||
const promise = fetch(url, options).then(async response => {
|
||||
const {status} = response;
|
||||
let body;
|
||||
try {
|
||||
body = await response.json();
|
||||
if (format === "json") {
|
||||
body = await response.json();
|
||||
} else if (format === "buffer") {
|
||||
body = await response.arrayBuffer();
|
||||
}
|
||||
} catch (err) {
|
||||
// some error pages return html instead of json, ignore error
|
||||
if (!(err.name === "SyntaxError" && status >= 400)) {
|
||||
|
@ -105,14 +112,14 @@ export function createFetchRequest(createTimeout) {
|
|||
//
|
||||
// One could check navigator.onLine to rule out the first
|
||||
// but the 2 latter ones are indistinguishable from javascript.
|
||||
throw new ConnectionError(`${options.method} ${url}: ${err.message}`);
|
||||
throw new ConnectionError(`${method} ${url}: ${err.message}`);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
const result = new RequestResult(promise, controller);
|
||||
|
||||
if (options.timeout) {
|
||||
result.promise = abortOnTimeout(createTimeout, options.timeout, result, result.promise);
|
||||
if (timeout) {
|
||||
result.promise = abortOnTimeout(createTimeout, timeout, result, result.promise);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
@ -35,19 +35,24 @@ class RequestResult {
|
|||
}
|
||||
}
|
||||
|
||||
function send(url, options) {
|
||||
function send(url, {method, headers, timeout, body, format}) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(options.method, url);
|
||||
if (options.headers) {
|
||||
for(const [name, value] of options.headers.entries()) {
|
||||
xhr.open(method, url);
|
||||
|
||||
if (format === "buffer") {
|
||||
// important to call this after calling open
|
||||
xhr.responseType = "arraybuffer";
|
||||
}
|
||||
if (headers) {
|
||||
for(const [name, value] of headers.entries()) {
|
||||
xhr.setRequestHeader(name, value);
|
||||
}
|
||||
}
|
||||
if (options.timeout) {
|
||||
xhr.timeout = options.timeout;
|
||||
if (timeout) {
|
||||
xhr.timeout = timeout;
|
||||
}
|
||||
|
||||
xhr.send(options.body || null);
|
||||
xhr.send(body || null);
|
||||
|
||||
return xhr;
|
||||
}
|
||||
|
@ -62,12 +67,17 @@ function xhrAsPromise(xhr, method, url) {
|
|||
}
|
||||
|
||||
export function xhrRequest(url, options) {
|
||||
url = addCacheBuster(url);
|
||||
const {cache, format} = options;
|
||||
if (!cache) {
|
||||
url = addCacheBuster(url);
|
||||
}
|
||||
const xhr = send(url, options);
|
||||
const promise = xhrAsPromise(xhr, options.method, url).then(xhr => {
|
||||
const {status} = xhr;
|
||||
let body = null;
|
||||
if (xhr.getResponseHeader("Content-Type") === "application/json") {
|
||||
if (format === "buffer") {
|
||||
body = xhr.response;
|
||||
} else if (xhr.getResponseHeader("Content-Type") === "application/json") {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
}
|
||||
return {status, body};
|
||||
|
|
|
@ -505,6 +505,11 @@ ul.Timeline > li.messageStatus .message-container > p {
|
|||
--avatar-size: 25px;
|
||||
}
|
||||
|
||||
.message-container img.picture {
|
||||
margin-top: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.TextMessageView.continuation .message-container {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
@ -608,7 +613,7 @@ ul.Timeline > li.messageStatus .message-container > p {
|
|||
flex: 0 0 200px;
|
||||
}
|
||||
|
||||
.Settings .error {
|
||||
.error {
|
||||
color: red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
@ -144,6 +144,19 @@ export class ListView {
|
|||
}
|
||||
}
|
||||
|
||||
recreateItem(index, value) {
|
||||
if (this._childInstances) {
|
||||
const child = this._childCreator(value);
|
||||
if (!child) {
|
||||
this.onRemove(index, value);
|
||||
} else {
|
||||
const [oldChild] = this._childInstances.splice(index, 1, child);
|
||||
this._root.replaceChild(child.mount(this._mountArgs), oldChild.root());
|
||||
oldChild.unmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeListChanged() {}
|
||||
onListChanged() {}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,17 @@ import {TextMessageView} from "./timeline/TextMessageView.js";
|
|||
import {ImageView} from "./timeline/ImageView.js";
|
||||
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
||||
|
||||
function viewClassForEntry(entry) {
|
||||
switch (entry.shape) {
|
||||
case "gap": return GapView;
|
||||
case "announcement": return AnnouncementView;
|
||||
case "message":
|
||||
case "message-status":
|
||||
return TextMessageView;
|
||||
case "image": return ImageView;
|
||||
}
|
||||
}
|
||||
|
||||
export class TimelineList extends ListView {
|
||||
constructor(viewModel) {
|
||||
const options = {
|
||||
|
@ -27,13 +38,9 @@ export class TimelineList extends ListView {
|
|||
list: viewModel.tiles,
|
||||
}
|
||||
super(options, entry => {
|
||||
switch (entry.shape) {
|
||||
case "gap": return new GapView(entry);
|
||||
case "announcement": return new AnnouncementView(entry);
|
||||
case "message":
|
||||
case "message-status":
|
||||
return new TextMessageView(entry);
|
||||
case "image": return new ImageView(entry);
|
||||
const View = viewClassForEntry(entry);
|
||||
if (View) {
|
||||
return new View(entry);
|
||||
}
|
||||
});
|
||||
this._atBottom = false;
|
||||
|
@ -127,4 +134,21 @@ export class TimelineList extends ListView {
|
|||
root.scrollTop = root.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(index, value, param) {
|
||||
if (param === "shape") {
|
||||
if (this._childInstances) {
|
||||
const ExpectedClass = viewClassForEntry(value);
|
||||
const child = this._childInstances[index];
|
||||
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
|
||||
// shape was updated, so we need to recreate the tile view,
|
||||
// the shape parameter is set in EncryptedEventTile.updateEntry
|
||||
// (and perhaps elsewhere by the time you read this)
|
||||
super.recreateItem(index, value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
super.onUpdate(index, value, param);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,17 +23,19 @@ export class ImageView extends TemplateView {
|
|||
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
|
||||
const image = t.img({
|
||||
className: "picture",
|
||||
src: vm.thumbnailUrl,
|
||||
src: vm => vm.thumbnailUrl,
|
||||
width: vm.thumbnailWidth,
|
||||
height: vm.thumbnailHeight,
|
||||
loading: "lazy",
|
||||
alt: vm.label,
|
||||
alt: vm => vm.label,
|
||||
title: vm => vm.label,
|
||||
});
|
||||
const linkContainer = t.a({
|
||||
href: vm.url,
|
||||
target: "_blank",
|
||||
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;`
|
||||
}, image);
|
||||
}, [
|
||||
image,
|
||||
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
|
||||
]);
|
||||
|
||||
return renderMessage(t, vm,
|
||||
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]
|
||||
|
|
Reference in a new issue