Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit f04a0e2

Browse files
authored
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
1 parent 8b8ca42 commit f04a0e2

File tree

17 files changed

+556
-85
lines changed

17 files changed

+556
-85
lines changed

cypress/e2e/right-panel/file-panel.spec.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe("FilePanel", () => {
183183
});
184184
});
185185

186-
it("should render the audio pleyer and play the audio file on the panel", () => {
186+
it("should render the audio player and play the audio file on the panel", () => {
187187
// Upload an image file
188188
uploadFile("cypress/fixtures/1sec.ogg");
189189

@@ -202,10 +202,14 @@ describe("FilePanel", () => {
202202
cy.contains(".mx_AudioPlayer_byline", "(3.56 KB)").should("exist"); // actual size
203203
});
204204

205+
// Assert that the duration counter is 00:01 before clicking the play button
206+
cy.contains(".mx_AudioPlayer_mediaInfo time", "00:01").should("exist");
207+
205208
// Assert that the counter is zero before clicking the play button
206209
cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist");
207210

208211
// Click the play button
212+
cy.wait(500);
209213
cy.findByRole("button", { name: "Play" }).click();
210214

211215
// Assert that the pause button is rendered

src/ContentMessages.ts

+69-18
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ import {
3333
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
3434
import { removeElement } from "matrix-js-sdk/src/utils";
3535

36-
import { IEncryptedFile, IMediaEventContent, IMediaEventInfo } from "./customisations/models/IMediaEventContent";
36+
import {
37+
AudioInfo,
38+
EncryptedFile,
39+
ImageInfo,
40+
IMediaEventContent,
41+
IMediaEventInfo,
42+
VideoInfo,
43+
} from "./customisations/models/IMediaEventContent";
3744
import dis from "./dispatcher/dispatcher";
3845
import { _t } from "./languageHandler";
3946
import Modal from "./Modal";
@@ -146,11 +153,7 @@ const ALWAYS_INCLUDE_THUMBNAIL = ["image/avif", "image/webp"];
146153
* @param {File} imageFile The image to read and thumbnail.
147154
* @return {Promise} A promise that resolves with the attachment info.
148155
*/
149-
async function infoForImageFile(
150-
matrixClient: MatrixClient,
151-
roomId: string,
152-
imageFile: File,
153-
): Promise<Partial<IMediaEventInfo>> {
156+
async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File): Promise<ImageInfo> {
154157
let thumbnailType = "image/png";
155158
if (imageFile.type === "image/jpeg") {
156159
thumbnailType = "image/jpeg";
@@ -184,16 +187,59 @@ async function infoForImageFile(
184187
return imageInfo;
185188
}
186189

190+
/**
191+
* Load a file into a newly created audio element and load the metadata
192+
*
193+
* @param {File} audioFile The file to load in an audio element.
194+
* @return {Promise} A promise that resolves with the audio element.
195+
*/
196+
function loadAudioElement(audioFile: File): Promise<HTMLAudioElement> {
197+
return new Promise((resolve, reject) => {
198+
// Load the file into a html element
199+
const audio = document.createElement("audio");
200+
audio.preload = "metadata";
201+
audio.muted = true;
202+
203+
const reader = new FileReader();
204+
205+
reader.onload = function (ev): void {
206+
audio.onloadedmetadata = async function (): Promise<void> {
207+
resolve(audio);
208+
};
209+
audio.onerror = function (e): void {
210+
reject(e);
211+
};
212+
213+
audio.src = ev.target?.result as string;
214+
};
215+
reader.onerror = function (e): void {
216+
reject(e);
217+
};
218+
reader.readAsDataURL(audioFile);
219+
});
220+
}
221+
222+
/**
223+
* Read the metadata for an audio file.
224+
*
225+
* @param {File} audioFile The audio to read.
226+
* @return {Promise} A promise that resolves with the attachment info.
227+
*/
228+
async function infoForAudioFile(audioFile: File): Promise<AudioInfo> {
229+
const audio = await loadAudioElement(audioFile);
230+
return { duration: Math.ceil(audio.duration * 1000) };
231+
}
232+
187233
/**
188234
* Load a file into a newly created video element and pull some strings
189235
* in an attempt to guarantee the first frame will be showing.
190236
*
191-
* @param {File} videoFile The file to load in an video element.
192-
* @return {Promise} A promise that resolves with the video image element.
237+
* @param {File} videoFile The file to load in a video element.
238+
* @return {Promise} A promise that resolves with the video element.
193239
*/
194240
function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
195241
return new Promise((resolve, reject) => {
196-
// Load the file into an html element
242+
// Load the file into a html element
197243
const video = document.createElement("video");
198244
video.preload = "metadata";
199245
video.playsInline = true;
@@ -237,20 +283,17 @@ function loadVideoElement(videoFile: File): Promise<HTMLVideoElement> {
237283
* @param {File} videoFile The video to read and thumbnail.
238284
* @return {Promise} A promise that resolves with the attachment info.
239285
*/
240-
function infoForVideoFile(
241-
matrixClient: MatrixClient,
242-
roomId: string,
243-
videoFile: File,
244-
): Promise<Partial<IMediaEventInfo>> {
286+
function infoForVideoFile(matrixClient: MatrixClient, roomId: string, videoFile: File): Promise<VideoInfo> {
245287
const thumbnailType = "image/jpeg";
246288

247-
let videoInfo: Partial<IMediaEventInfo>;
289+
const videoInfo: VideoInfo = {};
248290
return loadVideoElement(videoFile)
249291
.then((video) => {
292+
videoInfo.duration = Math.ceil(video.duration * 1000);
250293
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
251294
})
252295
.then((result) => {
253-
videoInfo = result.info;
296+
Object.assign(videoInfo, result.info);
254297
return uploadFile(matrixClient, roomId, result.thumbnail);
255298
})
256299
.then((result) => {
@@ -299,7 +342,7 @@ export async function uploadFile(
299342
file: File | Blob,
300343
progressHandler?: UploadOpts["progressHandler"],
301344
controller?: AbortController,
302-
): Promise<{ url?: string; file?: IEncryptedFile }> {
345+
): Promise<{ url?: string; file?: EncryptedFile }> {
303346
const abortController = controller ?? new AbortController();
304347

305348
// If the room is encrypted then encrypt the file before uploading it.
@@ -329,7 +372,7 @@ export async function uploadFile(
329372
file: {
330373
...encryptResult.info,
331374
url,
332-
} as IEncryptedFile,
375+
} as EncryptedFile,
333376
};
334377
} else {
335378
const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController });
@@ -546,6 +589,14 @@ export default class ContentMessages {
546589
}
547590
} else if (file.type.indexOf("audio/") === 0) {
548591
content.msgtype = MsgType.Audio;
592+
try {
593+
const audioInfo = await infoForAudioFile(file);
594+
Object.assign(content.info, audioInfo);
595+
} catch (e) {
596+
// Failed to process audio file, fall back to uploading an m.file
597+
logger.error(e);
598+
content.msgtype = MsgType.File;
599+
}
549600
} else if (file.type.indexOf("video/") === 0) {
550601
content.msgtype = MsgType.Video;
551602
try {

src/components/views/messages/MFileBody.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
198198
const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted;
199199
const contentUrl = this.getContentUrl();
200200
const contentFileSize = this.content.info ? this.content.info.size : null;
201-
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
201+
const fileType = this.content.info?.mimetype ?? "application/octet-stream";
202202

203203
let placeholder: React.ReactNode = null;
204204
if (this.props.showGenericPlaceholder) {

src/components/views/messages/MImageBody.tsx

+14-8
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import SettingsStore from "../../../settings/SettingsStore";
2929
import Spinner from "../elements/Spinner";
3030
import { Media, mediaFromContent } from "../../../customisations/Media";
3131
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
32-
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
32+
import { ImageContent } from "../../../customisations/models/IMediaEventContent";
3333
import ImageView from "../elements/ImageView";
3434
import { IBodyProps } from "./IBodyProps";
3535
import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
@@ -102,7 +102,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
102102
return;
103103
}
104104

105-
const content = this.props.mxEvent.getContent<IMediaEventContent>();
105+
const content = this.props.mxEvent.getContent<ImageContent>();
106106
const httpUrl = this.state.contentUrl;
107107
if (!httpUrl) return;
108108
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
@@ -212,7 +212,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
212212
const thumbWidth = 800;
213213
const thumbHeight = 600;
214214

215-
const content = this.props.mxEvent.getContent<IMediaEventContent>();
215+
const content = this.props.mxEvent.getContent<ImageContent>();
216216
const media = mediaFromContent(content);
217217
const info = content.info;
218218

@@ -287,7 +287,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
287287
contentUrl = this.getContentUrl();
288288
}
289289

290-
const content = this.props.mxEvent.getContent<IMediaEventContent>();
290+
const content = this.props.mxEvent.getContent<ImageContent>();
291291
let isAnimated = mayBeAnimated(content.info?.mimetype);
292292

293293
// 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> {
317317
}
318318

319319
if (isAnimated) {
320-
const thumb = await createThumbnail(img, img.width, img.height, content.info!.mimetype, false);
320+
const thumb = await createThumbnail(
321+
img,
322+
img.width,
323+
img.height,
324+
content.info?.mimetype ?? "image/jpeg",
325+
false,
326+
);
321327
thumbUrl = URL.createObjectURL(thumb.thumbnail);
322328
}
323329
} catch (error) {
@@ -381,7 +387,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
381387
}
382388
}
383389

384-
protected getBanner(content: IMediaEventContent): ReactNode {
390+
protected getBanner(content: ImageContent): ReactNode {
385391
// Hide it for the threads list & the file panel where we show it as text anyway.
386392
if (
387393
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
@@ -395,7 +401,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
395401
protected messageContent(
396402
contentUrl: string | null,
397403
thumbUrl: string | null,
398-
content: IMediaEventContent,
404+
content: ImageContent,
399405
forcedHeight?: number,
400406
): ReactNode {
401407
if (!thumbUrl) thumbUrl = contentUrl; // fallback
@@ -591,7 +597,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
591597
}
592598

593599
public render(): React.ReactNode {
594-
const content = this.props.mxEvent.getContent<IMediaEventContent>();
600+
const content = this.props.mxEvent.getContent<ImageContent>();
595601

596602
if (this.state.error) {
597603
let errorText = _t("Unable to show image due to error");

src/components/views/messages/MImageReplyBody.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ limitations under the License.
1717
import React from "react";
1818

1919
import MImageBody from "./MImageBody";
20-
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
20+
import { ImageContent } from "../../../customisations/models/IMediaEventContent";
2121

2222
const FORCED_IMAGE_HEIGHT = 44;
2323

@@ -35,7 +35,7 @@ export default class MImageReplyBody extends MImageBody {
3535
return super.render();
3636
}
3737

38-
const content = this.props.mxEvent.getContent<IMediaEventContent>();
38+
const content = this.props.mxEvent.getContent<ImageContent>();
3939
const thumbnail = this.state.contentUrl
4040
? this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT)
4141
: undefined;

0 commit comments

Comments
 (0)