From 741514e4fb344e5440291273f378a092e7ee8180 Mon Sep 17 00:00:00 2001 From: edshot99 Date: Mon, 4 Aug 2025 22:26:13 -0500 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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 da226a44820edf99df3fd52a3ba2f594f3d52f70 Mon Sep 17 00:00:00 2001 From: edshot99 Date: Thu, 4 Sep 2025 13:35:11 -0500 Subject: [PATCH 5/7] HTTP avatars tests --- src/headless/plugins/vcard/tests/update.js | 186 ++++++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/src/headless/plugins/vcard/tests/update.js b/src/headless/plugins/vcard/tests/update.js index 60becf942d..6bbca3be44 100644 --- a/src/headless/plugins/vcard/tests/update.js +++ b/src/headless/plugins/vcard/tests/update.js @@ -92,6 +92,83 @@ 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` + + + + ` + ) + ); + 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(); + + 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) { @@ -165,7 +242,6 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( to="${_converse.session.get('jid')}" from="${contact_jid}/resource"> - ` ) @@ -202,6 +278,114 @@ 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 () { From 182de88e1b6e5da78c891156443edf3f62d57f39 Mon Sep 17 00:00:00 2001 From: edshot99 Date: Thu, 4 Sep 2025 14:45:03 -0500 Subject: [PATCH 6/7] 1 --- src/headless/plugins/vcard/tests/update.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/headless/plugins/vcard/tests/update.js b/src/headless/plugins/vcard/tests/update.js index 6bbca3be44..d1a681757c 100644 --- a/src/headless/plugins/vcard/tests/update.js +++ b/src/headless/plugins/vcard/tests/update.js @@ -110,7 +110,7 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( to="${_converse.session.get('jid')}" from="${contact_jid}/resource"> - + 123 ` ) @@ -160,6 +160,18 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( 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); @@ -242,6 +254,7 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( to="${_converse.session.get('jid')}" from="${contact_jid}/resource"> + ` ) @@ -350,6 +363,7 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( to="${_converse.session.get('jid')}" from="${contact_jid}/resource"> + ` ) From bc162d10fc4a9a35da5dad0eb816fd518d997b1c Mon Sep 17 00:00:00 2001 From: edshot99 Date: Thu, 4 Sep 2025 14:52:28 -0500 Subject: [PATCH 7/7] 2 --- src/headless/plugins/vcard/tests/update.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/headless/plugins/vcard/tests/update.js b/src/headless/plugins/vcard/tests/update.js index d1a681757c..8ed2b4f24c 100644 --- a/src/headless/plugins/vcard/tests/update.js +++ b/src/headless/plugins/vcard/tests/update.js @@ -160,6 +160,7 @@ describe('An incoming presence with a XEP-0153 vcard:update element', function ( 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`` ) ); + */ return new Promise((resolve) => { setTimeout(() => {