Populate info.duration for audio & video file uploads (#11225)

* Improve m.file m.image m.audio m.video types

* Populate `info.duration` for audio & video file uploads

* Fix tests

* Iterate types

* Improve coverage

* Fix test

* Add small delay to stabilise cypress test

* Fix test idempotency

* Improve coverage

* Slow down

* iterate
This commit is contained in:
Michael Telatynski 2023-07-17 13:07:58 +01:00 committed by GitHub
parent 8b8ca425d7
commit f04a0e2860
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 556 additions and 85 deletions

View file

@ -183,7 +183,7 @@ describe("FilePanel", () => {
}); });
}); });
it("should render the audio pleyer and play the audio file on the panel", () => { it("should render the audio player and play the audio file on the panel", () => {
// Upload an image file // Upload an image file
uploadFile("cypress/fixtures/1sec.ogg"); uploadFile("cypress/fixtures/1sec.ogg");
@ -202,10 +202,14 @@ describe("FilePanel", () => {
cy.contains(".mx_AudioPlayer_byline", "(3.56 KB)").should("exist"); // actual size cy.contains(".mx_AudioPlayer_byline", "(3.56 KB)").should("exist"); // actual size
}); });
// Assert that the duration counter is 00:01 before clicking the play button
cy.contains(".mx_AudioPlayer_mediaInfo time", "00:01").should("exist");
// Assert that the counter is zero before clicking the play button // Assert that the counter is zero before clicking the play button
cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist");
// Click the play button // Click the play button
cy.wait(500);
cy.findByRole("button", { name: "Play" }).click(); cy.findByRole("button", { name: "Play" }).click();
// Assert that the pause button is rendered // Assert that the pause button is rendered

View file

@ -33,7 +33,14 @@ import {
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { removeElement } from "matrix-js-sdk/src/utils"; import { removeElement } from "matrix-js-sdk/src/utils";
import { IEncryptedFile, IMediaEventContent, IMediaEventInfo } from "./customisations/models/IMediaEventContent"; import {
AudioInfo,
EncryptedFile,
ImageInfo,
IMediaEventContent,
IMediaEventInfo,
VideoInfo,
} from "./customisations/models/IMediaEventContent";
import dis from "./dispatcher/dispatcher"; import dis from "./dispatcher/dispatcher";
import { _t } from "./languageHandler"; import { _t } from "./languageHandler";
import Modal from "./Modal"; import Modal from "./Modal";
@ -146,11 +153,7 @@ const ALWAYS_INCLUDE_THUMBNAIL = ["image/avif", "image/webp"];
* @param {File} imageFile The image to read and thumbnail. * @param {File} imageFile The image to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info. * @return {Promise} A promise that resolves with the attachment info.
*/ */
async function infoForImageFile( async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File): Promise<ImageInfo> {
matrixClient: MatrixClient,
roomId: string,
imageFile: File,
): Promise<Partial<IMediaEventInfo>> {
let thumbnailType = "image/png"; let thumbnailType = "image/png";
if (imageFile.type === "image/jpeg") { if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg"; thumbnailType = "image/jpeg";
@ -184,16 +187,59 @@ async function infoForImageFile(
return imageInfo; return imageInfo;
} }
/**
* Load a file into a newly created audio element and load the metadata
*
* @param {File} audioFile The file to load in an audio element.
* @return {Promise} A promise that resolves with the audio element.
*/
function loadAudioElement(audioFile: File): Promise<HTMLAudioElement> {
return new Promise((resolve, reject) => {
// Load the file into a html element
const audio = document.createElement("audio");
audio.preload = "metadata";
audio.muted = true;
const reader = new FileReader();
reader.onload = function (ev): void {
audio.onloadedmetadata = async function (): Promise<void> {
resolve(audio);
};
audio.onerror = function (e): void {
reject(e);
};
audio.src = ev.target?.result as string;
};
reader.onerror = function (e): void {
reject(e);
};
reader.readAsDataURL(audioFile);
});
}
/**
* Read the metadata for an audio file.
*
* @param {File} audioFile The audio to read.
* @return {Promise} A promise that resolves with the attachment info.
*/
async function infoForAudioFile(audioFile: File): Promise<AudioInfo> {
const audio = await loadAudioElement(audioFile);
return { duration: Math.ceil(audio.duration * 1000) };
}
/** /**
* Load a file into a newly created video element and pull some strings * Load a file into a newly created video element and pull some strings
* in an attempt to guarantee the first frame will be showing. * in an attempt to guarantee the first frame will be showing.
* *
* @param {File} videoFile The file to load in an video element. * @param {File} videoFile The file to load in a video element.
* @return {Promise} A promise that resolves with the video image element. * @return {Promise} A promise that resolves with the video element.
*/ */
function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> { function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Load the file into an html element // Load the file into a html element
const video = document.createElement("video"); const video = document.createElement("video");
video.preload = "metadata"; video.preload = "metadata";
video.playsInline = true; video.playsInline = true;
@ -237,20 +283,17 @@ function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
* @param {File} videoFile The video to read and thumbnail. * @param {File} videoFile The video to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info. * @return {Promise} A promise that resolves with the attachment info.
*/ */
function infoForVideoFile( function infoForVideoFile(matrixClient: MatrixClient, roomId: string, videoFile: File): Promise<VideoInfo> {
matrixClient: MatrixClient,
roomId: string,
videoFile: File,
): Promise<Partial<IMediaEventInfo>> {
const thumbnailType = "image/jpeg"; const thumbnailType = "image/jpeg";
let videoInfo: Partial<IMediaEventInfo>; const videoInfo: VideoInfo = {};
return loadVideoElement(videoFile) return loadVideoElement(videoFile)
.then((video) => { .then((video) => {
videoInfo.duration = Math.ceil(video.duration * 1000);
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
}) })
.then((result) => { .then((result) => {
videoInfo = result.info; Object.assign(videoInfo, result.info);
return uploadFile(matrixClient, roomId, result.thumbnail); return uploadFile(matrixClient, roomId, result.thumbnail);
}) })
.then((result) => { .then((result) => {
@ -299,7 +342,7 @@ export async function uploadFile(
file: File | Blob, file: File | Blob,
progressHandler?: UploadOpts["progressHandler"], progressHandler?: UploadOpts["progressHandler"],
controller?: AbortController, controller?: AbortController,
): Promise<{ url?: string; file?: IEncryptedFile }> { ): Promise<{ url?: string; file?: EncryptedFile }> {
const abortController = controller ?? new AbortController(); const abortController = controller ?? new AbortController();
// If the room is encrypted then encrypt the file before uploading it. // If the room is encrypted then encrypt the file before uploading it.
@ -329,7 +372,7 @@ export async function uploadFile(
file: { file: {
...encryptResult.info, ...encryptResult.info,
url, url,
} as IEncryptedFile, } as EncryptedFile,
}; };
} else { } else {
const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController }); const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController });
@ -546,6 +589,14 @@ export default class ContentMessages {
} }
} else if (file.type.indexOf("audio/") === 0) { } else if (file.type.indexOf("audio/") === 0) {
content.msgtype = MsgType.Audio; content.msgtype = MsgType.Audio;
try {
const audioInfo = await infoForAudioFile(file);
Object.assign(content.info, audioInfo);
} catch (e) {
// Failed to process audio file, fall back to uploading an m.file
logger.error(e);
content.msgtype = MsgType.File;
}
} else if (file.type.indexOf("video/") === 0) { } else if (file.type.indexOf("video/") === 0) {
content.msgtype = MsgType.Video; content.msgtype = MsgType.Video;
try { try {

View file

@ -198,7 +198,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted; const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted;
const contentUrl = this.getContentUrl(); const contentUrl = this.getContentUrl();
const contentFileSize = this.content.info ? this.content.info.size : null; const contentFileSize = this.content.info ? this.content.info.size : null;
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream"; const fileType = this.content.info?.mimetype ?? "application/octet-stream";
let placeholder: React.ReactNode = null; let placeholder: React.ReactNode = null;
if (this.props.showGenericPlaceholder) { if (this.props.showGenericPlaceholder) {

View file

@ -29,7 +29,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import { Media, mediaFromContent } from "../../../customisations/Media"; import { Media, mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media"; import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { ImageContent } from "../../../customisations/models/IMediaEventContent";
import ImageView from "../elements/ImageView"; import ImageView from "../elements/ImageView";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize"; import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
@ -102,7 +102,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
return; return;
} }
const content = this.props.mxEvent.getContent<IMediaEventContent>(); const content = this.props.mxEvent.getContent<ImageContent>();
const httpUrl = this.state.contentUrl; const httpUrl = this.state.contentUrl;
if (!httpUrl) return; if (!httpUrl) return;
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = { const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
@ -212,7 +212,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
const thumbWidth = 800; const thumbWidth = 800;
const thumbHeight = 600; const thumbHeight = 600;
const content = this.props.mxEvent.getContent<IMediaEventContent>(); const content = this.props.mxEvent.getContent<ImageContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
const info = content.info; const info = content.info;
@ -287,7 +287,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
contentUrl = this.getContentUrl(); contentUrl = this.getContentUrl();
} }
const content = this.props.mxEvent.getContent<IMediaEventContent>(); const content = this.props.mxEvent.getContent<ImageContent>();
let isAnimated = mayBeAnimated(content.info?.mimetype); let isAnimated = mayBeAnimated(content.info?.mimetype);
// If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server // If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server
@ -317,7 +317,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} }
if (isAnimated) { if (isAnimated) {
const thumb = await createThumbnail(img, img.width, img.height, content.info!.mimetype, false); const thumb = await createThumbnail(
img,
img.width,
img.height,
content.info?.mimetype ?? "image/jpeg",
false,
);
thumbUrl = URL.createObjectURL(thumb.thumbnail); thumbUrl = URL.createObjectURL(thumb.thumbnail);
} }
} catch (error) { } catch (error) {
@ -381,7 +387,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} }
} }
protected getBanner(content: IMediaEventContent): ReactNode { protected getBanner(content: ImageContent): ReactNode {
// Hide it for the threads list & the file panel where we show it as text anyway. // Hide it for the threads list & the file panel where we show it as text anyway.
if ( if (
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType) [TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
@ -395,7 +401,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
protected messageContent( protected messageContent(
contentUrl: string | null, contentUrl: string | null,
thumbUrl: string | null, thumbUrl: string | null,
content: IMediaEventContent, content: ImageContent,
forcedHeight?: number, forcedHeight?: number,
): ReactNode { ): ReactNode {
if (!thumbUrl) thumbUrl = contentUrl; // fallback if (!thumbUrl) thumbUrl = contentUrl; // fallback
@ -591,7 +597,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} }
public render(): React.ReactNode { public render(): React.ReactNode {
const content = this.props.mxEvent.getContent<IMediaEventContent>(); const content = this.props.mxEvent.getContent<ImageContent>();
if (this.state.error) { if (this.state.error) {
let errorText = _t("Unable to show image due to error"); let errorText = _t("Unable to show image due to error");

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import MImageBody from "./MImageBody"; import MImageBody from "./MImageBody";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { ImageContent } from "../../../customisations/models/IMediaEventContent";
const FORCED_IMAGE_HEIGHT = 44; const FORCED_IMAGE_HEIGHT = 44;
@ -35,7 +35,7 @@ export default class MImageReplyBody extends MImageBody {
return super.render(); return super.render();
} }
const content = this.props.mxEvent.getContent<IMediaEventContent>(); const content = this.props.mxEvent.getContent<ImageContent>();
const thumbnail = this.state.contentUrl const thumbnail = this.state.contentUrl
? this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT) ? this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT)
: undefined; : undefined;

View file

@ -16,8 +16,21 @@
// TODO: These types should be elsewhere. // TODO: These types should be elsewhere.
export interface IEncryptedFile { import { MsgType } from "matrix-js-sdk/src/matrix";
import { BLURHASH_FIELD } from "../../utils/image-media";
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#extensions-to-mroommessage-msgtypes
*/
export interface EncryptedFile {
/**
* The URL to the file.
*/
url: string; url: string;
/**
* A JSON Web Key object.
*/
key: { key: {
alg: string; alg: string;
key_ops: string[]; // eslint-disable-line camelcase key_ops: string[]; // eslint-disable-line camelcase
@ -25,43 +38,204 @@ export interface IEncryptedFile {
k: string; k: string;
ext: boolean; ext: boolean;
}; };
/**
* The 128-bit unique counter block used by AES-CTR, encoded as unpadded base64.
*/
iv: string; iv: string;
/**
* A map from an algorithm name to a hash of the ciphertext, encoded as unpadded base64.
* Clients should support the SHA-256 hash, which uses the key sha256.
*/
hashes: { [alg: string]: string }; hashes: { [alg: string]: string };
/**
* Version of the encrypted attachment's protocol. Must be v2.
*/
v: string; v: string;
} }
export interface IMediaEventInfo { interface ThumbnailInfo {
thumbnail_url?: string; // eslint-disable-line camelcase /**
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase * The mimetype of the image, e.g. image/jpeg.
thumbnail_info?: { */
// eslint-disable-line camelcase mimetype?: string;
mimetype: string; /**
w?: number; * The intended display width of the image in pixels.
h?: number; * This may differ from the intrinsic dimensions of the image file.
size?: number; */
};
mimetype: string;
w?: number; w?: number;
/**
* The intended display height of the image in pixels.
* This may differ from the intrinsic dimensions of the image file.
*/
h?: number; h?: number;
/**
* Size of the image in bytes.
*/
size?: number; size?: number;
} }
export interface IMediaEventContent { interface BaseInfo {
msgtype: string; mimetype?: string;
body?: string; size?: number;
filename?: string; // `m.file` optional field
url?: string; // required on unencrypted media
file?: IEncryptedFile; // required for *encrypted* media
info?: IMediaEventInfo;
} }
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#mfile
*/
export interface FileInfo extends BaseInfo {
/**
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/2448
*/
[BLURHASH_FIELD]?: string;
/**
* Information on the encrypted thumbnail file, as specified in End-to-end encryption.
* Only present if the thumbnail is encrypted.
* @see https://spec.matrix.org/v1.7/client-server-api/#sending-encrypted-attachments
*/
thumbnail_file?: EncryptedFile;
/**
* Metadata about the image referred to in thumbnail_url.
*/
thumbnail_info?: ThumbnailInfo;
/**
* The URL to the thumbnail of the file. Only present if the thumbnail is unencrypted.
*/
thumbnail_url?: string;
}
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#mimage
*
*/
export interface ImageInfo extends FileInfo, ThumbnailInfo {}
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#mimage
*/
export interface AudioInfo extends BaseInfo {
/**
* The duration of the audio in milliseconds.
*/
duration?: number;
}
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#mvideo
*/
export interface VideoInfo extends AudioInfo, ImageInfo {
/**
* The duration of the video in milliseconds.
*/
duration?: number;
}
export type IMediaEventInfo = FileInfo | ImageInfo | AudioInfo | VideoInfo;
interface BaseContent {
/**
* Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption.
* @see https://spec.matrix.org/v1.7/client-server-api/#sending-encrypted-attachments
*/
file?: EncryptedFile;
/**
* Required if the file is unencrypted. The URL (typically mxc:// URI) to the file.
*/
url?: string;
}
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#mfile
*/
export interface FileContent extends BaseContent {
/**
* A human-readable description of the file.
* This is recommended to be the filename of the original upload.
*/
body: string;
/**
* The original filename of the uploaded file.
*/
filename?: string;
/**
* Information about the file referred to in url.
*/
info?: FileInfo;
/**
* One of: [m.file].
*/
msgtype: MsgType.File;
}
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#mimage
*/
export interface ImageContent extends BaseContent {
/**
* A textual representation of the image.
* This could be the alt text of the image, the filename of the image,
* or some kind of content description for accessibility e.g. image attachment.
*/
body: string;
/**
* Metadata about the image referred to in url.
*/
info?: ImageInfo;
/**
* One of: [m.image].
*/
msgtype: MsgType.Image;
}
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#maudio
*/
export interface AudioContent extends BaseContent {
/**
* A description of the audio e.g. Bee Gees - Stayin Alive,
* or some kind of content description for accessibility e.g. audio attachment.
*/
body: string;
/**
* Metadata for the audio clip referred to in url.
*/
info?: AudioInfo;
/**
* One of: [m.audio].
*/
msgtype: MsgType.Audio;
}
/**
* @see https://spec.matrix.org/v1.7/client-server-api/#mvideo
*/
export interface VideoContent extends BaseContent {
/**
* A description of the video e.g. Gangnam style,
* or some kind of content description for accessibility e.g. video attachment.
*/
body: string;
/**
* Metadata about the video clip referred to in url.
*/
info?: VideoInfo;
/**
* One of: [m.video].
*/
msgtype: MsgType.Video;
}
/**
* Type representing media event contents for `m.room.message` events listed in the Matrix specification
*/
export type IMediaEventContent = FileContent | ImageContent | AudioContent | VideoContent;
export interface IPreparedMedia extends IMediaObject { export interface IPreparedMedia extends IMediaObject {
thumbnail?: IMediaObject; thumbnail?: IMediaObject;
} }
export interface IMediaObject { export interface IMediaObject {
mxc: string; mxc: string;
file?: IEncryptedFile; file?: EncryptedFile;
} }
/** /**
@ -73,12 +247,17 @@ export interface IMediaObject {
*/ */
export function prepEventContentAsMedia(content: Partial<IMediaEventContent>): IPreparedMedia { export function prepEventContentAsMedia(content: Partial<IMediaEventContent>): IPreparedMedia {
let thumbnail: IMediaObject | undefined; let thumbnail: IMediaObject | undefined;
if (content?.info?.thumbnail_url) { if (typeof content?.info === "object" && "thumbnail_url" in content.info && content.info.thumbnail_url) {
thumbnail = { thumbnail = {
mxc: content.info.thumbnail_url, mxc: content.info.thumbnail_url,
file: content.info.thumbnail_file, file: content.info.thumbnail_file,
}; };
} else if (content?.info?.thumbnail_file?.url) { } else if (
typeof content?.info === "object" &&
"thumbnail_file" in content.info &&
typeof content?.info?.thumbnail_file === "object" &&
content?.info?.thumbnail_file?.url
) {
thumbnail = { thumbnail = {
mxc: content.info.thumbnail_file.url, mxc: content.info.thumbnail_file.url,
file: content.info.thumbnail_file, file: content.info.thumbnail_file,

View file

@ -16,11 +16,11 @@ limitations under the License.
import { IEventRelation, UploadProgress } from "matrix-js-sdk/src/matrix"; import { IEventRelation, UploadProgress } from "matrix-js-sdk/src/matrix";
import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; import { EncryptedFile } from "../customisations/models/IMediaEventContent";
export class RoomUpload { export class RoomUpload {
public readonly abortController = new AbortController(); public readonly abortController = new AbortController();
public promise?: Promise<{ url?: string; file?: IEncryptedFile }>; public promise?: Promise<{ url?: string; file?: EncryptedFile }>;
private uploaded = 0; private uploaded = 0;
public constructor( public constructor(

View file

@ -19,7 +19,7 @@ import encrypt from "matrix-encrypt-attachment";
import { parseErrorResponse } from "matrix-js-sdk/src/http-api"; import { parseErrorResponse } from "matrix-js-sdk/src/http-api";
import { mediaFromContent } from "../customisations/Media"; import { mediaFromContent } from "../customisations/Media";
import { IEncryptedFile, IMediaEventInfo } from "../customisations/models/IMediaEventContent"; import { EncryptedFile, IMediaEventInfo } from "../customisations/models/IMediaEventContent";
import { getBlobSafeMimeType } from "./blobs"; import { getBlobSafeMimeType } from "./blobs";
export class DownloadError extends Error { export class DownloadError extends Error {
@ -40,14 +40,14 @@ export class DecryptError extends Error {
/** /**
* Decrypt a file attached to a matrix event. * Decrypt a file attached to a matrix event.
* @param {IEncryptedFile} file The encrypted file information taken from the matrix event. * @param {EncryptedFile} file The encrypted file information taken from the matrix event.
* This passed to [link]{@link https://github.com/matrix-org/matrix-encrypt-attachment} * This passed to [link]{@link https://github.com/matrix-org/matrix-encrypt-attachment}
* as the encryption info object, so will also have the those keys in addition to * as the encryption info object, so will also have the those keys in addition to
* the keys below. * the keys below.
* @param {IMediaEventInfo} info The info parameter taken from the matrix event. * @param {IMediaEventInfo} info The info parameter taken from the matrix event.
* @returns {Promise<Blob>} Resolves to a Blob of the file. * @returns {Promise<Blob>} Resolves to a Blob of the file.
*/ */
export async function decryptFile(file?: IEncryptedFile, info?: IMediaEventInfo): Promise<Blob> { export async function decryptFile(file?: EncryptedFile, info?: IMediaEventInfo): Promise<Blob> {
// throws if file is falsy // throws if file is falsy
const media = mediaFromContent({ file }); const media = mediaFromContent({ file });

View file

@ -21,7 +21,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { LazyValue } from "./LazyValue"; import { LazyValue } from "./LazyValue";
import { Media, mediaFromContent } from "../customisations/Media"; import { Media, mediaFromContent } from "../customisations/Media";
import { decryptFile } from "./DecryptFile"; import { decryptFile } from "./DecryptFile";
import { IMediaEventContent } from "../customisations/models/IMediaEventContent"; import { FileContent, ImageContent, IMediaEventContent } from "../customisations/models/IMediaEventContent";
import { IDestroyable } from "./IDestroyable"; import { IDestroyable } from "./IDestroyable";
// TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192 // TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192
@ -48,7 +48,7 @@ export class MediaEventHelper implements IDestroyable {
public get fileName(): string { public get fileName(): string {
return ( return (
this.event.getContent<IMediaEventContent>().filename || this.event.getContent<FileContent>().filename ||
this.event.getContent<IMediaEventContent>().body || this.event.getContent<IMediaEventContent>().body ||
"download" "download"
); );
@ -92,7 +92,7 @@ export class MediaEventHelper implements IDestroyable {
if (!this.media.hasThumbnail) return Promise.resolve(null); if (!this.media.hasThumbnail) return Promise.resolve(null);
if (this.media.isEncrypted) { if (this.media.isEncrypted) {
const content = this.event.getContent<IMediaEventContent>(); const content = this.event.getContent<ImageContent>();
if (content.info?.thumbnail_file) { if (content.info?.thumbnail_file) {
return decryptFile(content.info.thumbnail_file, content.info.thumbnail_info); return decryptFile(content.info.thumbnail_file, content.info.thumbnail_info);
} else { } else {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { BlurhashEncoder } from "../BlurhashEncoder"; import { BlurhashEncoder } from "../BlurhashEncoder";
import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; import { EncryptedFile } from "../customisations/models/IMediaEventContent";
type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
@ -33,7 +33,7 @@ interface IThumbnail {
h: number; h: number;
[BLURHASH_FIELD]?: string; [BLURHASH_FIELD]?: string;
thumbnail_url?: string; thumbnail_url?: string;
thumbnail_file?: IEncryptedFile; thumbnail_file?: EncryptedFile;
}; };
thumbnail: Blob; thumbnail: Blob;
} }

View file

@ -38,7 +38,7 @@ import {
VoiceBroadcastRecorderEvent, VoiceBroadcastRecorderEvent,
} from ".."; } from "..";
import { uploadFile } from "../../ContentMessages"; import { uploadFile } from "../../ContentMessages";
import { IEncryptedFile } from "../../customisations/models/IMediaEventContent"; import { EncryptedFile } from "../../customisations/models/IMediaEventContent";
import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent"; import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent";
import { IDestroyable } from "../../utils/IDestroyable"; import { IDestroyable } from "../../utils/IDestroyable";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
@ -367,7 +367,7 @@ export class VoiceBroadcastRecording
); );
} }
private async sendVoiceMessage(chunk: ChunkRecordedPayload, url?: string, file?: IEncryptedFile): Promise<void> { private async sendVoiceMessage(chunk: ChunkRecordedPayload, url?: string, file?: EncryptedFile): Promise<void> {
/** /**
* Increment the last sequence number and use it for this message. * Increment the last sequence number and use it for this message.
* Done outside of the sendMessageFn to get a scoped value. * Done outside of the sendMessageFn to get a scoped value.

View file

@ -163,6 +163,11 @@ describe("ContentMessages", () => {
return 800; return 800;
}, },
}); });
Object.defineProperty(element, "duration", {
get() {
return 123;
},
});
} }
return element; return element;
}); });
@ -176,11 +181,31 @@ describe("ContentMessages", () => {
expect.objectContaining({ expect.objectContaining({
url: "mxc://server/file", url: "mxc://server/file",
msgtype: "m.video", msgtype: "m.video",
info: expect.objectContaining({
duration: 123000,
}),
}), }),
); );
}); });
it("should use m.audio for audio files", async () => { it("should use m.audio for audio files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "audio") {
Object.defineProperty(element, "duration", {
get() {
return 621;
},
});
Object.defineProperty(element, "src", {
set() {
element.onloadedmetadata!(new Event("loadedmetadata"));
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "audio/mp3" }); const file = new File([], "fileName", { type: "audio/mp3" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
@ -190,6 +215,34 @@ describe("ContentMessages", () => {
expect.objectContaining({ expect.objectContaining({
url: "mxc://server/file", url: "mxc://server/file",
msgtype: "m.audio", msgtype: "m.audio",
info: expect.objectContaining({
duration: 621000,
}),
}),
);
});
it("should fall back to m.file for invalid audio files", async () => {
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
const element = createElement(tagName);
if (tagName === "audio") {
Object.defineProperty(element, "src", {
set() {
element.onerror!("fail");
},
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "audio/mp3" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(
roomId,
null,
expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
}), }),
); );
}); });

View file

@ -15,11 +15,13 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest"; import fetchMock from "fetch-mock-jest";
import encrypt from "matrix-encrypt-attachment"; import encrypt from "matrix-encrypt-attachment";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import fs from "fs";
import path from "path";
import MImageBody from "../../../../src/components/views/messages/MImageBody"; import MImageBody from "../../../../src/components/views/messages/MImageBody";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
@ -56,7 +58,11 @@ describe("<MImageBody/>", () => {
}); });
const url = "https://server/_matrix/media/r0/download/server/encrypted-image"; const url = "https://server/_matrix/media/r0/download/server/encrypted-image";
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockReturnValue(url); cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const encryptedMediaEvent = new MatrixEvent({ const encryptedMediaEvent = new MatrixEvent({
room_id: "!room:server", room_id: "!room:server",
sender: userId, sender: userId,
@ -175,12 +181,6 @@ describe("<MImageBody/>", () => {
it("should fall back to /download/ if /thumbnail/ fails", async () => { it("should fall back to /download/ if /thumbnail/ fails", async () => {
const thumbUrl = "https://server/_matrix/media/r0/thumbnail/server/image?width=800&height=600&method=scale"; const thumbUrl = "https://server/_matrix/media/r0/thumbnail/server/image?width=800&height=600&method=scale";
const downloadUrl = "https://server/_matrix/media/r0/download/server/image"; const downloadUrl = "https://server/_matrix/media/r0/download/server/image";
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const event = new MatrixEvent({ const event = new MatrixEvent({
room_id: "!room:server", room_id: "!room:server",
@ -206,4 +206,56 @@ describe("<MImageBody/>", () => {
fireEvent.error(img); fireEvent.error(img);
expect(img).toHaveProperty("src", downloadUrl); expect(img).toHaveProperty("src", downloadUrl);
}); });
it("should generate a thumbnail if one isn't included for animated media", async () => {
Object.defineProperty(global.Image.prototype, "src", {
set(src) {
window.setTimeout(() => this.onload());
},
});
Object.defineProperty(global.Image.prototype, "height", {
get() {
return 600;
},
});
Object.defineProperty(global.Image.prototype, "width", {
get() {
return 800;
},
});
mocked(global.URL.createObjectURL).mockReturnValue("blob:generated-thumb");
fetchMock.getOnce(
"https://server/_matrix/media/r0/download/server/image",
{
body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")),
},
{ sendAsJson: false },
);
const event = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test image",
info: {
w: 40,
h: 50,
mimetype: "image/webp",
},
url: "mxc://server/image",
},
});
const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
);
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
// thumbnail with dimensions present
expect(container).toMatchSnapshot();
});
}); });

View file

@ -15,23 +15,22 @@ limitations under the License.
*/ */
import React, { ComponentProps } from "react"; import React, { ComponentProps } from "react";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { EventType, getHttpUriForMxc, IContent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { render, RenderResult } from "@testing-library/react"; import { render, RenderResult } from "@testing-library/react";
import fetchMock from "fetch-mock-jest";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper";
import { getMockClientWithEventEmitter } from "../../../test-utils"; import {
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsDevice,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../test-utils";
import MVideoBody from "../../../../src/components/views/messages/MVideoBody"; import MVideoBody from "../../../../src/components/views/messages/MVideoBody";
jest.mock("../../../../src/customisations/Media", () => {
return {
mediaFromContent: () => {
return { isEncrypted: false };
},
};
});
describe("MVideoBody", () => { describe("MVideoBody", () => {
it("does not crash when given a portrait image", () => { it("does not crash when given a portrait image", () => {
// Check for an unreliable crash caused by a fractional-sized // Check for an unreliable crash caused by a fractional-sized
@ -40,6 +39,58 @@ describe("MVideoBody", () => {
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
// If we get here, we did not crash. // If we get here, we did not crash.
}); });
it("should show poster for encrypted media before downloading it", async () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({
unstable_features: {
"org.matrix.msc3882": true,
"org.matrix.msc3886": true,
},
}),
});
const thumbUrl = "https://server/_matrix/media/r0/download/server/encrypted-poster";
fetchMock.getOnce(thumbUrl, { status: 200 });
// eslint-disable-next-line no-restricted-properties
cli.mxcUrlToHttp.mockImplementation(
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
},
);
const encryptedMediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test video",
info: {
duration: 420,
w: 40,
h: 50,
thumbnail_file: {
url: "mxc://server/encrypted-poster",
},
},
file: {
url: "mxc://server/encrypted-image",
},
},
});
const { asFragment } = render(
<MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />,
);
expect(asFragment()).toMatchSnapshot();
});
}); });
function makeMVideoBody(w: number, h: number): RenderResult { function makeMVideoBody(w: number, h: number): RenderResult {

View file

@ -1,5 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MImageBody/> should generate a thumbnail if one isn't included for animated media 1`] = `
<div>
<div
class="mx_MImageBody"
>
<a
href="https://server/_matrix/media/r0/download/server/image"
>
<div
class="mx_MImageBody_thumbnail_container"
style="max-height: 50px; max-width: 40px;"
>
<div
class="mx_MImageBody_placeholder"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
style="max-height: 50px; max-width: 40px;"
>
<img
alt="alt for a test image"
class="mx_MImageBody_thumbnail"
src="blob:generated-thumb"
/>
<p
class="mx_MImageBody_gifLabel"
>
GIF
</p>
</div>
<div
style="height: 50px; width: 40px;"
/>
</div>
</a>
</div>
</div>
`;
exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1`] = ` exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1`] = `
<div> <div>
<div <div

View file

@ -23,3 +23,28 @@ exports[`MVideoBody does not crash when given a portrait image 1`] = `
</span> </span>
</DocumentFragment> </DocumentFragment>
`; `;
exports[`MVideoBody should show poster for encrypted media before downloading it 1`] = `
<DocumentFragment>
<span
class="mx_MVideoBody"
>
<div
class="mx_MVideoBody_container"
style="max-width: 40px; max-height: 50px;"
>
<video
class="mx_MVideoBody"
controls=""
controlslist="nodownload"
poster="https://server/_matrix/media/r0/download/server/encrypted-poster"
preload="none"
title="alt for a test video"
/>
<div
style="width: 40px; height: 50px;"
/>
</div>
</span>
</DocumentFragment>
`;

View file

@ -31,7 +31,7 @@ import { Relations } from "matrix-js-sdk/src/models/relations";
import { SyncState } from "matrix-js-sdk/src/sync"; import { SyncState } from "matrix-js-sdk/src/sync";
import { uploadFile } from "../../../src/ContentMessages"; import { uploadFile } from "../../../src/ContentMessages";
import { IEncryptedFile } from "../../../src/customisations/models/IMediaEventContent"; import { EncryptedFile } from "../../../src/customisations/models/IMediaEventContent";
import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent"; import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent";
import { import {
createVoiceBroadcastRecorder, createVoiceBroadcastRecorder,
@ -82,7 +82,7 @@ jest.mock("../../../src/utils/createVoiceMessageContent", () => ({
describe("VoiceBroadcastRecording", () => { describe("VoiceBroadcastRecording", () => {
const roomId = "!room:example.com"; const roomId = "!room:example.com";
const uploadedUrl = "mxc://example.com/vb"; const uploadedUrl = "mxc://example.com/vb";
const uploadedFile = { file: true } as unknown as IEncryptedFile; const uploadedFile = { file: true } as unknown as EncryptedFile;
const maxLength = getMaxBroadcastLength(); const maxLength = getMaxBroadcastLength();
let room: Room; let room: Room;
let client: MatrixClient; let client: MatrixClient;
@ -223,7 +223,7 @@ describe("VoiceBroadcastRecording", () => {
mimetype: string, mimetype: string,
duration: number, duration: number,
size: number, size: number,
file?: IEncryptedFile, file?: EncryptedFile,
waveform?: number[], waveform?: number[],
) => { ) => {
return { return {