From 741514e4fb344e5440291273f378a092e7ee8180 Mon Sep 17 00:00:00 2001 From: edshot99 Date: Mon, 4 Aug 2025 22:26:13 -0500 Subject: [PATCH 1/5] Load avatar data over HTTP if available in vCard EXTVAL --- src/headless/plugins/vcard/parsers.js | 6 +++++- src/headless/plugins/vcard/types.ts | 2 ++ src/headless/plugins/vcard/utils.js | 3 ++- src/shared/avatar/avatar.js | 6 ++++-- src/shared/avatar/templates/avatar.js | 7 ++++++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/headless/plugins/vcard/parsers.js b/src/headless/plugins/vcard/parsers.js index 94e0bd14a3..3f5009a6ab 100644 --- a/src/headless/plugins/vcard/parsers.js +++ b/src/headless/plugins/vcard/parsers.js @@ -12,6 +12,7 @@ export async function parseVCardResultStanza(iq) { fullname: iq.querySelector(':scope > vCard FN')?.textContent, image: iq.querySelector(':scope > vCard PHOTO BINVAL')?.textContent, image_type: iq.querySelector(':scope > vCard PHOTO TYPE')?.textContent, + image_url: iq.querySelector(':scope > vCard PHOTO EXTVAL')?.textContent, nickname: iq.querySelector(':scope > vCard NICKNAME')?.textContent, role: iq.querySelector(':scope > vCard ROLE')?.textContent, stanza: iq, // TODO: remove? @@ -21,7 +22,10 @@ export async function parseVCardResultStanza(iq) { vcard_error: undefined, image_hash: undefined, }; - if (result.image) { + if (result.image_url) { + result['image_url'] = result.image_url; + } + else if (result.image) { const buffer = u.base64ToArrayBuffer(result.image); const ab = await crypto.subtle.digest('SHA-1', buffer); result['image_hash'] = u.arrayBufferToHex(ab); 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..e71f33a104 100644 --- a/src/headless/plugins/vcard/utils.js +++ b/src/headless/plugins/vcard/utils.js @@ -39,13 +39,14 @@ export function createStanza(type, jid, vcard_el) { * @param {MUCOccupant} occupant */ export function onOccupantAvatarChanged(occupant) { + const url = occupant.get('image_url'); const hash = occupant.get('image_hash'); 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.forEach((v) => hash && v && (v?.get('image_url') !== url || v?.get('image_hash') !== hash) && api.vcard.update(v, true)); } /** diff --git a/src/shared/avatar/avatar.js b/src/shared/avatar/avatar.js index 1dc900300b..521bb0d3f3 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_url || image || data_uri)) { 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..10298763da 100644 --- a/src/shared/avatar/templates/avatar.js +++ b/src/shared/avatar/templates/avatar.js @@ -5,7 +5,12 @@ import { html, nothing } from 'lit'; * @param {string} image_type */ const getImgHref = (image, image_type) => { - return image.startsWith('data:') ? image : `data:${image_type};base64,${image}`; + if (image.startsWith('https:') || image.startsWith('data:')) { + return image; + } + else { + return `data:${image_type};base64,${image}`; + } }; export default (o) => { From 5bc57d0b4ee6af6c9a9215adbc6195aa33bbe6c5 Mon Sep 17 00:00:00 2001 From: edshot99 Date: Mon, 4 Aug 2025 22:46:53 -0500 Subject: [PATCH 2/5] Update CHANGES.md --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From 9fd27182449d40fbeb5b39a2a8d19a676f29f07e Mon Sep 17 00:00:00 2001 From: edshot99 Date: Tue, 5 Aug 2025 22:48:14 -0500 Subject: [PATCH 3/5] HTTP avatar review changes --- src/headless/plugins/vcard/parsers.js | 28 +++++++++++++++++---------- src/headless/plugins/vcard/utils.js | 4 ++-- src/shared/avatar/avatar.js | 2 +- src/shared/avatar/templates/avatar.js | 4 ++-- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/headless/plugins/vcard/parsers.js b/src/headless/plugins/vcard/parsers.js index 3f5009a6ab..f5d6545cc7 100644 --- a/src/headless/plugins/vcard/parsers.js +++ b/src/headless/plugins/vcard/parsers.js @@ -10,9 +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, - image_url: iq.querySelector(':scope > vCard PHOTO EXTVAL')?.textContent, nickname: iq.querySelector(':scope > vCard NICKNAME')?.textContent, role: iq.querySelector(':scope > vCard ROLE')?.textContent, stanza: iq, // TODO: remove? @@ -20,15 +17,26 @@ export async function parseVCardResultStanza(iq) { vcard_updated: new Date().toISOString(), error: undefined, vcard_error: undefined, - image_hash: undefined, }; - if (result.image_url) { - result['image_url'] = result.image_url; - } - else 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/utils.js b/src/headless/plugins/vcard/utils.js index e71f33a104..33d01e5306 100644 --- a/src/headless/plugins/vcard/utils.js +++ b/src/headless/plugins/vcard/utils.js @@ -39,14 +39,14 @@ export function createStanza(type, jid, vcard_el) { * @param {MUCOccupant} occupant */ export function onOccupantAvatarChanged(occupant) { - const url = occupant.get('image_url'); 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_url') !== url || v?.get('image_hash') !== hash) && api.vcard.update(v, true)); + vcards.forEach((v) => (hash || url) && v && (v?.get('image_hash') !== hash || 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 521bb0d3f3..593e09962d 100644 --- a/src/shared/avatar/avatar.js +++ b/src/shared/avatar/avatar.js @@ -42,7 +42,7 @@ export default class Avatar extends CustomElement { image = this.model?.vcard?.get('image'); } - if (image_type && (image_url || image || data_uri)) { + if ((image_type && (image || data_uri)) || (image_url)) { return tplAvatar({ classes: this.getAttribute('class'), height: this.height, diff --git a/src/shared/avatar/templates/avatar.js b/src/shared/avatar/templates/avatar.js index 10298763da..b976c0355b 100644 --- a/src/shared/avatar/templates/avatar.js +++ b/src/shared/avatar/templates/avatar.js @@ -2,9 +2,9 @@ import { html, nothing } from 'lit'; /** * @param {string} image - * @param {string} image_type + * @param {string} [image_type] */ -const getImgHref = (image, image_type) => { +function getImgHref(image, image_type) { if (image.startsWith('https:') || image.startsWith('data:')) { return image; } From 917446af17f2450cfc621b754101672aeb8aae06 Mon Sep 17 00:00:00 2001 From: edshot99 Date: Mon, 1 Sep 2025 14:12:10 -0500 Subject: [PATCH 4/5] HTTP avatar proper condition and style fixes --- src/headless/plugins/vcard/parsers.js | 3 +-- src/headless/plugins/vcard/utils.js | 6 +++++- src/shared/avatar/templates/avatar.js | 3 +-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/headless/plugins/vcard/parsers.js b/src/headless/plugins/vcard/parsers.js index f5d6545cc7..c33507df8c 100644 --- a/src/headless/plugins/vcard/parsers.js +++ b/src/headless/plugins/vcard/parsers.js @@ -32,8 +32,7 @@ export async function parseVCardResultStanza(iq) { image_type, image_hash: u.arrayBufferToHex(ab), }); - } - else if (image_url) { + } else if (image_url) { Object.assign(result, { image_url, }); diff --git a/src/headless/plugins/vcard/utils.js b/src/headless/plugins/vcard/utils.js index 33d01e5306..9be7776f60 100644 --- a/src/headless/plugins/vcard/utils.js +++ b/src/headless/plugins/vcard/utils.js @@ -46,7 +46,11 @@ export function onOccupantAvatarChanged(occupant) { vcards.push(_converse.state.vcards.get(occupant.get('jid'))); } vcards.push(_converse.state.vcards.get(occupant.get('from'))); - vcards.forEach((v) => (hash || url) && v && (v?.get('image_hash') !== hash || v?.get('image_url') !== url) && 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/templates/avatar.js b/src/shared/avatar/templates/avatar.js index b976c0355b..7d97f2773c 100644 --- a/src/shared/avatar/templates/avatar.js +++ b/src/shared/avatar/templates/avatar.js @@ -7,8 +7,7 @@ import { html, nothing } from 'lit'; function getImgHref(image, image_type) { if (image.startsWith('https:') || image.startsWith('data:')) { return image; - } - else { + } else { return `data:${image_type};base64,${image}`; } }; From e5b287fcef6039fb4a7f2c9663c564aafaeb1ee8 Mon Sep 17 00:00:00 2001 From: edshot99 Date: Wed, 10 Sep 2025 10:43:38 -0500 Subject: [PATCH 5/5] HTTP avatars test --- src/headless/plugins/vcard/tests/update.js | 200 +++++++++++++++++++++ 1 file changed, 200 insertions(+) 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 () {