Merge pull request #175 from vector-im/bwindels/decrypt-images

Decrypt images
This commit is contained in:
Bruno Windels 2020-10-29 09:42:39 +00:00 committed by GitHub
commit 6d13709fcc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 372 additions and 65 deletions

View File

@ -95,6 +95,9 @@ export class TilesCollection extends BaseObservableList {
onUnsubscribeLast() { onUnsubscribeLast() {
this._entrySubscription = this._entrySubscription(); this._entrySubscription = this._entrySubscription();
for(let i = 0; i < this._tiles.length; i+= 1) {
this._tiles[i].dispose();
}
this._tiles = null; this._tiles = null;
} }
@ -147,7 +150,8 @@ export class TilesCollection extends BaseObservableList {
if (action.shouldReplace) { if (action.shouldReplace) {
const newTile = this._tileCreator(entry); const newTile = this._tileCreator(entry);
if (newTile) { if (newTile) {
this._replaceTile(tileIdx, tile, newTile); this._replaceTile(tileIdx, tile, newTile, action.updateParams);
newTile.setUpdateEmit(this._emitSpontanousUpdate);
} else { } else {
this._removeTile(tileIdx, tile); this._removeTile(tileIdx, tile);
} }
@ -172,7 +176,7 @@ export class TilesCollection extends BaseObservableList {
// merge with neighbours? ... hard to imagine use case for this ... // merge with neighbours? ... hard to imagine use case for this ...
} }
_replaceTile(tileIdx, existingTile, newTile) { _replaceTile(tileIdx, existingTile, newTile, updateParams) {
existingTile.dispose(); existingTile.dispose();
const prevTile = this._getTileAtIdx(tileIdx - 1); const prevTile = this._getTileAtIdx(tileIdx - 1);
const nextTile = this._getTileAtIdx(tileIdx + 1); const nextTile = this._getTileAtIdx(tileIdx + 1);
@ -181,7 +185,7 @@ export class TilesCollection extends BaseObservableList {
newTile.updatePreviousSibling(prevTile); newTile.updatePreviousSibling(prevTile);
newTile.updateNextSibling(nextTile); newTile.updateNextSibling(nextTile);
nextTile?.updatePreviousSibling(newTile); nextTile?.updatePreviousSibling(newTile);
this.emitUpdate(tileIdx, newTile, null); this.emitUpdate(tileIdx, newTile, updateParams);
} }
_removeTile(tileIdx, tile) { _removeTile(tileIdx, tile) {

View File

@ -50,7 +50,7 @@ export class UpdateAction {
return new UpdateAction(false, false, false, null); return new UpdateAction(false, false, false, null);
} }
static Replace() { static Replace(params) {
return new UpdateAction(false, false, true, null); return new UpdateAction(false, false, true, params);
} }
} }

View File

@ -22,7 +22,8 @@ export class EncryptedEventTile extends MessageTile {
const parentResult = super.updateEntry(entry, params); const parentResult = super.updateEntry(entry, params);
// event got decrypted, recreate the tile and replace this one with it // event got decrypted, recreate the tile and replace this one with it
if (entry.eventType !== "m.room.encrypted") { if (entry.eventType !== "m.room.encrypted") {
return UpdateAction.Replace(); // the "shape" parameter trigger tile recreation in TimelineList
return UpdateAction.Replace("shape");
} else { } else {
return parentResult; return parentResult;
} }
@ -38,7 +39,7 @@ export class EncryptedEventTile extends MessageTile {
if (code === "MEGOLM_NO_SESSION") { if (code === "MEGOLM_NO_SESSION") {
return this.i18n`The sender hasn't sent us the key for this message yet.`; return this.i18n`The sender hasn't sent us the key for this message yet.`;
} else { } 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.`;
} }
} }
} }

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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; const MAX_WIDTH = 400;
export class ImageTile extends MessageTile { 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() { get thumbnailUrl() {
if (this._decryptedThumbail) {
return this._decryptedThumbail.url;
} else if (this._decryptedImage) {
return this._decryptedImage.url;
}
const mxcUrl = this._getContent()?.url; const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") { if (typeof mxcUrl === "string") {
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
} }
return null; return "";
} }
get url() { async loadImageUrl() {
const mxcUrl = this._getContent()?.url; if (!this._decryptedImage) {
if (typeof mxcUrl === "string") { const file = this._getContent().file;
return this._mediaRepository.mxcUrl(mxcUrl); if (file) {
this._decryptedImage = await this._loadEncryptedFile(file);
} }
return null; }
return this._decryptedImage?.url || "";
} }
_scaleFactor() { _scaleFactor() {
@ -59,6 +100,13 @@ export class ImageTile extends MessageTile {
return this._getContent().body; return this._getContent().body;
} }
get error() {
if (this._error) {
return `Could not decrypt image: ${this._error.message}`;
}
return null;
}
get shape() { get shape() {
return "image"; return "image";
} }

View File

@ -18,9 +18,9 @@ import {UpdateAction} from "../UpdateAction.js";
import {ViewModel} from "../../../../ViewModel.js"; import {ViewModel} from "../../../../ViewModel.js";
export class SimpleTile extends ViewModel { export class SimpleTile extends ViewModel {
constructor({entry}) { constructor(options) {
super(); super(options);
this._entry = entry; this._entry = options.entry;
} }
// view model props for all subclasses // view model props for all subclasses
// hmmm, could also do instanceof ... ? // hmmm, could also do instanceof ... ?

View File

@ -165,14 +165,19 @@ export class SessionContainer {
} }
this._requestScheduler = new RequestScheduler({hsApi, clock}); this._requestScheduler = new RequestScheduler({hsApi, clock});
this._requestScheduler.start(); this._requestScheduler.start();
const mediaRepository = new MediaRepository({
homeServer: sessionInfo.homeServer,
crypto: this._platform.crypto,
request: this._platform.request,
});
this._session = new Session({ this._session = new Session({
storage: this._storage, storage: this._storage,
sessionInfo: filteredSessionInfo, sessionInfo: filteredSessionInfo,
hsApi: this._requestScheduler.hsApi, hsApi: this._requestScheduler.hsApi,
olm, olm,
olmWorker, olmWorker,
mediaRepository,
platform: this._platform, platform: this._platform,
mediaRepository: new MediaRepository(sessionInfo.homeServer)
}); });
await this._session.load(); await this._session.load();
if (isNewLogin) { if (isNewLogin) {

View 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;
}

View File

@ -75,7 +75,8 @@ export class HomeServerApi {
method, method,
headers, headers,
body: bodyString, body: bodyString,
timeout: options?.timeout timeout: options?.timeout,
format: "json"
}); });
const wrapper = new RequestWrapper(method, url, requestResult); const wrapper = new RequestWrapper(method, url, requestResult);

View File

@ -15,17 +15,20 @@ limitations under the License.
*/ */
import {encodeQueryParams} from "./common.js"; import {encodeQueryParams} from "./common.js";
import {decryptAttachment} from "../e2ee/attachment.js";
export class MediaRepository { export class MediaRepository {
constructor(homeserver) { constructor({homeServer, crypto, request}) {
this._homeserver = homeserver; this._homeServer = homeServer;
this._crypto = crypto;
this._request = request;
} }
mxcUrlThumbnail(url, width, height, method) { mxcUrlThumbnail(url, width, height, method) {
const parts = this._parseMxcUrl(url); const parts = this._parseMxcUrl(url);
if (parts) { if (parts) {
const [serverName, mediaId] = 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 httpUrl + "?" + encodeQueryParams({width, height, method});
} }
return null; return null;
@ -35,7 +38,7 @@ export class MediaRepository {
const parts = this._parseMxcUrl(url); const parts = this._parseMxcUrl(url);
if (parts) { if (parts) {
const [serverName, mediaId] = 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 { } else {
return null; return null;
} }
@ -49,4 +52,11 @@ export class MediaRepository {
return null; 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;
}
} }

View File

@ -64,8 +64,11 @@ export class SecretStorage {
throw new Error("Bad MAC"); throw new Error("Bad MAC");
} }
const plaintextBytes = await this._crypto.aes.decrypt( const plaintextBytes = await this._crypto.aes.decryptCTR({
aesKey, base64.decode(encryptedData.iv), ciphertextBytes); key: aesKey,
iv: base64.decode(encryptedData.iv),
data: ciphertextBytes
});
return textDecoder.decode(plaintextBytes); return textDecoder.decode(plaintextBytes);
} }

View File

@ -27,6 +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 {BufferURL} from "./dom/BufferURL.js";
function addScript(src) { function addScript(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -127,4 +128,8 @@ export class Platform {
setNavigation(navigation) { setNavigation(navigation) {
this._serviceWorkerHandler?.setNavigation(navigation); this._serviceWorkerHandler?.setNavigation(navigation);
} }
createBufferURL(buffer, mimetype) {
return new BufferURL(buffer, mimetype);
}
} }

View 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;
}
}

View File

@ -159,21 +159,25 @@ class AESCrypto {
/** /**
* [decrypt description] * [decrypt description]
* @param {BufferSource} key [description] * @param {BufferSource} key [description]
* @param {Object} jwkKey [description]
* @param {BufferSource} iv [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] * @return {BufferSource} [description]
*/ */
async decrypt(key, iv, ciphertext) { async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) {
const opts = { const opts = {
name: "AES-CTR", name: "AES-CTR",
counter: iv, counter: iv,
length: 64, length: counterLength,
}; };
let aesKey; let aesKey;
try { try {
const selectedKey = key || jwkKey;
const format = jwkKey ? "jwk" : "raw";
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey( aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw', format,
key, selectedKey,
opts, opts,
false, false,
['decrypt'], ['decrypt'],
@ -186,7 +190,7 @@ class AESCrypto {
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams // see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
opts, opts,
aesKey, aesKey,
ciphertext, data,
), "decrypt"); ), "decrypt");
return new Uint8Array(plaintext); return new Uint8Array(plaintext);
} catch (err) { } catch (err) {
@ -196,6 +200,8 @@ class AESCrypto {
} }
import base64 from "../../../../lib/base64-arraybuffer/index.js";
class AESLegacyCrypto { class AESLegacyCrypto {
constructor(aesjs) { constructor(aesjs) {
this._aesjs = aesjs; this._aesjs = aesjs;
@ -205,12 +211,31 @@ class AESLegacyCrypto {
* @param {BufferSource} key [description] * @param {BufferSource} key [description]
* @param {BufferSource} iv [description] * @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description] * @param {BufferSource} ciphertext [description]
* @param {Number} counterLength the size of the counter, in bits
* @return {BufferSource} [description] * @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; 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(ciphertext)); return aesCtr.decrypt(new Uint8Array(data));
} }
} }

View File

@ -51,14 +51,17 @@ class RequestResult {
} }
export function createFetchRequest(createTimeout) { 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; const controller = typeof AbortController === "function" ? new AbortController() : null;
let options = {method, body};
if (controller) { if (controller) {
options = Object.assign(options, { options = Object.assign(options, {
signal: controller.signal signal: controller.signal
}); });
} }
if (!cache) {
url = addCacheBuster(url); url = addCacheBuster(url);
}
options = Object.assign(options, { options = Object.assign(options, {
mode: "cors", mode: "cors",
credentials: "omit", credentials: "omit",
@ -76,18 +79,22 @@ export function createFetchRequest(createTimeout) {
// cache: "no-store", // cache: "no-store",
cache: "default", cache: "default",
}); });
if (options.headers) { if (headers) {
const headers = new Headers(); const fetchHeaders = new Headers();
for(const [name, value] of options.headers.entries()) { for(const [name, value] of headers.entries()) {
headers.append(name, value); fetchHeaders.append(name, value);
} }
options.headers = headers; options.headers = fetchHeaders;
} }
const promise = fetch(url, options).then(async response => { const promise = fetch(url, options).then(async response => {
const {status} = response; const {status} = response;
let body; let body;
try { try {
if (format === "json") {
body = await response.json(); body = await response.json();
} else if (format === "buffer") {
body = await response.arrayBuffer();
}
} catch (err) { } catch (err) {
// some error pages return html instead of json, ignore error // some error pages return html instead of json, ignore error
if (!(err.name === "SyntaxError" && status >= 400)) { if (!(err.name === "SyntaxError" && status >= 400)) {
@ -105,14 +112,14 @@ export function createFetchRequest(createTimeout) {
// //
// One could check navigator.onLine to rule out the first // One could check navigator.onLine to rule out the first
// but the 2 latter ones are indistinguishable from javascript. // 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; throw err;
}); });
const result = new RequestResult(promise, controller); const result = new RequestResult(promise, controller);
if (options.timeout) { if (timeout) {
result.promise = abortOnTimeout(createTimeout, options.timeout, result, result.promise); result.promise = abortOnTimeout(createTimeout, timeout, result, result.promise);
} }
return result; return result;

View File

@ -35,19 +35,24 @@ class RequestResult {
} }
} }
function send(url, options) { function send(url, {method, headers, timeout, body, format}) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open(options.method, url); xhr.open(method, url);
if (options.headers) {
for(const [name, value] of options.headers.entries()) { 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); xhr.setRequestHeader(name, value);
} }
} }
if (options.timeout) { if (timeout) {
xhr.timeout = options.timeout; xhr.timeout = timeout;
} }
xhr.send(options.body || null); xhr.send(body || null);
return xhr; return xhr;
} }
@ -62,12 +67,17 @@ function xhrAsPromise(xhr, method, url) {
} }
export function xhrRequest(url, options) { export function xhrRequest(url, options) {
const {cache, format} = options;
if (!cache) {
url = addCacheBuster(url); url = addCacheBuster(url);
}
const xhr = send(url, options); const xhr = send(url, options);
const promise = xhrAsPromise(xhr, options.method, url).then(xhr => { const promise = xhrAsPromise(xhr, options.method, url).then(xhr => {
const {status} = xhr; const {status} = xhr;
let body = null; 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); body = JSON.parse(xhr.responseText);
} }
return {status, body}; return {status, body};

View File

@ -505,6 +505,11 @@ ul.Timeline > li.messageStatus .message-container > p {
--avatar-size: 25px; --avatar-size: 25px;
} }
.message-container img.picture {
margin-top: 4px;
border-radius: 4px;
}
.TextMessageView.continuation .message-container { .TextMessageView.continuation .message-container {
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
@ -608,7 +613,7 @@ ul.Timeline > li.messageStatus .message-container > p {
flex: 0 0 200px; flex: 0 0 200px;
} }
.Settings .error { .error {
color: red; color: red;
font-weight: 600; font-weight: 600;
} }

View File

@ -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() {} onBeforeListChanged() {}
onListChanged() {} onListChanged() {}
} }

View File

@ -20,6 +20,17 @@ import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js"; import {ImageView} from "./timeline/ImageView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.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 { export class TimelineList extends ListView {
constructor(viewModel) { constructor(viewModel) {
const options = { const options = {
@ -27,13 +38,9 @@ export class TimelineList extends ListView {
list: viewModel.tiles, list: viewModel.tiles,
} }
super(options, entry => { super(options, entry => {
switch (entry.shape) { const View = viewClassForEntry(entry);
case "gap": return new GapView(entry); if (View) {
case "announcement": return new AnnouncementView(entry); return new View(entry);
case "message":
case "message-status":
return new TextMessageView(entry);
case "image": return new ImageView(entry);
} }
}); });
this._atBottom = false; this._atBottom = false;
@ -127,4 +134,21 @@ export class TimelineList extends ListView {
root.scrollTop = root.scrollHeight; 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);
}
} }

View File

@ -23,17 +23,19 @@ export class ImageView extends TemplateView {
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
const image = t.img({ const image = t.img({
className: "picture", className: "picture",
src: vm.thumbnailUrl, src: vm => vm.thumbnailUrl,
width: vm.thumbnailWidth, width: vm.thumbnailWidth,
height: vm.thumbnailHeight, height: vm.thumbnailHeight,
loading: "lazy", loading: "lazy",
alt: vm.label, alt: vm => vm.label,
title: vm => vm.label,
}); });
const linkContainer = t.a({ const linkContainer = t.a({
href: vm.url,
target: "_blank",
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;` 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, return renderMessage(t, vm,
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))] [t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]