/* Copyright 2024 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import { type MatrixEvent, EventType, MsgType } from "matrix-js-sdk/src/matrix"; import { type FileContent, type ImageContent, type MediaEventContent } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; import { LazyValue } from "./LazyValue"; import { type Media, mediaFromContent } from "../customisations/Media"; import { decryptFile } from "./DecryptFile"; import { type IDestroyable } from "./IDestroyable"; import { getBlobSafeMimeType } from "./blobs.ts"; // TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192 export class MediaEventHelper implements IDestroyable { // Either an HTTP or Object URL (when encrypted) to the media. public readonly sourceUrl: LazyValue; public readonly thumbnailUrl: LazyValue; // Either the raw or decrypted (when encrypted) contents of the file. public readonly sourceBlob: LazyValue; public readonly thumbnailBlob: LazyValue; public readonly media: Media; public constructor(private event: MatrixEvent) { this.sourceUrl = new LazyValue(this.prepareSourceUrl); this.thumbnailUrl = new LazyValue(this.prepareThumbnailUrl); this.sourceBlob = new LazyValue(this.fetchSource); this.thumbnailBlob = new LazyValue(this.fetchThumbnail); this.media = mediaFromContent(this.event.getContent()); } public get fileName(): string { return ( this.event.getContent().filename || this.event.getContent().body || "download" ); } public destroy(): void { if (this.media.isEncrypted) { if (this.sourceUrl.cachedValue) URL.revokeObjectURL(this.sourceUrl.cachedValue); if (this.thumbnailUrl.cachedValue) URL.revokeObjectURL(this.thumbnailUrl.cachedValue); } } private prepareSourceUrl = async (): Promise => { if (this.media.isEncrypted) { const blob = await this.sourceBlob.value; return URL.createObjectURL(blob); } else { return this.media.srcHttp; } }; private prepareThumbnailUrl = async (): Promise => { if (this.media.isEncrypted) { const blob = await this.thumbnailBlob.value; if (blob === null) return null; return URL.createObjectURL(blob); } else { return this.media.thumbnailHttp; } }; private fetchSource = (): Promise => { const content = this.event.getContent(); if (this.media.isEncrypted) { return decryptFile(content.file!, content.info); } return ( this.media .downloadSource() .then((r) => r.blob()) // Set the mime type from the event info on the blob .then((blob) => blob.slice(0, blob.size, getBlobSafeMimeType(content.info?.mimetype ?? blob.type))) ); }; private fetchThumbnail = (): Promise => { if (!this.media.hasThumbnail) return Promise.resolve(null); const content = this.event.getContent(); if (this.media.isEncrypted) { if (content.info?.thumbnail_file) { return decryptFile(content.info.thumbnail_file, content.info.thumbnail_info); } else { // "Should never happen" logger.warn("Media claims to have thumbnail and is encrypted, but no thumbnail_file found"); return Promise.resolve(null); } } const thumbnailHttp = this.media.thumbnailHttp; if (!thumbnailHttp) return Promise.resolve(null); return ( fetch(thumbnailHttp) .then((r) => r.blob()) // Set the mime type from the event info on the blob .then((blob) => blob.slice(0, blob.size, getBlobSafeMimeType(content.info?.thumbnail_info?.mimetype ?? blob.type)), ) ); }; public static isEligible(event: MatrixEvent): boolean { if (!event) return false; if (event.isRedacted()) return false; if (event.getType() === EventType.Sticker) return true; if (event.getType() !== EventType.RoomMessage) return false; const content = event.getContent(); const mediaMsgTypes: string[] = [MsgType.Video, MsgType.Audio, MsgType.Image, MsgType.File]; if (mediaMsgTypes.includes(content.msgtype!)) return true; if (typeof content.url === "string") return true; // Finally, it's probably not media return false; } /** * Determine if the media event in question supports being hidden in the timeline. * @param event Any matrix event. * @returns `true` if the media can be hidden, otherwise `false`. */ public static canHide(event: MatrixEvent): boolean { if (!event) return false; if (event.isRedacted()) return false; const content = event.getContent(); const hideTypes: string[] = [MsgType.Video, MsgType.Image]; if (hideTypes.includes(content.msgtype!)) return true; return false; } }