diff --git a/CHANGES.md b/CHANGES.md index fda5c078aa..d48a88cc72 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Changelog +## 12.0.1 (Unreleased) + +- Load avatar data over HTTP if available in vCard EXTVAL + ## 12.0.0 (2025-08-28) - #3581: Don't unnecessarily regenerate pot and po files diff --git a/src/headless/plugins/vcard/parsers.js b/src/headless/plugins/vcard/parsers.js index 94e0bd14a3..c33507df8c 100644 --- a/src/headless/plugins/vcard/parsers.js +++ b/src/headless/plugins/vcard/parsers.js @@ -10,8 +10,6 @@ export async function parseVCardResultStanza(iq) { const result = { email: iq.querySelector(':scope > vCard EMAIL USERID')?.textContent, fullname: iq.querySelector(':scope > vCard FN')?.textContent, - image: iq.querySelector(':scope > vCard PHOTO BINVAL')?.textContent, - image_type: iq.querySelector(':scope > vCard PHOTO TYPE')?.textContent, nickname: iq.querySelector(':scope > vCard NICKNAME')?.textContent, role: iq.querySelector(':scope > vCard ROLE')?.textContent, stanza: iq, // TODO: remove? @@ -19,12 +17,25 @@ export async function parseVCardResultStanza(iq) { vcard_updated: new Date().toISOString(), error: undefined, vcard_error: undefined, - image_hash: undefined, }; - if (result.image) { - const buffer = u.base64ToArrayBuffer(result.image); + + const image = iq.querySelector(':scope > vCard PHOTO BINVAL')?.textContent; + const image_type = iq.querySelector(':scope > vCard PHOTO TYPE')?.textContent; + const image_url = iq.querySelector(':scope > vCard PHOTO EXTVAL')?.textContent; + + if (image) { + const buffer = u.base64ToArrayBuffer(image); const ab = await crypto.subtle.digest('SHA-1', buffer); - result['image_hash'] = u.arrayBufferToHex(ab); + + Object.assign(result, { + image, + image_type, + image_hash: u.arrayBufferToHex(ab), + }); + } else if (image_url) { + Object.assign(result, { + image_url, + }); } return result; } diff --git a/src/headless/plugins/vcard/tests/update.js b/src/headless/plugins/vcard/tests/update.js index 60becf942d..8ed2b4f24c 100644 --- a/src/headless/plugins/vcard/tests/update.js +++ b/src/headless/plugins/vcard/tests/update.js @@ -92,6 +92,97 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( }) ); + it( + 'will cause a VCard HTTP avatar to be replaced', + mock.initConverse(['chatBoxesFetched'], { no_vcard_mocks: true }, async function (_converse) { + const { api } = _converse; + const { u, sizzle } = _converse.env; + await mock.waitForRoster(_converse, 'current', 1); + mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + + const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; + while (IQ_stanzas.length) IQ_stanzas.pop(); + + _converse.api.connection.get()._dataRecv( + mock.createRequest( + stx` + + 123 + + ` + ) + ); + const sent_stanza = await u.waitUntil( + () => IQ_stanzas.filter((s) => sizzle('vCard', s).length).pop(), + 1000 + ); + expect(sent_stanza).toEqualStanza(stx` + + + `); + + const response = await fetch('/base/logo/conversejs-filled-192.png'); + const blob = await response.blob(); + + _converse.api.connection.get()._dataRecv( + mock.createRequest(stx` + + + 1476-06-09 + + Italy + Verona + + + + MercutioCapulet + mercutio@shakespeare.lit + + ${blob.type} + + http://localhost:9876/base/logo/conversejs-filled-192.png + + + `) + ); + + const { vcard } = await api.contacts.get(contact_jid); + await u.waitUntil(() => vcard.get('image_url') === 'http://localhost:9876/base/logo/conversejs-filled-192.png'); + while (IQ_stanzas.length) IQ_stanzas.pop(); + + /* + _converse.api.connection.get()._dataRecv( + mock.createRequest( + stx` + + 123 + + ` + ) + ); + */ + + return new Promise((resolve) => { + setTimeout(() => { + expect(IQ_stanzas.filter((s) => sizzle('vCard', s).length).length).toBe(0); + resolve(); + }, 251); + }); + }) + ); + it( 'will cause a VCard avatar to be removed', mock.initConverse(['chatBoxesFetched'], { no_vcard_mocks: true }, async function (_converse) { @@ -202,6 +293,115 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( expect(contact.vcard.get('image_hash')).toBeUndefined(); }) ); + + it( + 'will cause a VCard HTTP avatar to be removed', + mock.initConverse(['chatBoxesFetched'], { no_vcard_mocks: true }, async function (_converse) { + const { api } = _converse; + const { u, sizzle } = _converse.env; + await mock.waitForRoster(_converse, 'current', 1); + mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + const own_jid = _converse.session.get('jid'); + + const IQ_stanzas = _converse.api.connection.get().IQ_stanzas; + let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter((s) => sizzle(`vCard`, s).length).pop(), 500); + _converse.api.connection.get()._dataRecv( + mock.createRequest(stx` + + + `) + ); + + sent_stanza = await u.waitUntil(() => + IQ_stanzas.filter((s) => sizzle(`iq[to="${contact_jid}"] vCard`, s).length).pop() + ); + expect(sent_stanza).toEqualStanza(stx` + + + `); + + const response = await fetch('/base/logo/conversejs-filled-192.png'); + const blob = await response.blob(); + + _converse.api.connection.get()._dataRecv( + mock.createRequest(stx` + + + 1476-06-09 + Italy + Verona + MercutioCapulet + mercutio@shakespeare.lit + + ${blob.type} + + http://localhost:9876/base/logo/conversejs-filled-192.png + + + `) + ); + + const contact = await api.contacts.get(contact_jid); + await u.waitUntil(() => contact.vcard.get('image_url')); + expect(contact.vcard.get('image_url')).toEqual("http://localhost:9876/base/logo/conversejs-filled-192.png"); + + while (IQ_stanzas.length) IQ_stanzas.pop(); + + _converse.api.connection.get()._dataRecv( + mock.createRequest( + stx` + + + + ` + ) + ); + + sent_stanza = await u.waitUntil(() => IQ_stanzas.filter((s) => sizzle('vCard', s).length).pop(), 500); + expect(sent_stanza).toEqualStanza(stx` + + + `); + + _converse.api.connection.get()._dataRecv( + mock.createRequest(stx` + + + 1476-06-09 + Italy + Verona + MercutioCapulet + mercutio@shakespeare.lit + + + `) + ); + + await u.waitUntil(() => !contact.vcard.get('image_url')); + expect(contact.vcard.get('image_url')).toBeUndefined(); + }) + ); }); describe('An outgoing presence with a XEP-0153 vcard:update element', function () { diff --git a/src/headless/plugins/vcard/types.ts b/src/headless/plugins/vcard/types.ts index 33a3bfe643..1f4ad8f461 100644 --- a/src/headless/plugins/vcard/types.ts +++ b/src/headless/plugins/vcard/types.ts @@ -10,6 +10,7 @@ export interface VCardResult { image?: string; image_hash?: string; image_type?: string; + image_url?: string; nickname?: string; role?: string; stanza: Element; @@ -25,6 +26,7 @@ export type VCardData = { role?: string; email?: string; url?: string; + image_url?: string; image_type?: string; image?: string; }; diff --git a/src/headless/plugins/vcard/utils.js b/src/headless/plugins/vcard/utils.js index 45f624f6c4..9be7776f60 100644 --- a/src/headless/plugins/vcard/utils.js +++ b/src/headless/plugins/vcard/utils.js @@ -40,12 +40,17 @@ export function createStanza(type, jid, vcard_el) { */ export function onOccupantAvatarChanged(occupant) { const hash = occupant.get('image_hash'); + const url = occupant.get('image_url'); const vcards = []; if (occupant.get('jid')) { vcards.push(_converse.state.vcards.get(occupant.get('jid'))); } vcards.push(_converse.state.vcards.get(occupant.get('from'))); - vcards.forEach((v) => hash && v && v?.get('image_hash') !== hash && api.vcard.update(v, true)); + vcards.filter((v) => v).forEach((v) => { + if (hash && v.get('image_hash') !== hash || url && v.get('image_url') !== url) { + api.vcard.update(v, true); + } + }); } /** diff --git a/src/shared/avatar/avatar.js b/src/shared/avatar/avatar.js index 1dc900300b..593e09962d 100644 --- a/src/shared/avatar/avatar.js +++ b/src/shared/avatar/avatar.js @@ -29,6 +29,7 @@ export default class Avatar extends CustomElement { } render() { + let image_url; let image_type; let image; let data_uri; @@ -36,16 +37,17 @@ export default class Avatar extends CustomElement { image_type = this.pickerdata.image_type; data_uri = this.pickerdata.data_uri; } else { + image_url = this.model?.vcard?.get('image_url'); image_type = this.model?.vcard?.get('image_type'); image = this.model?.vcard?.get('image'); } - if (image_type && (image || data_uri)) { + if ((image_type && (image || data_uri)) || (image_url)) { return tplAvatar({ classes: this.getAttribute('class'), height: this.height, width: this.width, - image: data_uri || `data:${image_type};base64,${image}`, + image: image_url || data_uri || `data:${image_type};base64,${image}`, image_type, alt_text: __('The profile picture of %1$s', this.name), }); diff --git a/src/shared/avatar/templates/avatar.js b/src/shared/avatar/templates/avatar.js index 8cd8696e5f..7d97f2773c 100644 --- a/src/shared/avatar/templates/avatar.js +++ b/src/shared/avatar/templates/avatar.js @@ -2,10 +2,14 @@ import { html, nothing } from 'lit'; /** * @param {string} image - * @param {string} image_type + * @param {string} [image_type] */ -const getImgHref = (image, image_type) => { - return image.startsWith('data:') ? image : `data:${image_type};base64,${image}`; +function getImgHref(image, image_type) { + if (image.startsWith('https:') || image.startsWith('data:')) { + return image; + } else { + return `data:${image_type};base64,${image}`; + } }; export default (o) => {