diff --git a/CHANGES.md b/CHANGES.md index f8d78cf77f..62100d9930 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/src/headless/types/utils/index.d.ts b/src/headless/types/utils/index.d.ts index de827e09ac..55e14b3c36 100644 --- a/src/headless/types/utils/index.d.ts +++ b/src/headless/types/utils/index.d.ts @@ -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; diff --git a/src/headless/types/utils/types.d.ts b/src/headless/types/utils/types.d.ts index bc27f97234..0c937aea9b 100644 --- a/src/headless/types/utils/types.d.ts +++ b/src/headless/types/utils/types.d.ts @@ -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 & { diff --git a/src/headless/types/utils/url.d.ts b/src/headless/types/utils/url.d.ts index 52294f7009..b431d1d63e 100644 --- a/src/headless/types/utils/url.d.ts +++ b/src/headless/types/utils/url.d.ts @@ -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] diff --git a/src/headless/utils/types.ts b/src/headless/utils/types.ts index 6c02b4163d..e6bb52c4ad 100644 --- a/src/headless/utils/types.ts +++ b/src/headless/utils/types.ts @@ -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'; diff --git a/src/headless/utils/url.js b/src/headless/utils/url.js index 7536eb76d1..a7e7b8efa7 100644 --- a/src/headless/utils/url.js +++ b/src/headless/utils/url.js @@ -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] @@ -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), }; } diff --git a/src/plugins/chatview/tests/message-webps.js b/src/plugins/chatview/tests/message-webps.js new file mode 100644 index 0000000000..52f5087209 --- /dev/null +++ b/src/plugins/chatview/tests/message-webps.js @@ -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(); + }), + ); + +}); diff --git a/src/shared/gif/component.js b/src/shared/gif/component.js index 6865b0b483..5d1f3ce608 100644 --- a/src/shared/gif/component.js +++ b/src/shared/gif/component.js @@ -36,6 +36,10 @@ export default class ConverseGIFElement extends CustomElement { } initGIF () { + this.supergif = new ConverseGif(this, this.initOPtions()); + } + + initOPtions () { /** @type {Record} */ const options = { 'autoplay': this.autoplay, @@ -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; } /** diff --git a/src/shared/texture/templates/webp.js b/src/shared/texture/templates/webp.js new file mode 100644 index 0000000000..dcb16ba976 --- /dev/null +++ b/src/shared/texture/templates/webp.js @@ -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`${hide_url + ? '' + : html`${url}`}`; diff --git a/src/shared/texture/texture.js b/src/shared/texture/texture.js index ee4ddfe9df..ebfd55ae74 100644 --- a/src/shared/texture/texture.js +++ b/src/shared/texture/texture.js @@ -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'; @@ -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, diff --git a/src/shared/webp/component.js b/src/shared/webp/component.js new file mode 100644 index 0000000000..c568b209a5 --- /dev/null +++ b/src/shared/webp/component.js @@ -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); diff --git a/src/shared/webp/index.js b/src/shared/webp/index.js new file mode 100644 index 0000000000..d4542cc4ca --- /dev/null +++ b/src/shared/webp/index.js @@ -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(); + } + } +} diff --git a/src/shared/webp/tests/component.js b/src/shared/webp/tests/component.js new file mode 100644 index 0000000000..2d47c4786b --- /dev/null +++ b/src/shared/webp/tests/component.js @@ -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(); + }); +}); diff --git a/src/types/shared/gif/component.d.ts b/src/types/shared/gif/component.d.ts index 4daa5adefb..ef025d465e 100644 --- a/src/types/shared/gif/component.d.ts +++ b/src/types/shared/gif/component.d.ts @@ -23,6 +23,7 @@ export default class ConverseGIFElement extends CustomElement { progress_color: any; initGIF(): void; supergif: ConverseGif; + initOPtions(): Record; render(): string | import("utils/html.js").TemplateResult; renderErrorFallback(): string | import("utils/html.js").TemplateResult; setHover(): void; diff --git a/src/types/shared/texture/templates/webp.d.ts b/src/types/shared/texture/templates/webp.d.ts new file mode 100644 index 0000000000..edfc7db087 --- /dev/null +++ b/src/types/shared/texture/templates/webp.d.ts @@ -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 \ No newline at end of file diff --git a/src/types/shared/texture/utils.d.ts b/src/types/shared/texture/utils.d.ts index 7694bc5c2c..8f502f32ba 100644 --- a/src/types/shared/texture/utils.d.ts +++ b/src/types/shared/texture/utils.d.ts @@ -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; diff --git a/src/types/shared/webp/component.d.ts b/src/types/shared/webp/component.d.ts new file mode 100644 index 0000000000..615f95cb1b --- /dev/null +++ b/src/types/shared/webp/component.d.ts @@ -0,0 +1,4 @@ +export default class ConverseWebPElement extends ConverseGIFElement { +} +import ConverseGIFElement from 'shared/gif/component.js'; +//# sourceMappingURL=component.d.ts.map \ No newline at end of file diff --git a/src/types/shared/webp/index.d.ts b/src/types/shared/webp/index.d.ts new file mode 100644 index 0000000000..3deb226e40 --- /dev/null +++ b/src/types/shared/webp/index.d.ts @@ -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; + /** + * + * @param {ArrayBuffer} fileBuffer + */ + loadWebPFrames(fileBuffer: ArrayBuffer): Promise; +} +import ConverseGif from "shared/gif/index.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/types/utils/index.d.ts b/src/types/utils/index.d.ts index 824e809f07..565671a19a 100644 --- a/src/types/utils/index.d.ts +++ b/src/types/utils/index.d.ts @@ -13,6 +13,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; @@ -126,6 +127,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; diff --git a/vitest.config.js b/vitest.config.js index 8b6a304884..58a3866f9d 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -35,6 +35,9 @@ export default defineConfig({ // inconsistent (some use one `../` too many and only resolved under Karma's // `/base/` URL prefix). Resolve the bundles by basename, depth-independent. alias: [ + { find: /^shared\/(.*)$/, replacement: abs('src/shared/$1') }, + { find: /^utils\/(.*)$/, replacement: abs('src/utils/$1') }, + { find: /^templates\/(.*)$/, replacement: abs('src/templates/$1') }, { find: /(?:\.\.\/)+dist\/converse\.js$/, replacement: abs('dist/converse.js') }, { find: /(?:\.\.\/)+dist\/converse-headless\.js$/,