Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 17 additions & 6 deletions src/headless/plugins/vcard/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,32 @@ 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?
url: iq.querySelector(':scope > vCard URL')?.textContent,
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;
}
200 changes: 200 additions & 0 deletions src/headless/plugins/vcard/tests/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`<presence xmlns="jabber:client"
to="${_converse.session.get('jid')}"
from="${contact_jid}/resource">
<x xmlns='vcard-temp:x:update'>
<photo>123</photo>
</x>
</presence>`
)
);
const sent_stanza = await u.waitUntil(
() => IQ_stanzas.filter((s) => sizzle('vCard', s).length).pop(),
1000
);
expect(sent_stanza).toEqualStanza(stx`
<iq type="get"
to="[email protected]"
xmlns="jabber:client"
id="${sent_stanza.getAttribute('id')}">
<vCard xmlns="vcard-temp"/>
</iq>`);

const response = await fetch('/base/logo/conversejs-filled-192.png');
const blob = await response.blob();

_converse.api.connection.get()._dataRecv(
mock.createRequest(stx`
<iq from='${contact_jid}'
xmlns="jabber:client"
to='${_converse.session.get('jid')}'
type='result'
id='${sent_stanza.getAttribute('id')}'>
<vCard xmlns='vcard-temp'>
<BDAY>1476-06-09</BDAY>
<ADR>
<CTRY>Italy</CTRY>
<LOCALITY>Verona</LOCALITY>
<HOME/>
</ADR>
<NICKNAME/>
<N><GIVEN>Mercutio</GIVEN><FAMILY>Capulet</FAMILY></N>
<EMAIL><USERID>[email protected]</USERID></EMAIL>
<PHOTO>
<TYPE>${blob.type}</TYPE>
<BINVAL></BINVAL>
<EXTVAL>http://localhost:9876/base/logo/conversejs-filled-192.png</EXTVAL>
</PHOTO>
</vCard>
</iq>`)
);

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`<presence xmlns="jabber:client"
to="${_converse.session.get('jid')}"
from="${contact_jid}/resource">
<x xmlns='vcard-temp:x:update'>
<photo>123</photo>
</x>
</presence>`
)
);
*/

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) {
Expand Down Expand Up @@ -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`
<iq from='${own_jid}'
xmlns="jabber:client"
to='${_converse.session.get('jid')}'
type='result'
id='${sent_stanza.getAttribute('id')}'>
<vCard xmlns='vcard-temp'></vCard>
</iq>`)
);

sent_stanza = await u.waitUntil(() =>
IQ_stanzas.filter((s) => sizzle(`iq[to="${contact_jid}"] vCard`, s).length).pop()
);
expect(sent_stanza).toEqualStanza(stx`
<iq type="get"
to="[email protected]"
xmlns="jabber:client"
id="${sent_stanza.getAttribute('id')}">
<vCard xmlns="vcard-temp"/>
</iq>`);

const response = await fetch('/base/logo/conversejs-filled-192.png');
const blob = await response.blob();

_converse.api.connection.get()._dataRecv(
mock.createRequest(stx`
<iq from='${contact_jid}'
xmlns="jabber:client"
to='${_converse.session.get('jid')}'
type='result'
id='${sent_stanza.getAttribute('id')}'>
<vCard xmlns='vcard-temp'>
<BDAY>1476-06-09</BDAY>
<CTRY>Italy</CTRY>
<LOCALITY>Verona</LOCALITY>
<N><GIVEN>Mercutio</GIVEN><FAMILY>Capulet</FAMILY></N>
<EMAIL><USERID>[email protected]</USERID></EMAIL>
<PHOTO>
<TYPE>${blob.type}</TYPE>
<BINVAL></BINVAL>
<EXTVAL>http://localhost:9876/base/logo/conversejs-filled-192.png</EXTVAL>
</PHOTO>
</vCard>
</iq>`)
);

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`<presence xmlns="jabber:client"
to="${_converse.session.get('jid')}"
from="${contact_jid}/resource">
<x xmlns='vcard-temp:x:update'>
<photo></photo>
</x>
</presence>`
)
);

sent_stanza = await u.waitUntil(() => IQ_stanzas.filter((s) => sizzle('vCard', s).length).pop(), 500);
expect(sent_stanza).toEqualStanza(stx`
<iq type="get"
to="[email protected]"
xmlns="jabber:client"
id="${sent_stanza.getAttribute('id')}">
<vCard xmlns="vcard-temp"/>
</iq>`);

_converse.api.connection.get()._dataRecv(
mock.createRequest(stx`
<iq from='${contact_jid}'
xmlns="jabber:client"
to='${_converse.session.get('jid')}'
type='result'
id='${sent_stanza.getAttribute('id')}'>
<vCard xmlns='vcard-temp'>
<BDAY>1476-06-09</BDAY>
<CTRY>Italy</CTRY>
<LOCALITY>Verona</LOCALITY>
<N><GIVEN>Mercutio</GIVEN><FAMILY>Capulet</FAMILY></N>
<EMAIL><USERID>[email protected]</USERID></EMAIL>
<PHOTO></PHOTO>
</vCard>
</iq>`)
);

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 () {
Expand Down
2 changes: 2 additions & 0 deletions src/headless/plugins/vcard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface VCardResult {
image?: string;
image_hash?: string;
image_type?: string;
image_url?: string;
nickname?: string;
role?: string;
stanza: Element;
Expand All @@ -25,6 +26,7 @@ export type VCardData = {
role?: string;
email?: string;
url?: string;
image_url?: string;
image_type?: string;
image?: string;
};
7 changes: 6 additions & 1 deletion src/headless/plugins/vcard/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/shared/avatar/avatar.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,25 @@ export default class Avatar extends CustomElement {
}

render() {
let image_url;
let image_type;
let image;
let data_uri;
if (this.pickerdata) {
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),
});
Expand Down
10 changes: 7 additions & 3 deletions src/shared/avatar/templates/avatar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading