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) => {