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

Commit 4f807cc

Browse files
committed
Properly check if GIF or WEBP are animated where we have access to the blob
1 parent 8a9752e commit 4f807cc

File tree

3 files changed

+101
-27
lines changed

3 files changed

+101
-27
lines changed

src/components/views/messages/MImageBody.tsx

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { IBodyProps } from "./IBodyProps";
3737
import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
3838
import { MatrixClientPeg } from '../../../MatrixClientPeg';
3939
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
40+
import { blobIsAnimated, mayBeAnimated } from '../../../utils/Image';
4041

4142
enum Placeholder {
4243
NoImage,
@@ -59,11 +60,6 @@ interface IState {
5960
placeholder: Placeholder;
6061
}
6162

62-
function mayBeAnimated(mimeType: string): boolean {
63-
// Both GIF and WEBP can be animated, and here we assume they are, as checking is much more difficult.
64-
return ["image/gif", "image/webp"].includes(mimeType);
65-
}
66-
6763
@replaceableComponent("views.messages.MImageBody")
6864
export default class MImageBody extends React.Component<IBodyProps, IState> {
6965
static contextType = RoomContext;
@@ -185,22 +181,17 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
185181
this.setState({ imgLoaded: true, loadedImageDimensions });
186182
};
187183

188-
protected getContentUrl(): string {
189-
const content: IMediaEventContent = this.props.mxEvent.getContent();
184+
private getContentUrl(): string {
190185
// During export, the content url will point to the MSC, which will later point to a local url
191-
if (this.props.forExport) return content.url || content.file?.url;
192-
if (this.media.isEncrypted) {
193-
return this.state.contentUrl;
194-
} else {
195-
return this.media.srcHttp;
196-
}
186+
if (this.props.forExport) return this.media.srcMxc;
187+
return this.media.srcHttp;
197188
}
198189

199190
private get media(): Media {
200191
return mediaFromContent(this.props.mxEvent.getContent());
201192
}
202193

203-
protected getThumbUrl(): string {
194+
private getThumbUrl(): string {
204195
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
205196
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
206197
// thumbnail resolution will be unnecessarily reduced.
@@ -260,7 +251,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
260251
}
261252

262253
private async downloadImage() {
263-
if (this.state.contentUrl || this.state.thumbUrl) return; // already downloaded
254+
if (this.state.contentUrl) return; // already downloaded
264255

265256
let thumbUrl: string;
266257
let contentUrl: string;
@@ -282,26 +273,30 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
282273
}
283274

284275
const content = this.props.mxEvent.getContent<IMediaEventContent>();
285-
const isAnimated = mayBeAnimated(content.info?.mimetype);
276+
let isAnimated = mayBeAnimated(content.info?.mimetype);
286277

287278
// If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server
288279
// because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail.
289280
if (isAnimated && !SettingsStore.getValue("autoplayGifs")) {
290281
if (!thumbUrl || !content?.info.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
291282
const img = document.createElement("img");
292283
const loadPromise = new Promise((resolve, reject) => {
293-
img.onload = function() {
294-
resolve(img);
295-
};
296-
img.onerror = function(e) {
297-
reject(e);
298-
};
284+
img.onload = resolve;
285+
img.onerror = reject;
299286
});
300287
img.crossOrigin = "Anonymous"; // CORS allow canvas access
301288
img.src = contentUrl;
302289

303290
await loadPromise;
304291

292+
// Rudimentary validation for whether it is animated, only in encrypted rooms as we have the blob
293+
if (this.props.mediaEventHelper.media.isEncrypted) {
294+
const blob = await this.props.mediaEventHelper.sourceBlob.value;
295+
if (!await blobIsAnimated(content.info.mimetype, blob)) {
296+
isAnimated = false;
297+
}
298+
}
299+
305300
if (isAnimated) {
306301
const thumb = await createThumbnail(img, img.width, img.height, content.info.mimetype, false);
307302
thumbUrl = URL.createObjectURL(thumb.thumbnail);
@@ -370,6 +365,8 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
370365
content: IMediaEventContent,
371366
forcedHeight?: number,
372367
): JSX.Element {
368+
if (!thumbUrl) thumbUrl = contentUrl; // fallback
369+
373370
let infoWidth: number;
374371
let infoHeight: number;
375372

@@ -571,7 +568,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
571568
if (this.props.forExport || (this.state.isAnimated && SettingsStore.getValue("autoplayGifs"))) {
572569
thumbUrl = contentUrl;
573570
} else {
574-
thumbUrl = this.state.thumbUrl;
571+
thumbUrl = this.state.thumbUrl ?? this.state.contentUrl;
575572
}
576573

577574
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);

src/components/views/messages/MImageReplyBody.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,12 @@ export default class MImageReplyBody extends MImageBody {
4141
}
4242

4343
render() {
44-
if (this.state.error !== null) {
44+
if (this.state.error) {
4545
return super.render();
4646
}
4747

4848
const content = this.props.mxEvent.getContent<IMediaEventContent>();
49-
50-
const contentUrl = this.getContentUrl();
51-
const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT);
49+
const thumbnail = this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT);
5250
const fileBody = this.getFileBody();
5351
const sender = <SenderProfile
5452
mxEvent={this.props.mxEvent}

src/utils/Image.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright 2022 The Matrix.org Foundation C.I.C.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export function mayBeAnimated(mimeType: string): boolean {
18+
// Both GIF and WEBP can be animated, and here we assume they are, as checking is much more difficult.
19+
return ["image/gif", "image/webp"].includes(mimeType);
20+
}
21+
22+
function arrayBufferRead(arr: ArrayBuffer, start: number, len: number): Uint8Array {
23+
return new Uint8Array(arr.slice(start, start + len));
24+
}
25+
26+
function arrayBufferReadStr(arr: ArrayBuffer, start: number, len: number): string {
27+
return String.fromCharCode.apply(null, arrayBufferRead(arr, start, len));
28+
}
29+
30+
export async function blobIsAnimated(mimeType: string, blob: Blob): Promise<boolean> {
31+
if (!mayBeAnimated(mimeType)) return false;
32+
33+
if (mimeType === "image/webp") {
34+
// Only extended file format WEBP images support animation, so grab the expected data range and verify header.
35+
// Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format
36+
const arr = await blob.slice(0, 16).arrayBuffer();
37+
if (
38+
arrayBufferReadStr(arr, 0, 4) === "RIFF" &&
39+
arrayBufferReadStr(arr, 8, 4) === "WEBP" &&
40+
arrayBufferReadStr(arr, 12, 4) === "VP8X"
41+
) {
42+
const [flags] = arrayBufferRead(arr, 16, 1);
43+
// Flags: R R I L E X _A_ R (reversed)
44+
const animationFlagMask = 1 << 1;
45+
return (flags & animationFlagMask) != 0;
46+
}
47+
} else if (mimeType === "image/gif") {
48+
// Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285
49+
// More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
50+
const dv = new DataView(await blob.arrayBuffer(), 10);
51+
52+
const globalColorTable = dv.getUint8(0);
53+
let globalColorTableSize = 0;
54+
// check first bit, if 0, then we don't have a Global Color Table
55+
if (globalColorTable & 0x80) {
56+
// grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1)
57+
// N is the value in the last 3 bits.
58+
globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1);
59+
}
60+
61+
// move on to the Graphics Control Extension
62+
const offset = 3 + globalColorTableSize;
63+
64+
const extensionIntroducer = dv.getUint8(offset);
65+
const graphicsControlLabel = dv.getUint8(offset + 1);
66+
let delayTime = 0;
67+
68+
// Graphics Control Extension section is where GIF animation data is stored
69+
// First 2 bytes must be 0x21 and 0xF9
70+
if ((extensionIntroducer & 0x21) && (graphicsControlLabel & 0xF9)) {
71+
// skip to the 2 bytes with the delay time
72+
delayTime = dv.getUint16(offset + 4);
73+
}
74+
75+
return !!delayTime;
76+
}
77+
78+
return false;
79+
}

0 commit comments

Comments
 (0)