Prechádzať zdrojové kódy

Add support for XEP-0153

JC Brand 2 mesiacov pred
rodič
commit
a122fc2000

+ 6 - 0
conversejs.doap

@@ -109,6 +109,12 @@
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0144.html"/>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0153.html"/>
+        <xmpp:since>11.0.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0156.html"/>

+ 7 - 0
karma.conf.js

@@ -16,6 +16,12 @@ module.exports = function(config) {
       "dist/converse.css",
       { pattern: "dist/images/**/*.*", included: false },
       { pattern: "dist/webfonts/**/*.*", included: false },
+      { pattern: "logo/conversejs-filled.svg",
+        watched: false,
+        included: false,
+        served: true,
+        nocache: false
+      },
       { pattern: "logo/conversejs-filled-192.svg",
         watched: false,
         included: false,
@@ -48,6 +54,7 @@ module.exports = function(config) {
       { pattern: "src/headless/plugins/roster/tests/presence.js", type: 'module' },
       { pattern: "src/headless/plugins/smacks/tests/smacks.js", type: 'module' },
       { pattern: "src/headless/plugins/status/tests/status.js", type: 'module' },
+      { pattern: "src/headless/plugins/vcard/tests/update.js", type: 'module' },
       { pattern: "src/headless/shared/settings/tests/settings.js", type: 'module' },
       { pattern: "src/headless/tests/converse.js", type: 'module' },
       { pattern: "src/headless/tests/eventemitter.js", type: 'module' },

+ 65 - 69
src/headless/plugins/roster/tests/presence.js

@@ -7,40 +7,36 @@ describe("A received presence stanza", function () {
     it("has its priority taken into account",
         mock.initConverse([], {}, async (_converse) => {
 
-        const u = converse.env.utils;
         mock.openControlBox(_converse);
         await mock.waitForRoster(_converse, 'current');
         const contact_jid = mock.cur_names[8].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         const contact = await _converse.api.contacts.get(contact_jid);
-        let stanza = u.toStanza(`
+        let stanza = stx`
             <presence xmlns="jabber:client"
                     to="romeo@montague.lit/converse.js-21770972"
                     from="${contact_jid}/priority-1-resource">
                 <priority>1</priority>
                 <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" ext="voice-v1 camera-v1 video-v1"
                     ver="AcN1/PEN8nq7AHD+9jpxMV4U6YM=" node="http://pidgin.im/"/>
-                <x xmlns="vcard-temp:x:update">
-                    <photo>ce51d94f7f22b87a21274abb93710b9eb7cc1c65</photo>
-                </x>
                 <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T20:26:05Z" from="${contact_jid}/priority-1-resource"/>
-            </presence>`);
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(contact.presence.get('show')).toBe('online');
         expect(contact.presence.resources.length).toBe(1);
         expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
         expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          from="'+contact_jid+'/priority-0-resource">'+
-        '    <status/>'+
-        '    <priority>0</priority>'+
-        '    <show>xa</show>'+
-        '    <c xmlns="http://jabber.org/protocol/caps" ver="GyIX/Kpa4ScVmsZCxRBboJlLAYU=" hash="sha-1"'+
-        '       node="http://www.igniterealtime.org/projects/smack/"/>'+
-        '    <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T17:02:24Z" from="'+contact_jid+'/priority-0-resource"/>'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                    to="romeo@montague.lit/converse.js-21770972"
+                    from="${contact_jid}/priority-0-resource">
+                <status/>
+                <priority>0</priority>
+                <show>xa</show>
+                <c xmlns="http://jabber.org/protocol/caps" ver="GyIX/Kpa4ScVmsZCxRBboJlLAYU=" hash="sha-1"
+                    node="http://www.igniterealtime.org/projects/smack/"/>
+                <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T17:02:24Z" from="'+contact_jid+'/priority-0-resource"/>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(contact.presence.get('show')).toBe('online');
 
@@ -50,13 +46,13 @@ describe("A received presence stanza", function () {
         expect(contact.presence.resources.get('priority-1-resource').get('priority')).toBe(1);
         expect(contact.presence.resources.get('priority-1-resource').get('show')).toBe('online');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          from="'+contact_jid+'/priority-2-resource">'+
-        '    <priority>2</priority>'+
-        '    <show>dnd</show>'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                      to="romeo@montague.lit/converse.js-21770972"
+                      from="${contact_jid}/priority-2-resource">
+                <priority>2</priority>
+                <show>dnd</show>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(contact.presence.get('show')).toBe('dnd');
         expect(contact.presence.resources.length).toBe(3);
@@ -67,13 +63,13 @@ describe("A received presence stanza", function () {
         expect(contact.presence.resources.get('priority-2-resource').get('priority')).toBe(2);
         expect(contact.presence.resources.get('priority-2-resource').get('show')).toBe('dnd');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          from="'+contact_jid+'/priority-3-resource">'+
-        '    <priority>3</priority>'+
-        '    <show>away</show>'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                    to="romeo@montague.lit/converse.js-21770972"
+                    from="${contact_jid}/priority-3-resource">
+                <priority>3</priority>
+                <show>away</show>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('away');
         expect(contact.presence.resources.length).toBe(4);
@@ -86,14 +82,14 @@ describe("A received presence stanza", function () {
         expect(contact.presence.resources.get('priority-3-resource').get('priority')).toBe(3);
         expect(contact.presence.resources.get('priority-3-resource').get('show')).toBe('away');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          from="'+contact_jid+'/older-priority-1-resource">'+
-        '    <priority>1</priority>'+
-        '    <show>dnd</show>'+
-        '    <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T15:02:24Z" from="'+contact_jid+'/older-priority-1-resource"/>'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                    to="romeo@montague.lit/converse.js-21770972"
+                    from="${contact_jid}/older-priority-1-resource">
+                <priority>1</priority>
+                <show>dnd</show>
+                <delay xmlns="urn:xmpp:delay" stamp="2017-02-15T15:02:24Z" from="${contact_jid}/older-priority-1-resource"/>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('away');
         expect(contact.presence.resources.length).toBe(5);
@@ -108,12 +104,12 @@ describe("A received presence stanza", function () {
         expect(contact.presence.resources.get('priority-3-resource').get('priority')).toBe(3);
         expect(contact.presence.resources.get('priority-3-resource').get('show')).toBe('away');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          type="unavailable"'+
-        '          from="'+contact_jid+'/priority-3-resource">'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                      to="romeo@montague.lit/converse.js-21770972"
+                      type="unavailable"
+                      from="${contact_jid}/priority-3-resource">
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('dnd');
         expect(contact.presence.resources.length).toBe(4);
@@ -126,12 +122,12 @@ describe("A received presence stanza", function () {
         expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
         expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          type="unavailable"'+
-        '          from="'+contact_jid+'/priority-2-resource">'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                      to="romeo@montague.lit/converse.js-21770972"
+                      type="unavailable"
+                      from="${contact_jid}/priority-2-resource">
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('online');
         expect(contact.presence.resources.length).toBe(3);
@@ -142,12 +138,12 @@ describe("A received presence stanza", function () {
         expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
         expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          type="unavailable"'+
-        '          from="'+contact_jid+'/priority-1-resource">'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                      to="romeo@montague.lit/converse.js-21770972"
+                      type="unavailable"
+                      from="${contact_jid}/priority-1-resource">
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('dnd');
         expect(contact.presence.resources.length).toBe(2);
@@ -156,24 +152,24 @@ describe("A received presence stanza", function () {
         expect(contact.presence.resources.get('older-priority-1-resource').get('priority')).toBe(1);
         expect(contact.presence.resources.get('older-priority-1-resource').get('show')).toBe('dnd');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          type="unavailable"'+
-        '          from="'+contact_jid+'/older-priority-1-resource">'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                      to="romeo@montague.lit/converse.js-21770972"
+                      type="unavailable"
+                      from="${contact_jid}/older-priority-1-resource">
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('xa');
         expect(contact.presence.resources.length).toBe(1);
         expect(contact.presence.resources.get('priority-0-resource').get('priority')).toBe(0);
         expect(contact.presence.resources.get('priority-0-resource').get('show')).toBe('xa');
 
-        stanza = u.toStanza(
-        '<presence xmlns="jabber:client"'+
-        '          to="romeo@montague.lit/converse.js-21770972"'+
-        '          type="unavailable"'+
-        '          from="'+contact_jid+'/priority-0-resource">'+
-        '</presence>');
+        stanza = stx`
+            <presence xmlns="jabber:client"
+                      to="romeo@montague.lit/converse.js-21770972"
+                      type="unavailable"
+                      from="${contact_jid}/priority-0-resource">
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(_converse.roster.get(contact_jid).presence.get('show')).toBe('offline');
         expect(contact.presence.resources.length).toBe(0);

+ 27 - 19
src/headless/plugins/vcard/plugin.js

@@ -2,22 +2,27 @@
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import "../status/index.js";
-import VCard from "./vcard.js";
-import _converse from "../../shared/_converse.js";
-import api from "../../shared/api/index.js";
-import converse from "../../shared/api/public.js";
-import vcard_api from "./api.js";
-import VCards from "./vcards";
-import { clearVCardsSession, onOccupantAvatarChanged } from "./utils.js";
+import '../status/index.js';
+import VCard from './vcard.js';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import converse from '../../shared/api/public.js';
+import vcard_api from './api.js';
+import VCards from './vcards';
+import {
+    clearVCardsSession,
+    onOccupantAvatarChanged,
+    registerPresenceHandler,
+    unregisterPresenceHandler,
+} from './utils.js';
 
 const { Strophe } = converse.env;
 
-converse.plugins.add("converse-vcard", {
-    dependencies: ["converse-status", "converse-roster"],
+converse.plugins.add('converse-vcard', {
+    dependencies: ['converse-status', 'converse-roster'],
 
     enabled() {
-        return !api.settings.get("blacklisted_plugins")?.includes("converse-vcard");
+        return !api.settings.get('blacklisted_plugins')?.includes('converse-vcard');
     },
 
     initialize() {
@@ -25,7 +30,7 @@ converse.plugins.add("converse-vcard", {
             lazy_load_vcards: true,
         });
 
-        api.promises.add("VCardsInitialized");
+        api.promises.add('VCardsInitialized');
 
         Object.assign(_converse.api, vcard_api);
 
@@ -34,24 +39,27 @@ converse.plugins.add("converse-vcard", {
         Object.assign(_converse.exports, exports);
 
         api.listen.on(
-            "chatRoomInitialized",
+            'chatRoomInitialized',
             /** @param {import('../muc/muc').default} m */ (m) => {
-                m.listenTo(m.occupants, "change:image_hash", (o) => onOccupantAvatarChanged(o));
+                m.listenTo(m.occupants, 'change:image_hash', (o) => onOccupantAvatarChanged(o));
             }
         );
 
-        api.listen.on("addClientFeatures", () => api.disco.own.features.add(Strophe.NS.VCARD));
-        api.listen.on("clearSession", () => clearVCardsSession());
+        api.listen.on('addClientFeatures', () => api.disco.own.features.add(Strophe.NS.VCARD));
+        api.listen.on('clearSession', () => clearVCardsSession());
 
-        api.listen.on("visibilityChanged", ({ el }) => {
+        api.listen.on('visibilityChanged', ({ el }) => {
             const { model } = el;
-            if (model?.vcard) model.vcard.trigger("visibilityChanged");
+            if (model?.vcard) model.vcard.trigger('visibilityChanged');
         });
 
-        api.listen.on("connected", () => {
+        api.listen.on('connected', () => {
             const vcards = new _converse.exports.VCards();
             _converse.state.vcards = vcards;
             Object.assign(_converse, { vcards }); // XXX DEPRECATED
         });
+
+        api.listen.on('presencesInitialized', () => registerPresenceHandler());
+        api.listen.on('beforeTearDown', () => unregisterPresenceHandler());
     },
 });

+ 204 - 0
src/headless/plugins/vcard/tests/update.js

@@ -0,0 +1,204 @@
+describe('A VCard', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it(
+        'is replaced when a XEP-0153 presence update is received',
+        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>01b87fcd030b72895ff8e88db57ec525450f000d</photo>
+                            </x>
+                        </presence>`
+                )
+            );
+            const sent_stanza = await u.waitUntil(() => IQ_stanzas.filter((s) => sizzle('vCard', s).length).pop(), 500);
+            expect(sent_stanza).toEqualStanza(stx`
+                <iq type="get"
+                        to="mercutio@montague.lit"
+                        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();
+            const arrayBuffer = await blob.arrayBuffer();
+            const byteArray = new Uint8Array(arrayBuffer);
+            const base64Image = btoa(String.fromCharCode(...byteArray));
+
+            _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>mercutio@shakespeare.lit</USERID></EMAIL>
+                        <PHOTO>
+                        <TYPE>${blob.type}</TYPE>
+                        <BINVAL>${base64Image}</BINVAL>
+                        </PHOTO>
+                    </vCard>
+                </iq>`)
+            );
+
+            const { vcard } = await api.contacts.get(contact_jid);
+            await u.waitUntil(() => vcard.get('image_hash') === '6d52ba485d3fd69c96b8d424ceaf8082a7a00e51');
+            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>6d52ba485d3fd69c96b8d424ceaf8082a7a00e51</photo>
+                            </x>
+                        </presence>`
+                )
+            );
+
+            return new Promise((resolve) => {
+                setTimeout(() => {
+                    expect(IQ_stanzas.filter((s) => sizzle('vCard', s).length).length).toBe(0);
+                    resolve();
+                }, 251);
+            });
+        })
+    );
+
+    it(
+        'is removed when a XEP-0153 presence update is received',
+        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()
+            );
+            _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="mercutio@montague.lit"
+                        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();
+            const arrayBuffer = await blob.arrayBuffer();
+            const byteArray = new Uint8Array(arrayBuffer);
+            const base64Image = btoa(String.fromCharCode(...byteArray));
+
+            _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>mercutio@shakespeare.lit</USERID></EMAIL>
+                        <PHOTO>
+                            <TYPE>${blob.type}</TYPE>
+                            <BINVAL>${base64Image}</BINVAL>
+                        </PHOTO>
+                    </vCard>
+                </iq>`)
+            );
+
+            const contact = await api.contacts.get(contact_jid);
+            await u.waitUntil(() => contact.vcard.get('image'));
+            expect(contact.vcard.get('image')).toEqual(base64Image);
+
+            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="mercutio@montague.lit"
+                        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>mercutio@shakespeare.lit</USERID></EMAIL>
+                        <PHOTO></PHOTO>
+                    </vCard>
+                </iq>`)
+            );
+
+            await u.waitUntil(() => !contact.vcard.get('image'));
+            expect(contact.vcard.get('image_hash')).toBeUndefined();
+        })
+    );
+});

+ 90 - 42
src/headless/plugins/vcard/utils.js

@@ -7,15 +7,17 @@
  * @typedef {import('../muc/occupant.js').default} MUCOccupant
  * @typedef {import('@converse/skeletor/src/types/helpers.js').Model} Model
  */
-import _converse from "../../shared/_converse.js";
-import api from "../../shared/api/index.js";
-import converse from "../../shared/api/public.js";
-import log from "@converse/log";
-import { shouldClearCache } from "../../utils/session.js";
-import { isElement } from "../../utils/html.js";
-import { parseErrorStanza } from "../../shared/parsers.js";
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import converse from '../../shared/api/public.js';
+import log from '@converse/log';
+import { shouldClearCache } from '../../utils/session.js';
+import { isElement } from '../../utils/html.js';
+import { parseErrorStanza } from '../../shared/parsers.js';
 
-const { Strophe, $iq, u } = converse.env;
+const { Strophe, $iq, u, sizzle } = converse.env;
+
+Strophe.addNamespace('VCARD_UPDATE', 'vcard-temp:x:update');
 
 /**
  * @param {Element} iq
@@ -23,22 +25,23 @@ const { Strophe, $iq, u } = converse.env;
  */
 export async function onVCardData(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,
+        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,
+        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 ab = await crypto.subtle.digest("SHA-1", buffer);
-        result["image_hash"] = u.arrayBufferToHex(ab);
+        const ab = await crypto.subtle.digest('SHA-1', buffer);
+        result['image_hash'] = u.arrayBufferToHex(ab);
     }
     return result;
 }
@@ -49,9 +52,9 @@ export async function onVCardData(iq) {
  * @param {Element} [vcard_el]
  */
 export function createStanza(type, jid, vcard_el) {
-    const iq = $iq(jid ? { "type": type, "to": jid } : { "type": type });
+    const iq = $iq(jid ? { 'type': type, 'to': jid } : { 'type': type });
     if (!vcard_el) {
-        iq.c("vCard", { "xmlns": Strophe.NS.VCARD });
+        iq.c('vCard', { 'xmlns': Strophe.NS.VCARD });
     } else {
         iq.cnode(vcard_el);
     }
@@ -62,13 +65,13 @@ export function createStanza(type, jid, vcard_el) {
  * @param {MUCOccupant} occupant
  */
 export function onOccupantAvatarChanged(occupant) {
-    const hash = occupant.get("image_hash");
+    const hash = occupant.get('image_hash');
     const vcards = [];
-    if (occupant.get("jid")) {
-        vcards.push(_converse.state.vcards.get(occupant.get("jid")));
+    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.push(_converse.state.vcards.get(occupant.get('from')));
+    vcards.forEach((v) => hash && v && v?.get('image_hash') !== hash && api.vcard.update(v, true));
 }
 
 /**
@@ -77,7 +80,7 @@ export function onOccupantAvatarChanged(occupant) {
  * @returns {Promise<VCard|null>}
  */
 export async function getVCardForModel(model, lazy_load = false) {
-    await api.waitUntil("VCardsInitialized");
+    await api.waitUntil('VCardsInitialized');
 
     let vcard;
     if (model instanceof _converse.exports.MUCOccupant) {
@@ -87,12 +90,12 @@ export async function getVCardForModel(model, lazy_load = false) {
     } else {
         let jid;
         if (model instanceof _converse.exports.Message) {
-            if (["error", "info"].includes(model.get("type"))) {
+            if (['error', 'info'].includes(model.get('type'))) {
                 return;
             }
-            jid = Strophe.getBareJidFromJid(model.get("from"));
+            jid = Strophe.getBareJidFromJid(model.get('from'));
         } else {
-            jid = model.get("jid");
+            jid = model.get('jid');
         }
 
         if (!jid) {
@@ -104,7 +107,7 @@ export async function getVCardForModel(model, lazy_load = false) {
     }
 
     if (vcard) {
-        vcard.on("change", () => model.trigger("vcard:change"));
+        vcard.on('change', () => model.trigger('vcard:change'));
     }
     return vcard;
 }
@@ -115,16 +118,16 @@ export async function getVCardForModel(model, lazy_load = false) {
  * @returns {Promise<VCard|null>}
  */
 export async function getVCardForOccupant(occupant, lazy_load = true) {
-    await api.waitUntil("VCardsInitialized");
+    await api.waitUntil('VCardsInitialized');
 
     const { vcards, profile } = _converse.state;
     const muc = occupant?.collection?.chatroom;
-    const nick = occupant.get("nick");
+    const nick = occupant.get('nick');
 
-    if (nick && muc?.get("nick") === nick) {
+    if (nick && muc?.get('nick') === nick) {
         return profile.vcard;
     } else {
-        const jid = occupant.get("jid") || occupant.get("from");
+        const jid = occupant.get('jid') || occupant.get('from');
         if (jid) {
             return vcards.get(jid) || vcards.create({ jid }, { lazy_load });
         } else {
@@ -140,21 +143,21 @@ export async function getVCardForOccupant(occupant, lazy_load = true) {
  * @returns {Promise<VCard|null>}
  */
 async function getVCardForMUCMessage(message, lazy_load = true) {
-    if (["error", "info"].includes(message.get("type"))) return;
+    if (['error', 'info'].includes(message.get('type'))) return;
 
-    await api.waitUntil("VCardsInitialized");
+    await api.waitUntil('VCardsInitialized');
     const { vcards, profile } = _converse.state;
     const muc = message?.collection?.chatbox;
-    const nick = Strophe.getResourceFromJid(message.get("from"));
+    const nick = Strophe.getResourceFromJid(message.get('from'));
 
-    if (nick && muc?.get("nick") === nick) {
+    if (nick && muc?.get('nick') === nick) {
         return profile.vcard;
     } else {
-        const jid = message.occupant?.get("jid") || message.get("from");
+        const jid = message.occupant?.get('jid') || message.get('from');
         if (jid) {
             return vcards.get(jid) || vcards.create({ jid }, { lazy_load });
         } else {
-            log.warn(`Could not get VCard for message because no JID found! msgid: ${message.get("msgid")}`);
+            log.warn(`Could not get VCard for message because no JID found! msgid: ${message.get('msgid')}`);
             return null;
         }
     }
@@ -162,7 +165,7 @@ async function getVCardForMUCMessage(message, lazy_load = true) {
 
 export function clearVCardsSession() {
     if (shouldClearCache(_converse)) {
-        api.promises.add("VCardsInitialized");
+        api.promises.add('VCardsInitialized');
         if (_converse.state.vcards) {
             _converse.state.vcards.clearStore();
             Object.assign(_converse, { vcards: undefined }); // XXX DEPRECATED
@@ -175,11 +178,11 @@ export function clearVCardsSession() {
  * @param {string} jid
  */
 export async function fetchVCard(jid) {
-    const bare_jid = _converse.session.get("bare_jid");
+    const bare_jid = _converse.session.get('bare_jid');
     const to = Strophe.getBareJidFromJid(jid) === bare_jid ? null : jid;
     let iq;
     try {
-        iq = await api.sendIQ(createStanza("get", to));
+        iq = await api.sendIQ(createStanza('get', to));
     } catch (error) {
         const { message: error_msg } = (isElement(error) ? await parseErrorStanza(error) : error) ?? {};
         return {
@@ -191,3 +194,48 @@ export async function fetchVCard(jid) {
     }
     return onVCardData(iq);
 }
+
+/**
+ * @param {Element} pres
+ */
+async function handleVCardUpdatePresence(pres) {
+    await api.waitUntil('VCardsInitialized');
+    const photo = sizzle(`x[xmlns="${Strophe.NS.VCARD_UPDATE}"] photo`, pres).pop();
+    if (photo) {
+        const avatar_hash = photo.textContent;
+        const from_jid = Strophe.getBareJidFromJid(pres.getAttribute('from'));
+        const vcard = await _converse.state.vcards.get(from_jid);
+        if (vcard?.get('image_hash') !== avatar_hash) {
+            api.vcard.update(from_jid, true).catch((e) => log.error(e));
+        }
+    }
+}
+
+let presence_ref;
+
+export function unregisterPresenceHandler() {
+    if (presence_ref) {
+        const connection = api.connection.get();
+        connection.deleteHandler(presence_ref);
+        presence_ref = null;
+    }
+}
+
+export function registerPresenceHandler() {
+    unregisterPresenceHandler();
+    const connection = api.connection.get();
+    presence_ref = connection.addHandler(
+        /** @param {Element} pres */
+        (pres) => {
+            try {
+                handleVCardUpdatePresence(pres);
+            } catch (e) {
+                log.error(e);
+            }
+            return true;
+        },
+        null,
+        'presence',
+        null
+    );
+}

+ 2 - 2
src/plugins/chatview/tests/message-images.js

@@ -5,7 +5,7 @@ describe("A Chat Message", function () {
 
     it("will render images from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
         await mock.waitForRoster(_converse, 'current');
-        const base_url = 'https://conversejs.org';
+        const base_url = `${window.origin}/base`;
         let message = base_url+"/logo/conversejs-filled.svg";
         const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
         await mock.openChatBoxFor(_converse, contact_jid);
@@ -17,7 +17,7 @@ describe("A Chat Message", function () {
         let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
         expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
             `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                `<img class="chat-image img-thumbnail" loading="lazy" src="https://conversejs.org/logo/conversejs-filled.svg">`+
+                `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg">`+
             `</a>`);
 
         message += "?param1=val1&param2=val2";

+ 1 - 1
src/plugins/omemo/tests/media-sharing.js

@@ -5,7 +5,7 @@ describe("The OMEMO module", function() {
 
     beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
-    fit("shows an error when it can't download a received encrypted file",
+    it("shows an error when it can't download a received encrypted file",
         mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
 
         await mock.waitForRoster(_converse, 'current', 1);

+ 3 - 3
src/shared/tests/mock.js

@@ -753,7 +753,7 @@ function getMockVcardFetcher (settings) {
         if (nickname) vcard.c('NICKNAME').t(nickname);
         const vcard_el = vcard.tree();
 
-        return {
+        return Promise.resolve({
             stanza: vcard_el,
             fullname: vcard_el.querySelector('FN')?.textContent,
             nickname: vcard_el.querySelector('NICKNAME')?.textContent,
@@ -762,7 +762,7 @@ function getMockVcardFetcher (settings) {
             url: vcard_el.querySelector('URL')?.textContent,
             vcard_updated: dayjs().format(),
             vcard_error: undefined
-        };
+        });
     }
 }
 
@@ -793,7 +793,7 @@ async function _initConverse (settings) {
 
     window._converse = _converse;
 
-    if (_converse.api.vcard) {
+    if (!settings?.no_vcard_mocks && _converse.api.vcard) {
         _converse.api.vcard.get = getMockVcardFetcher(settings);
     }