Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- feat(version): Add XEP-0092 Software Version support and show the server's software version in the profile modal
- fix(omemo): detect omemo:2-only contacts as OMEMO-capable
- feat: add custom element with support for animated WebP images

## 14.0.0-beta.4 (2026-06-22)

Expand Down
1 change: 1 addition & 0 deletions src/headless/types/utils/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ declare const _default: {
isGIFURL(url: string | URL): boolean;
isAudioURL(url: string | URL, headers?: Headers): boolean;
isVideoURL(url: string | URL, headers?: Headers): boolean;
isWebPURL(url: string | URL): boolean;
isImageURL(url: string | URL, headers?: Headers): boolean;
isEncryptedFileURL(url: string | URL): boolean;
withinString(string: string, callback: Function, options?: import("./types").ProcessStringOptions): string;
Expand Down
1 change: 1 addition & 0 deletions src/headless/types/utils/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type MediaURLMetadata = MediaURLIndexes & {
is_gif?: boolean;
is_image?: boolean;
is_video?: boolean;
is_webp?: boolean;
};
export type StorageType = 'persistent' | 'session';
export type StorageModel = IEventEmitter & {
Expand Down
5 changes: 5 additions & 0 deletions src/headless/types/utils/url.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export function isAudioURL(url: string | URL, headers?: Headers): boolean;
* @param {Headers} [headers]
*/
export function isVideoURL(url: string | URL, headers?: Headers): boolean;
/**
* @param {string|URL} url
* @returns {boolean}
*/
export function isWebPURL(url: string | URL): boolean;
/**
* @param {string|URL} url
* @param {Headers} [headers]
Expand Down
1 change: 1 addition & 0 deletions src/headless/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type MediaURLMetadata = MediaURLIndexes & {
is_gif?: boolean;
is_image?: boolean;
is_video?: boolean;
is_webp?: boolean;
};

export type StorageType = 'persistent' | 'session';
Expand Down
9 changes: 9 additions & 0 deletions src/headless/utils/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ export function isVideoURL(url, headers) {
return checkFileTypes([".mp4", ".webm"], url);
}

/**
* @param {string|URL} url
* @returns {boolean}
*/
export function isWebPURL(url) {
return checkFileTypes([".webp"], url);
}

/**
* @param {string|URL} url
* @param {Headers} [headers]
Expand Down Expand Up @@ -213,6 +221,7 @@ export async function getMetadataForURL(o) {
is_audio: isAudioURL(o.url, headers),
is_image: isImageURL(o.url, headers),
is_video: isVideoURL(o.url, headers),
is_webp: isWebPURL(o.url),
is_encrypted: isEncryptedFileURL(o.url),
};
}
Expand Down
93 changes: 93 additions & 0 deletions src/plugins/chatview/tests/message-webps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import mock from '../../../shared/tests/mock.js';
import converse from '../../../../dist/converse.js';

const { sizzle, u } = converse.env;

describe('A Chat Message', function () {
it(
'will render webps from their URLs',
mock.initConverse(converse, ['chatBoxesFetched'], {}, async function (_converse) {
await mock.waitForRoster(_converse, 'current');
const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.chatboxviews.get(contact_jid);
spyOn(view.model, 'sendMessage').and.callThrough();

for (const webp_url of [
'https://www.gstatic.com/webp/gallery/1.webp',
'https://www.gstatic.com/webp/animated/1.webp',
]) {
await mock.sendMessage(_converse, view, webp_url);
await u.waitUntil(() => view.querySelectorAll('.chat-content canvas').length);
expect(view.model.sendMessage).toHaveBeenCalled();

const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
await u.waitUntil(() => msg.querySelector('converse-webp'), 1000);
const webp_el = msg.querySelector('converse-webp');
expect(webp_el?.getAttribute('src')).toBe(webp_url);
expect(webp_el?.getAttribute('fallback')).toBe('empty');
expect(webp_el.querySelector('canvas.gif-canvas')).not.toBeNull();
expect(webp_el.querySelector('img.gif')?.getAttribute('src')).toBe(webp_url);
}
}),
);

it(
'will not pause or play on click when the webp is a single static frame',
mock.initConverse(converse, ['chatBoxesFetched'], {}, async function (_converse) {
await mock.waitForRoster(_converse, 'current');

const webp_url = 'https://www.gstatic.com/webp/gallery/1.webp';
const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';

await mock.openChatBoxFor(_converse, contact_jid);

const view = _converse.chatboxviews.get(contact_jid);
await mock.sendMessage(_converse, view, webp_url);

await u.waitUntil(() => view.querySelector('.chat-content converse-webp')?.supergif, 2000);
const webp_el = view.querySelector('.chat-content converse-webp');
webp_el.supergif.frames = [{}];
webp_el.supergif.playing = true;

spyOn(webp_el.supergif, 'pause').and.callThrough();
spyOn(webp_el.supergif, 'play').and.callThrough();
webp_el.onControlsClicked(new MouseEvent('click'));

expect(webp_el.supergif.pause).not.toHaveBeenCalled();
expect(webp_el.supergif.play).not.toHaveBeenCalled();
expect(webp_el.supergif.playing).toBeTrue();
}),
);

it(
'will pause and resume an animated webp when clicked',
mock.initConverse(converse, ['chatBoxesFetched'], {}, async function (_converse) {
await mock.waitForRoster(_converse, 'current');

const webp_url = 'https://www.gstatic.com/webp/animated/1.webp';
const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';

await mock.openChatBoxFor(_converse, contact_jid);

const view = _converse.chatboxviews.get(contact_jid);
await mock.sendMessage(_converse, view, webp_url);

await u.waitUntil(() => view.querySelector('.chat-content converse-webp')?.supergif, 2000);
const webp_el = view.querySelector('.chat-content converse-webp');
webp_el.supergif.frames = [{}, {}];
webp_el.supergif.playing = true;

spyOn(webp_el.supergif, 'pause').and.callThrough();
spyOn(webp_el.supergif, 'play').and.callThrough();
expect(webp_el.supergif.playing).toBeTrue();
webp_el.onControlsClicked(new MouseEvent('click'));
expect(webp_el.supergif.pause).toHaveBeenCalled();
expect(webp_el.supergif.playing).toBeFalse();
webp_el.onControlsClicked(new MouseEvent('click'));
expect(webp_el.supergif.play).toHaveBeenCalled();
expect(webp_el.supergif.playing).toBeTrue();
}),
);

});
7 changes: 6 additions & 1 deletion src/shared/gif/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export default class ConverseGIFElement extends CustomElement {
}

initGIF () {
this.supergif = new ConverseGif(this, this.initOPtions());
}

initOPtions () {
/** @type {Record<string, any>} */
const options = {
'autoplay': this.autoplay,
Expand All @@ -44,7 +48,8 @@ export default class ConverseGIFElement extends CustomElement {
if (this.progress_color) {
options['progress_color'] = this.progress_color;
}
this.supergif = new ConverseGif(this, options);

return options;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions src/shared/texture/templates/webp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { html } from 'lit';
import 'shared/webp/component.js';

/**
* @param {string} url
* @param {boolean} hide_url
*/
export default (url, hide_url) =>
html`<converse-webp autoplay noloop fallback="empty" src=${url}></converse-webp>${hide_url
? ''
: html`<a target="_blank" rel="noopener" href="${url}">${url}</a>`}`;
3 changes: 3 additions & 0 deletions src/shared/texture/texture.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import tplAudio from './templates/audio.js';
import tplGif from './templates/gif.js';
import tplImage from './templates/image.js';
import tplVideo from './templates/video.js';
import tplWebP from './templates/webp.js';
import tplSpotify from './templates/spotify.js';
import { getEmojiMarkup } from '../chat/utils.js';
import { getHyperlinkTemplate } from '../../utils/html.js';
Expand Down Expand Up @@ -126,6 +127,8 @@ export class Texture extends String {
let template;
if (url_obj.is_gif && this.shouldRenderMedia(url, 'image')) {
template = tplGif(filtered_url, this.hide_media_urls);
} else if (url_obj.is_webp) {
template = tplWebP(filtered_url, this.hide_media_urls);
} else if (url_obj.is_image && this.shouldRenderMedia(url, 'image')) {
template = tplImage({
src: filtered_url,
Expand Down
19 changes: 19 additions & 0 deletions src/shared/webp/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { api } from '@converse/headless';
import ConverseWebP from 'shared/webp/index.js';
import ConverseGIFElement from 'shared/gif/component.js';

export default class ConverseWebPElement extends ConverseGIFElement {
initGIF () {
this.supergif = new ConverseWebP(this, this.initOPtions());
}

/** @param {MouseEvent} event */
onControlsClicked (event) {
if (this.supergif.frames.length === 1) {
return;
}
super.onControlsClicked(event);
}
}

api.elements.define('converse-webp', ConverseWebPElement);
75 changes: 75 additions & 0 deletions src/shared/webp/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import ConverseGif from "shared/gif/index.js";

export default class ConverseWebP extends ConverseGif {
/**
* Create a new ConverseWebP instance.
* @param {import('./component').default} el
* @param {import("shared/gif/types").ConverseGifOptions} opts
*/
constructor(el, opts) {
super(el, opts);
this.el = el;
}

/**
* @param {ArrayBuffer} data - The WebP file data, as returned by the server
*/
async handleGIFResponse(data) {
await this.loadWebPFrames(data);
this.setSizes(
this.options.width ?? this.frames[0].dims.width,
this.options.height ?? this.frames[0].dims.height
);

if (this.frames.length === 1) {
this.renderImage(false);
return;
}

this.initPlayer();
}

/**
*
* @param {ArrayBuffer} fileBuffer
*/
async loadWebPFrames(fileBuffer) {
const decoder = new globalThis.ImageDecoder({
data: fileBuffer,
type: 'image/webp'
});

await decoder.tracks.ready;
const track = decoder.tracks.selectedTrack;
const totalFrames = track.frameCount;

const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');

for (let i = 0; i < totalFrames; i++) {
const result = await decoder.decode({ frameIndex: i });
const frameImage = result.image;

tempCanvas.width = frameImage.codedWidth;
tempCanvas.height = frameImage.codedHeight;

tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
tempCtx.drawImage(frameImage, 0, 0);
const imgData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);

// Build the frame object structure expected by renderImage
this.frames.push({
dims: {
width: frameImage.codedWidth,
height: frameImage.codedHeight,
left: 0, // WebP defaults to full frame context per VideoFrame
top: 0
},
patch: imgData.data,
delay: (frameImage.duration || 0) / 1000,
});

frameImage.close();
}
}
}
28 changes: 28 additions & 0 deletions src/shared/webp/tests/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import converse from '../../../../dist/converse.js';
import { api } from '@converse/headless';

const { u } = converse.env;

describe('ConverseWebPElement', function () {
it('renders empty fallback content when load_error is set', async function () {
const original_elements = api.elements;
api.elements = { 'define': () => {} };
const { default: ConverseWebPElement } = await import('../component.js');
api.elements = original_elements;
spyOn(ConverseWebPElement.prototype, 'initGIF').and.callFake(function () {});
if (!customElements.get('converse-webp')) {
customElements.define('converse-webp', ConverseWebPElement);
}

const el = document.createElement('converse-webp');
el.setAttribute('src', 'https://example.com/failing.webp');
el.setAttribute('fallback', 'empty');
el.supergif = { load_error: true };
document.body.appendChild(el);

await u.waitUntil(() => !el.querySelector('canvas'), 1000);
expect(el.querySelector('canvas')).toBeNull();

el.remove();
});
});
1 change: 1 addition & 0 deletions src/types/shared/gif/component.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default class ConverseGIFElement extends CustomElement {
progress_color: any;
initGIF(): void;
supergif: ConverseGif;
initOPtions(): Record<string, any>;
render(): string | import("utils/html.js").TemplateResult;
renderErrorFallback(): string | import("utils/html.js").TemplateResult;
setHover(): void;
Expand Down
3 changes: 3 additions & 0 deletions src/types/shared/texture/templates/webp.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare function _default(url: string, hide_url: boolean): import("lit-html").TemplateResult<1>;
export default _default;
//# sourceMappingURL=webp.d.ts.map
1 change: 1 addition & 0 deletions src/types/shared/texture/utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ declare const _default: {
isGIFURL(url: string | URL): boolean;
isAudioURL(url: string | URL, headers?: Headers): boolean;
isVideoURL(url: string | URL, headers?: Headers): boolean;
isWebPURL(url: string | URL): boolean;
isImageURL(url: string | URL, headers?: Headers): boolean;
isEncryptedFileURL(url: string | URL): boolean;
withinString(string: string, callback: Function, options?: import("headless/types/utils/types.js").ProcessStringOptions): string;
Expand Down
4 changes: 4 additions & 0 deletions src/types/shared/webp/component.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default class ConverseWebPElement extends ConverseGIFElement {
}
import ConverseGIFElement from 'shared/gif/component.js';
//# sourceMappingURL=component.d.ts.map
20 changes: 20 additions & 0 deletions src/types/shared/webp/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export default class ConverseWebP extends ConverseGif {
/**
* Create a new ConverseWebP instance.
* @param {import('./component').default} el
* @param {import("shared/gif/types").ConverseGifOptions} opts
*/
constructor(el: import("./component").default, opts: import("shared/gif/types").ConverseGifOptions);
el: import("./component").default;
/**
* @param {ArrayBuffer} data - The WebP file data, as returned by the server
*/
handleGIFResponse(data: ArrayBuffer): Promise<void>;
/**
*
* @param {ArrayBuffer} fileBuffer
*/
loadWebPFrames(fileBuffer: ArrayBuffer): Promise<void>;
}
import ConverseGif from "shared/gif/index.js";
//# sourceMappingURL=index.d.ts.map
Loading
Loading