diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index cf46899a..2a53c9d4 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -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) { diff --git a/src/domain/session/room/timeline/UpdateAction.js b/src/domain/session/room/timeline/UpdateAction.js index 33a5dbe6..0bd90d7c 100644 --- a/src/domain/session/room/timeline/UpdateAction.js +++ b/src/domain/session/room/timeline/UpdateAction.js @@ -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); } } diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js index bc4f8feb..23476ebb 100644 --- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js +++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js @@ -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.`; } } } diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index e72d28e4..04cff3c9 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +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"; } diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 12acd4c5..ec60bbba 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -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 ... ? diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 814e2473..e0fcf951 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -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) { diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js new file mode 100644 index 00000000..408e04fe --- /dev/null +++ b/src/matrix/e2ee/attachment.js @@ -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; +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 3c5eda8a..354423b8 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -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); diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index 63fde496..856c6657 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -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; + } } diff --git a/src/matrix/ssss/SecretStorage.js b/src/matrix/ssss/SecretStorage.js index 144aa267..ae71280d 100644 --- a/src/matrix/ssss/SecretStorage.js +++ b/src/matrix/ssss/SecretStorage.js @@ -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); } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 1ecae5be..dc299ef8 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -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); + } } diff --git a/src/platform/web/dom/BufferURL.js b/src/platform/web/dom/BufferURL.js new file mode 100644 index 00000000..28730022 --- /dev/null +++ b/src/platform/web/dom/BufferURL.js @@ -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; + } +} diff --git a/src/platform/web/dom/Crypto.js b/src/platform/web/dom/Crypto.js index e28d104b..f80c1a40 100644 --- a/src/platform/web/dom/Crypto.js +++ b/src/platform/web/dom/Crypto.js @@ -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)); } } diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 33d43ad0..13e2b32f 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -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; diff --git a/src/platform/web/dom/request/xhr.js b/src/platform/web/dom/request/xhr.js index 38a189fd..9574c8cf 100644 --- a/src/platform/web/dom/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -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}; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index b880c83d..9473b307 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -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; } diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js index cb5a3298..3ec7207c 100644 --- a/src/platform/web/ui/general/ListView.js +++ b/src/platform/web/ui/general/ListView.js @@ -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() {} } diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 2072b453..0cadbb71 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -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); + } } diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 69360b75..113fb1e4 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -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))]