Browse Source

Add support for rendering XEP-0392 colored avatars

Updates #1349

- Show user initials and XEP-0392 color when no avatar is present
- Show MUC details modal when clicking MUC avatar in header
- Render MUC avatars in the rooms list
JC Brand 1 năm trước cách đây
mục cha
commit
1da7643eea
85 tập tin đã thay đổi với 1203 bổ sung530 xóa
  1. 1 1
      CHANGES.md
  2. 6 0
      conversejs.doap
  3. 0 1
      dev.html
  4. 3 1
      karma.conf.js
  5. 1 1
      src/headless/plugins/chat/message.js
  6. 30 12
      src/headless/plugins/chat/model-with-contact.js
  7. 16 13
      src/headless/plugins/chat/model.js
  8. 1 1
      src/headless/plugins/muc/muc.js
  9. 5 20
      src/headless/plugins/muc/occupant.js
  10. 3 5
      src/headless/plugins/roster/contact.js
  11. 2 3
      src/headless/plugins/roster/utils.js
  12. 27 3
      src/headless/plugins/status/status.js
  13. 2 2
      src/headless/plugins/vcard/plugin.js
  14. 1 33
      src/headless/plugins/vcard/vcard.js
  15. 0 5
      src/headless/shared/_converse.js
  16. 37 0
      src/headless/shared/color.js
  17. 0 5
      src/headless/shared/constants.js
  18. 5 5
      src/headless/types/plugins/chat/model-with-contact.d.ts
  19. 18 15
      src/headless/types/plugins/chat/model.d.ts
  20. 1 5
      src/headless/types/plugins/muc/muc.d.ts
  21. 5 7
      src/headless/types/plugins/muc/occupant.d.ts
  22. 2 4
      src/headless/types/plugins/roster/contact.d.ts
  23. 8 2
      src/headless/types/plugins/status/status.d.ts
  24. 0 10
      src/headless/types/plugins/vcard/vcard.d.ts
  25. 6 3
      src/headless/types/shared/_converse.d.ts
  26. 0 2
      src/headless/types/shared/api/index.d.ts
  27. 14 0
      src/headless/types/shared/color.d.ts
  28. 0 2
      src/headless/types/shared/constants.d.ts
  29. 1 1
      src/headless/types/utils/index.d.ts
  30. 19 25
      src/headless/utils/color.js
  31. 0 1
      src/headless/utils/index.js
  32. 1 1
      src/i18n/af/LC_MESSAGES/converse.po
  33. 1 1
      src/plugins/bookmark-views/tests/bookmarks-list.js
  34. 4 3
      src/plugins/chatview/templates/chat-head.js
  35. 1 1
      src/plugins/chatview/tests/me-messages.js
  36. 93 0
      src/plugins/chatview/tests/message-avatar.js
  37. 1 1
      src/plugins/chatview/tests/messages.js
  38. 2 2
      src/plugins/chatview/tests/spoilers.js
  39. 4 0
      src/plugins/controlbox/styles/_controlbox.scss
  40. 1 1
      src/plugins/minimize/tests/minchats.js
  41. 26 0
      src/plugins/muc-views/heading.js
  42. 9 9
      src/plugins/muc-views/message-form.js
  43. 2 2
      src/plugins/muc-views/modals/muc-details.js
  44. 40 23
      src/plugins/muc-views/modals/templates/muc-details.js
  45. 2 1
      src/plugins/muc-views/modals/templates/occupant.js
  46. 5 1
      src/plugins/muc-views/styles/controlbox.scss
  47. 6 0
      src/plugins/muc-views/styles/muc-details-modal.scss
  48. 5 0
      src/plugins/muc-views/styles/muc-occupants.scss
  49. 23 16
      src/plugins/muc-views/templates/muc-head.js
  50. 16 4
      src/plugins/muc-views/templates/occupant.js
  51. 73 21
      src/plugins/muc-views/tests/autocomplete.js
  52. 202 0
      src/plugins/muc-views/tests/muc-avatar.js
  53. 1 1
      src/plugins/muc-views/tests/muc.js
  54. 53 19
      src/plugins/muc-views/utils.js
  55. 1 1
      src/plugins/profile/modals/profile.js
  56. 13 3
      src/plugins/profile/templates/profile.js
  57. 1 1
      src/plugins/profile/templates/profile_modal.js
  58. 6 0
      src/plugins/roomslist/styles/roomsgroups.scss
  59. 33 15
      src/plugins/roomslist/templates/roomslist.js
  60. 111 102
      src/plugins/roomslist/tests/roomslist.js
  61. 1 9
      src/plugins/roomslist/view.js
  62. 1 1
      src/plugins/rosterview/contactview.js
  63. 4 0
      src/plugins/rosterview/styles/roster.scss
  64. 4 3
      src/plugins/rosterview/templates/roster.js
  65. 26 11
      src/plugins/rosterview/templates/roster_item.js
  66. 1 1
      src/plugins/rosterview/tests/protocol.js
  67. 7 6
      src/plugins/rosterview/tests/roster.js
  68. 46 23
      src/shared/avatar/avatar.js
  69. 12 0
      src/shared/avatar/avatar.scss
  70. 21 8
      src/shared/avatar/templates/avatar.js
  71. 3 1
      src/shared/chat/message.js
  72. 5 2
      src/shared/chat/templates/file-progress.js
  73. 8 6
      src/shared/chat/templates/message.js
  74. 13 5
      src/shared/components/image-picker.js
  75. 34 8
      src/types/plugins/muc-views/heading.d.ts
  76. 1 1
      src/types/plugins/muc-views/modals/templates/muc-details.d.ts
  77. 1 1
      src/types/plugins/muc-views/templates/muc-head.d.ts
  78. 2 1
      src/types/plugins/muc-views/templates/occupant.d.ts
  79. 27 5
      src/types/plugins/muc-views/utils.d.ts
  80. 0 2
      src/types/plugins/roomslist/view.d.ts
  81. 2 1
      src/types/plugins/rosterview/templates/roster_item.d.ts
  82. 11 2
      src/types/shared/avatar/avatar.d.ts
  83. 7 4
      src/types/shared/components/image-picker.d.ts
  84. 3 3
      src/types/utils/color.d.ts
  85. 13 9
      src/utils/color.js

+ 1 - 1
CHANGES.md

@@ -3,7 +3,7 @@
 ## 11.0.0 (Unreleased)
 
 - #1195: Add actions to quote and copy messages
-- #1349: New config option [colorize_username](https://conversejs.org/docs/html/configuration.html#colorize_username)
+- #1349: XEP-0392 Consistent Color Generation
 - #2716: Fix issue with chat display when opening via URL
 - #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups
 - #3155: Some ad-hoc commands not working

+ 6 - 0
conversejs.doap

@@ -243,6 +243,12 @@
         <xmpp:since>4.0.0</xmpp:since>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0392.html"/>
+        <xmpp:since>11.0.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0393.html"/>

+ 0 - 1
dev.html

@@ -28,7 +28,6 @@
     });
 
     converse.initialize({
-        colorize_username: true,
         i18n: 'af',
         theme: 'dracula',
         auto_away: 300,

+ 3 - 1
karma.conf.js

@@ -54,6 +54,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-audio.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/message-avatar.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-gifs.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-images.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-videos.js", type: 'module' },
@@ -70,8 +71,8 @@ module.exports = function(config) {
       { pattern: "src/plugins/mam-views/tests/mam.js", type: 'module' },
       { pattern: "src/plugins/mam-views/tests/placeholder.js", type: 'module' },
       { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' },
-      { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/actions.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/disco.js", type: 'module' },
@@ -88,6 +89,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/modtools.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-add-modal.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-api.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/muc-avatar.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-list-modal.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-mentions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-messages.js", type: 'module' },

+ 1 - 1
src/headless/plugins/chat/message.js

@@ -65,7 +65,7 @@ class Message extends ModelWithContact {
     setContact () {
         if (['chat', 'normal'].includes(this.get('type'))) {
             ModelWithContact.prototype.initialize.apply(this, arguments);
-            return this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
+            return this.setModelContact(Strophe.getBareJidFromJid(this.get('from')));
         }
     }
 

+ 30 - 12
src/headless/plugins/chat/model-with-contact.js

@@ -1,22 +1,25 @@
-import { Model } from '@converse/skeletor';
 import { getOpenPromise } from '@converse/openpromise';
-import api from "../../shared/api/index.js";
+import { Strophe } from 'strophe.js';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import { ColorAwareModel } from '../../shared/color.js';
 
-class ModelWithContact extends Model {
+class ModelWithContact extends ColorAwareModel {
     /**
      * @typedef {import('../vcard/vcard').default} VCard
      * @typedef {import('../roster/contact').default} RosterContact
+     * @typedef {import('shared/_converse.js').XMPPStatus} XMPPStatus
      */
 
-    initialize () {
+    initialize() {
         super.initialize();
         this.rosterContactAdded = getOpenPromise();
         /**
          * @public
-         * @type {RosterContact}
+         * @type {RosterContact|XMPPStatus}
          */
-
         this.contact = null;
+
         /**
          * @public
          * @type {VCard}
@@ -27,13 +30,28 @@ class ModelWithContact extends Model {
     /**
      * @param {string} jid
      */
-    async setRosterContact (jid) {
-        const contact = await api.contacts.get(jid);
-        if (contact) {
-            this.contact = contact;
-            this.set('nickname', contact.get('nickname'));
-            this.rosterContactAdded.resolve();
+    async setModelContact(jid) {
+        if (this.contact?.get('jid') === jid) return;
+
+        if (Strophe.getBareJidFromJid(jid) === _converse.session.get('bare_jid')) {
+            this.contact = _converse.state.xmppstatus;
+        } else {
+            const contact = await api.contacts.get(jid);
+            if (contact) {
+                this.contact = contact;
+                this.set('nickname', contact.get('nickname'));
+            }
         }
+
+        this.listenTo(this.contact, 'change', (changed) => {
+            if (changed.nickname) {
+                this.set('nickname', changed.nickname);
+            }
+            this.trigger('contact:change', changed);
+        });
+
+        this.rosterContactAdded.resolve();
+        this.trigger('contactAdded', this.contact);
     }
 }
 

+ 16 - 13
src/headless/plugins/chat/model.js

@@ -1,10 +1,3 @@
-/**
- * @typedef {import('./message.js').default} Message
- * @typedef {import('../muc/muc.js').default} MUC
- * @typedef {import('../muc/message.js').default} MUCMessage
- * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
- * @typedef {import('strophe.js/src/builder.js').Builder} Strophe.Builder
- */
 import isMatch from "lodash-es/isMatch";
 import pick from "lodash-es/pick";
 import { getOpenPromise } from '@converse/openpromise';
@@ -32,6 +25,13 @@ const { Strophe, $msg, u } = converse.env;
  * Represents an open/ongoing chat conversation.
  */
 class ChatBox extends ModelWithContact {
+    /**
+     * @typedef {import('./message.js').default} Message
+     * @typedef {import('../muc/muc.js').default} MUC
+     * @typedef {import('../muc/message.js').default} MUCMessage
+     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
+     * @typedef {import('strophe.js').Builder} Builder
+     */
 
     defaults () {
         return {
@@ -72,8 +72,8 @@ class ChatBox extends ModelWithContact {
         if (this.get('type') === PRIVATE_CHAT_TYPE) {
             const { presences } = _converse.state;
             this.presence = presences.get(jid) || presences.create({ jid });
-            await this.setRosterContact(jid);
-            this.presence.on('change:show', item => this.onPresenceChanged(item));
+            await this.setModelContact(jid);
+            this.presence.on('change:show', (item) => this.onPresenceChanged(item));
         }
         this.on('change:chat_state', this.sendChatState, this);
         this.ui.on('change:scrolled', this.onScrolledChanged, this);
@@ -155,6 +155,9 @@ class ChatBox extends ModelWithContact {
         return this.messages.fetched;
     }
 
+    /**
+     * @param {Element} stanza
+     */
     async handleErrorMessageStanza (stanza) {
         const { __ } = _converse;
         const attrs = await parseMessage(stanza);
@@ -828,12 +831,12 @@ class ChatBox extends ModelWithContact {
         /**
          * *Hook* which allows plugins to update an outgoing message stanza
          * @event _converse#createMessageStanza
-         * @param { ChatBox | MUC } chat - The chat from
+         * @param {ChatBox|MUC} chat - The chat from
          *      which this message stanza is being sent.
-         * @param { Object } data - Message data
-         * @param { Message | MUCMessage } data.message
+         * @param {Object} data - Message data
+         * @param {Message|MUCMessage} data.message
          *      The message object from which the stanza is created and which gets persisted to storage.
-         * @param { Strophe.Builder } data.stanza
+         * @param {Builder} data.stanza
          *      The stanza that will be sent out, as a Strophe.Builder object.
          *      You can use the Strophe.Builder functions to extend the stanza.
          *      See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions

+ 1 - 1
src/headless/plugins/muc/muc.js

@@ -12,13 +12,13 @@
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import ChatBox from '../chat/model';
 import debounce from 'lodash-es/debounce';
 import log from '../../log';
 import p from '../../utils/parse-helpers';
 import pick from 'lodash-es/pick';
 import sizzle from 'sizzle';
 import { Model } from '@converse/skeletor';
+import ChatBox from '../chat/model.js';
 import { ROOMSTATUS } from './constants.js';
 import { CHATROOMS_TYPE, GONE } from '../../shared/constants.js';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js';

+ 5 - 20
src/headless/plugins/muc/occupant.js

@@ -1,9 +1,6 @@
-import { Model } from '@converse/skeletor';
 import api from '../../shared/api/index.js';
+import { ColorAwareModel } from '../../shared/color.js';
 import { AFFILIATIONS, ROLES } from './constants.js';
-import u from '../../utils/index.js';
-
-const { safeSave, colorize } = u;
 
 /**
  * Represents a participant in a MUC
@@ -11,7 +8,7 @@ const { safeSave, colorize } = u;
  * @namespace _converse.MUCOccupant
  * @memberOf _converse
  */
-class MUCOccupant extends Model {
+class MUCOccupant extends ColorAwareModel {
 
     constructor (attributes, options) {
         super(attributes, options);
@@ -69,9 +66,9 @@ class MUCOccupant extends Model {
     }
 
     /**
-    * Return affiliations which may be assigned by this occupant
-    * @returns {typeof AFFILIATIONS} An array of assignable affiliations
-    */
+     * Return affiliations which may be assigned by this occupant
+     * @returns {typeof AFFILIATIONS} An array of assignable affiliations
+     */
     getAssignableAffiliations () {
         let disabled = api.settings.get('modtools_disable_assign');
         if (!Array.isArray(disabled)) {
@@ -86,18 +83,6 @@ class MUCOccupant extends Model {
         }
     }
 
-    async setColor () {
-        const color = await colorize(this.getDisplayName());
-        safeSave(this, { color });
-    }
-
-    async getColor () {
-        if (!this.get('color')) {
-            await this.setColor();
-        }
-        return this.get('color');
-    }
-
     isMember () {
         return ['admin', 'owner', 'member'].includes(this.get('affiliation'));
     }

+ 3 - 5
src/headless/plugins/roster/contact.js

@@ -1,14 +1,14 @@
+import { getOpenPromise } from '@converse/openpromise';
 import '../../plugins/status/api.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import { Model } from '@converse/skeletor';
-import { getOpenPromise } from '@converse/openpromise';
+import { ColorAwareModel } from '../../shared/color.js';
 import { rejectPresenceSubscription } from './utils.js';
 
 const { Strophe, $iq, $pres } = converse.env;
 
-class RosterContact extends Model {
+class RosterContact extends ColorAwareModel {
     get idAttribute () {
         return 'jid';
     }
@@ -17,8 +17,6 @@ class RosterContact extends Model {
         return {
             'chat_state': undefined,
             'groups': [],
-            'image': _converse.DEFAULT_IMAGE,
-            'image_type': _converse.DEFAULT_IMAGE_TYPE,
             'num_unread': 0,
             'status': undefined,
         }

+ 2 - 3
src/headless/plugins/roster/utils.js

@@ -5,7 +5,6 @@ import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
 import log from "../../log.js";
-import { Strophe } from 'strophe.js';
 import { Model } from '@converse/skeletor';
 import { RosterFilter } from '../../plugins/roster/filter.js';
 import { PRIVATE_CHAT_TYPE } from "../../shared/constants";
@@ -191,7 +190,7 @@ export function onChatBoxesInitialized () {
 
     chatboxes.on('add', chatbox => {
         if (chatbox.get('type') === PRIVATE_CHAT_TYPE) {
-            chatbox.setRosterContact(chatbox.get('jid'));
+            chatbox.setModelContact(chatbox.get('jid'));
         }
     });
 }
@@ -206,7 +205,7 @@ export function onRosterContactsFetched () {
         // When a new contact is added, check if we already have a
         // chatbox open for it, and if so attach it to the chatbox.
         const chatbox = _converse.state.chatboxes.findWhere({ 'jid': contact.get('jid') });
-        chatbox?.setRosterContact(contact.get('jid'));
+        chatbox?.setModelContact(contact.get('jid'));
     });
 }
 

+ 27 - 3
src/headless/plugins/status/status.js

@@ -1,12 +1,12 @@
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import { Model } from '@converse/skeletor';
+import { ColorAwareModel } from '../../shared/color.js';
 import { isIdle, getIdleSeconds } from './utils.js';
 
 const { Strophe, $pres } = converse.env;
 
-export default class XMPPStatus extends Model {
+export default class XMPPStatus extends ColorAwareModel {
 
   constructor(attributes, options) {
         super(attributes, options);
@@ -17,6 +17,30 @@ export default class XMPPStatus extends Model {
         return { "status":  api.settings.get("default_state") }
     }
 
+    /**
+     * @param {string} attr
+     */
+    get(attr) {
+        if (attr === 'jid') {
+            return _converse.session.get('bare_jid');
+        } else if (attr === 'nickname') {
+            return api.settings.get('nickname');
+        }
+        return ColorAwareModel.prototype.get.call(this, attr);
+    }
+
+  /**
+   * @param {string|Object} key
+   * @param {string|Object} [val]
+   * @param {Object} [options]
+   */
+    set(key, val, options) {
+        if (key === 'jid' || key === 'nickname') {
+            throw new Error('Readonly property')
+        }
+        return ColorAwareModel.prototype.set.call(this, key, val, options);
+    }
+
     initialize () {
         this.on('change', item => {
             if (!(item.changed instanceof Object)) {
@@ -29,7 +53,7 @@ export default class XMPPStatus extends Model {
     }
 
     getDisplayName () {
-        return this.getFullname() || this.getNickname() || _converse.session.get('bare_jid');
+        return this.getFullname() || this.getNickname() || this.get('jid');
     }
 
     getNickname () {

+ 2 - 2
src/headless/plugins/vcard/plugin.js

@@ -34,8 +34,8 @@ converse.plugins.add('converse-vcard', {
             getNickname () {
                 const { _converse } = this.__super__;
                 const nick = this.__super__.getNickname.apply(this);
-                if (!nick && _converse.xmppstatus.vcard) {
-                    return _converse.xmppstatus.vcard.get('nickname');
+                if (!nick && _converse.state.xmppstatus.vcard) {
+                    return _converse.state.xmppstatus.vcard.get('nickname');
                 } else {
                     return nick;
                 }

+ 1 - 33
src/headless/plugins/vcard/vcard.js

@@ -1,4 +1,3 @@
-import _converse from '../../shared/_converse.js';
 import { Model } from '@converse/skeletor';
 
 /**
@@ -7,41 +6,10 @@ import { Model } from '@converse/skeletor';
  * @memberOf _converse
  */
 class VCard extends Model {
-    get idAttribute () { // eslint-disable-line class-methods-use-this
+    get idAttribute () {
         return 'jid';
     }
 
-    defaults () { // eslint-disable-line class-methods-use-this
-        return {
-            'image': _converse.DEFAULT_IMAGE,
-            'image_type': _converse.DEFAULT_IMAGE_TYPE
-        }
-    }
-
-  /**
-   * @param {string|Object} key
-   * @param {string|Object} [val]
-   * @param {Record.<string, any>} [options]
-   */
-    set (key, val, options) {
-        // Override Model.prototype.set to make sure that the
-        // default `image` and `image_type` values are maintained.
-        let attrs;
-        if (typeof key === 'object') {
-            attrs = key;
-            options = val;
-        } else {
-            (attrs = {})[key] = val;
-        }
-        if ('image' in attrs && !attrs['image']) {
-            attrs['image'] = _converse.DEFAULT_IMAGE;
-            attrs['image_type'] = _converse.DEFAULT_IMAGE_TYPE;
-            return Model.prototype.set.call(this, attrs, options);
-        } else {
-            return Model.prototype.set.apply(this, arguments);
-        }
-    }
-
     getDisplayName () {
         return this.get('nickname') || this.get('fullname') || this.get('jid');
     }

+ 0 - 5
src/headless/shared/_converse.js

@@ -18,8 +18,6 @@ import {
     ANONYMOUS,
     CLOSED,
     COMPOSING,
-    DEFAULT_IMAGE,
-    DEFAULT_IMAGE_TYPE,
     EXTERNAL,
     FAILURE,
     GONE,
@@ -94,9 +92,6 @@ class ConversePrivateGlobal extends EventEmitter(Object) {
             'initialized': getOpenPromise(),
         };
 
-        this.DEFAULT_IMAGE_TYPE = DEFAULT_IMAGE_TYPE;
-        this.DEFAULT_IMAGE = DEFAULT_IMAGE;
-
         this.NUM_PREKEYS = 100; // DEPRECATED. Set here so that tests can override
 
         // Set as module attr so that we can override in tests.

+ 37 - 0
src/headless/shared/color.js

@@ -0,0 +1,37 @@
+import { Model } from '@converse/skeletor';
+import u from '../utils/index.js';
+
+const { safeSave, colorize } = u;
+
+class ColorAwareModel extends Model {
+
+    async setColor() {
+        const color = await colorize(this.get('jid'));
+        safeSave(this, { color });
+    }
+
+    /**
+     * @returns {Promise<string>}
+     */
+    async getColor() {
+        if (!this.get('color')) {
+            await this.setColor();
+        }
+        return this.get('color');
+    }
+
+    /**
+     * @param {string} append_style
+     * @returns {Promise<string>}
+     */
+    async getAvatarStyle(append_style = '') {
+        try {
+            const color = await this.getColor();
+            return `background-color: ${color} !important; ${append_style}`;
+        } catch {
+            return `background-color: gray !important; ${append_style}`;
+        }
+    }
+}
+
+export { ColorAwareModel };

+ 0 - 5
src/headless/shared/constants.js

@@ -23,11 +23,6 @@ export const PREBIND = 'prebind';
 export const SUCCESS = 'success';
 export const FAILURE = 'failure';
 
-// Generated from css/images/user.svg
-export const DEFAULT_IMAGE_TYPE = 'image/svg+xml';
-export const DEFAULT_IMAGE =
-    'PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==';
-
 // XEP-0085 Chat states
 // https =//xmpp.org/extensions/xep-0085.html
 export const INACTIVE = 'inactive';

+ 5 - 5
src/headless/types/plugins/chat/model-with-contact.d.ts

@@ -1,11 +1,11 @@
 export default ModelWithContact;
-declare class ModelWithContact extends Model {
+declare class ModelWithContact extends ColorAwareModel {
     rosterContactAdded: any;
     /**
      * @public
-     * @type {RosterContact}
+     * @type {RosterContact|XMPPStatus}
      */
-    public contact: import("../roster/contact").default;
+    public contact: import("../status/status.js").default | import("../roster/contact").default;
     /**
      * @public
      * @type {VCard}
@@ -14,7 +14,7 @@ declare class ModelWithContact extends Model {
     /**
      * @param {string} jid
      */
-    setRosterContact(jid: string): Promise<void>;
+    setModelContact(jid: string): Promise<void>;
 }
-import { Model } from "@converse/skeletor";
+import { ColorAwareModel } from "../../shared/color.js";
 //# sourceMappingURL=model-with-contact.d.ts.map

+ 18 - 15
src/headless/types/plugins/chat/model.d.ts

@@ -1,16 +1,16 @@
 export default ChatBox;
-export type Message = import('./message.js').default;
-export type MUC = import('../muc/muc.js').default;
-export type MUCMessage = import('../muc/message.js').default;
-export type MessageAttributes = any;
-export namespace Strophe {
-    type Builder = any;
-}
 /**
  * Represents an open/ongoing chat conversation.
  */
 declare class ChatBox extends ModelWithContact {
     constructor(attrs: any, options: any);
+    /**
+     * @typedef {import('./message.js').default} Message
+     * @typedef {import('../muc/muc.js').default} MUC
+     * @typedef {import('../muc/message.js').default} MUCMessage
+     * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
+     * @typedef {import('strophe.js').Builder} Builder
+     */
     defaults(): {
         bookmarked: boolean;
         hidden: boolean;
@@ -35,7 +35,10 @@ declare class ChatBox extends ModelWithContact {
     getNotificationsText(): any;
     afterMessagesFetched(): void;
     fetchMessages(): any;
-    handleErrorMessageStanza(stanza: any): Promise<void>;
+    /**
+     * @param {Element} stanza
+     */
+    handleErrorMessageStanza(stanza: Element): Promise<void>;
     /**
      * Queue an incoming `chat` message stanza for processing.
      * @async
@@ -49,7 +52,7 @@ declare class ChatBox extends ModelWithContact {
      * @method ChatBox#onMessage
      * @param {Promise<MessageAttributes>} attrs_promise - A promise which resolves to the message attributes.
      */
-    onMessage(attrs_promise: Promise<MessageAttributes>): Promise<void>;
+    onMessage(attrs_promise: Promise<any>): Promise<void>;
     onMessageUploadChanged(message: any): Promise<void>;
     onMessageAdded(message: any): void;
     clearMessages(): Promise<void>;
@@ -126,7 +129,7 @@ declare class ChatBox extends ModelWithContact {
      *  message, as returned by {@link parseMessage}
      * @returns {Message}
      */
-    getDuplicateMessage(attrs: object): Message;
+    getDuplicateMessage(attrs: object): import("./message.js").default;
     getOriginIdQueryAttrs(attrs: any): {
         origin_id: any;
         from: any;
@@ -141,7 +144,7 @@ declare class ChatBox extends ModelWithContact {
      * @method ChatBoxView#retractOwnMessage
      * @param { Message } message - The message which we're retracting.
      */
-    retractOwnMessage(message: Message): void;
+    retractOwnMessage(message: import("./message.js").default): void;
     /**
      * Sends a message stanza to retract a message in this chat
      * @private
@@ -163,7 +166,7 @@ declare class ChatBox extends ModelWithContact {
      * @param { Boolean } force - Whether a marker should be sent for the
      *  message, even if it didn't include a `markable` element.
      */
-    sendMarkerForMessage(msg: Message, type?: ('received' | 'displayed' | 'acknowledged'), force?: boolean): void;
+    sendMarkerForMessage(msg: import("./message.js").default, type?: ('received' | 'displayed' | 'acknowledged'), force?: boolean): void;
     handleChatMarker(attrs: any): boolean;
     sendReceiptStanza(to_jid: any, id: any): void;
     handleReceipt(attrs: any): boolean;
@@ -204,7 +207,7 @@ declare class ChatBox extends ModelWithContact {
      * const chat = api.chats.get('buddy1@example.org');
      * chat.sendMessage({'body': 'hello world'});
      */
-    sendMessage(attrs?: any): Promise<Message>;
+    sendMessage(attrs?: any): Promise<import("./message.js").default>;
     /**
      * Sends a message with the current XEP-0085 chat state of the user
      * as taken from the `chat_state` attribute of the {@link ChatBox}.
@@ -232,11 +235,11 @@ declare class ChatBox extends ModelWithContact {
      * @method ChatBox#handleUnreadMessage
      * @param {Message} message
      */
-    handleUnreadMessage(message: Message): void;
+    handleUnreadMessage(message: import("./message.js").default): void;
     /**
      * @param {Message} message
      */
-    incrementUnreadMsgsCounter(message: Message): void;
+    incrementUnreadMsgsCounter(message: import("./message.js").default): void;
     clearUnreadMsgCounter(): void;
     isScrolledUp(): any;
     canPostMessages(): boolean;

+ 1 - 5
src/headless/types/plugins/muc/muc.d.ts

@@ -105,10 +105,6 @@ declare class MUC extends ChatBox {
     occupants: any;
     fetchOccupants(): any;
     handleAffiliationChangedMessage(stanza: any): void;
-    /**
-     * @param {Element} stanza
-     */
-    handleErrorMessageStanza(stanza: Element): Promise<void>;
     /**
      * Handles incoming message stanzas from the service that hosts this MUC
      * @private
@@ -661,7 +657,7 @@ declare class MUC extends ChatBox {
     isUserMentioned(message: MUCMessage): any;
     incrementUnreadMsgsCounter(message: any): void;
 }
-import ChatBox from "../chat/model";
+import ChatBox from "../chat/model.js";
 declare class MUCSession extends Model {
     defaults(): {
         connection_status: number;

+ 5 - 7
src/headless/types/plugins/muc/occupant.d.ts

@@ -5,7 +5,7 @@ export default MUCOccupant;
  * @namespace _converse.MUCOccupant
  * @memberOf _converse
  */
-declare class MUCOccupant extends Model {
+declare class MUCOccupant extends ColorAwareModel {
     constructor(attributes: any, options: any);
     vcard: any;
     defaults(): {
@@ -21,17 +21,15 @@ declare class MUCOccupant extends Model {
      */
     getAssignableRoles(): typeof ROLES;
     /**
-    * Return affiliations which may be assigned by this occupant
-    * @returns {typeof AFFILIATIONS} An array of assignable affiliations
-    */
+     * Return affiliations which may be assigned by this occupant
+     * @returns {typeof AFFILIATIONS} An array of assignable affiliations
+     */
     getAssignableAffiliations(): typeof AFFILIATIONS;
-    setColor(): Promise<void>;
-    getColor(): Promise<any>;
     isMember(): boolean;
     isModerator(): boolean;
     isSelf(): any;
 }
-import { Model } from "@converse/skeletor";
+import { ColorAwareModel } from "../../shared/color.js";
 import { ROLES } from "./constants.js";
 import { AFFILIATIONS } from "./constants.js";
 //# sourceMappingURL=occupant.d.ts.map

+ 2 - 4
src/headless/types/plugins/roster/contact.d.ts

@@ -1,10 +1,8 @@
 export default RosterContact;
-declare class RosterContact extends Model {
+declare class RosterContact extends ColorAwareModel {
     defaults(): {
         chat_state: any;
         groups: any[];
-        image: string;
-        image_type: string;
         num_unread: number;
         status: any;
     };
@@ -67,5 +65,5 @@ declare class RosterContact extends Model {
      */
     removeFromRoster(): Promise<any>;
 }
-import { Model } from "@converse/skeletor";
+import { ColorAwareModel } from "../../shared/color.js";
 //# sourceMappingURL=contact.d.ts.map

+ 8 - 2
src/headless/types/plugins/status/status.d.ts

@@ -1,9 +1,15 @@
-export default class XMPPStatus extends Model {
+export default class XMPPStatus extends ColorAwareModel {
     constructor(attributes: any, options: any);
     vcard: any;
     defaults(): {
         status: any;
     };
+    /**
+     * @param {string|Object} key
+     * @param {string|Object} [val]
+     * @param {Object} [options]
+     */
+    set(key: string | any, val?: string | any, options?: any): any;
     getDisplayName(): any;
     getNickname(): any;
     getFullname(): string;
@@ -14,5 +20,5 @@ export default class XMPPStatus extends Model {
      */
     constructPresence(type?: string, to?: string, status_message?: string): Promise<any>;
 }
-import { Model } from "@converse/skeletor";
+import { ColorAwareModel } from "../../shared/color.js";
 //# sourceMappingURL=status.d.ts.map

+ 0 - 10
src/headless/types/plugins/vcard/vcard.d.ts

@@ -5,16 +5,6 @@ export default VCard;
  * @memberOf _converse
  */
 declare class VCard extends Model {
-    defaults(): {
-        image: string;
-        image_type: string;
-    };
-    /**
-     * @param {string|Object} key
-     * @param {string|Object} [val]
-     * @param {Record.<string, any>} [options]
-     */
-    set(key: string | any, val?: string | any, options?: Record<string, any>, ...args: any[]): any;
     getDisplayName(): any;
 }
 import { Model } from "@converse/skeletor";

+ 6 - 3
src/headless/types/shared/_converse.d.ts

@@ -6,7 +6,12 @@ export type XMPPStatus = import('../plugins/status/status').default;
 export type VCards = import('../plugins/vcard/vcard').default;
 declare const _converse: ConversePrivateGlobal;
 declare const ConversePrivateGlobal_base: (new (...args: any[]) => {
-    on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context: any): any;
+    on(name: string, callback: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any, context: any): any; /**
+     * A private, closured namespace containing the private api (via {@link _converse.api})
+     * as well as private methods and internal data-structures.
+     * @global
+     * @namespace _converse
+     */
     _events: any;
     _listeners: {};
     listenTo(obj: any, name: string, callback?: (event: any, model: Model, collection: import("@converse/skeletor").Collection, options?: Record<string, any>) => any): any;
@@ -35,8 +40,6 @@ declare class ConversePrivateGlobal extends ConversePrivateGlobal_base {
     promises: {
         initialized: any;
     };
-    DEFAULT_IMAGE_TYPE: string;
-    DEFAULT_IMAGE: string;
     NUM_PREKEYS: number;
     TIMEOUTS: {
         PAUSED: number;

+ 0 - 2
src/headless/types/shared/api/index.d.ts

@@ -9,8 +9,6 @@ export type _converse = {
     promises: {
         initialized: any;
     };
-    DEFAULT_IMAGE_TYPE: string;
-    DEFAULT_IMAGE: string;
     NUM_PREKEYS: number;
     TIMEOUTS: {
         PAUSED: number;

+ 14 - 0
src/headless/types/shared/color.d.ts

@@ -0,0 +1,14 @@
+export class ColorAwareModel extends Model {
+    setColor(): Promise<void>;
+    /**
+     * @returns {Promise<string>}
+     */
+    getColor(): Promise<string>;
+    /**
+     * @param {string} append_style
+     * @returns {Promise<string>}
+     */
+    getAvatarStyle(append_style?: string): Promise<string>;
+}
+import { Model } from "@converse/skeletor";
+//# sourceMappingURL=color.d.ts.map

+ 0 - 2
src/headless/types/shared/constants.d.ts

@@ -18,8 +18,6 @@ export const OPENED: "opened";
 export const PREBIND: "prebind";
 export const SUCCESS: "success";
 export const FAILURE: "failure";
-export const DEFAULT_IMAGE_TYPE: "image/svg+xml";
-export const DEFAULT_IMAGE: "PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==";
 export const INACTIVE: "inactive";
 export const ACTIVE: "active";
 export const COMPOSING: "composing";

+ 1 - 1
src/headless/types/utils/index.d.ts

@@ -53,7 +53,7 @@ declare const _default: {
     getAttributes(stanza: Element): any;
     isUniView(): boolean;
     isTestEnv(): boolean;
-    getUnloadEvent(): "pagehide" | "beforeunload" | "unload";
+    getUnloadEvent(): "pagehide" | "beforeunload" | "unload"; /** @type {keyof LEVELS} */
     replacePromise(_converse: any, name: string): void;
     shouldClearCache(_converse: any): boolean;
     tearDown(_converse: any): Promise<any>;

+ 19 - 25
src/headless/utils/color.js

@@ -1,6 +1,6 @@
 import { Hsluv } from 'hsluv';
 
-const cache = new Map()
+const cache = new Map();
 
 /**
  * Computes an RGB color as specified in XEP-0392
@@ -9,32 +9,26 @@ const cache = new Map()
  * @param {string} s JID or nickname to colorize
  * @returns {Promise<string>}
  */
-export async function colorize (s) {
-  // We cache results in `cache`, to avoid unecessary computing (as it can be called very often)
-  const v = cache.get(s);
-  if (v) return v;
+export async function colorize(s) {
+    // We cache results in `cache`, to avoid unecessary computing (as it can be called very often)
+    const v = cache.get(s);
+    if (v) return v;
 
-  // Run the input through SHA-1
-  const digest = Array.from(
-    new Uint8Array(
-      await crypto.subtle.digest('SHA-1',
-        new TextEncoder().encode(s)
-      )
-    )
-  );
+    // Run the input through SHA-1
+    const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(s))));
 
-  // Treat the output as little endian and extract the least-significant 16 bits.
-  // (These are the first two bytes of the output, with the second byte being the most significant one.)
-  // Divide the value by 65536 (use float division) and multiply it by 360 (to map it to degrees in a full circle).
-  const angle = ((digest[0] + (digest[1] * 256)) / 65536.0) * 360;
+    // Treat the output as little endian and extract the least-significant 16 bits.
+    // (These are the first two bytes of the output, with the second byte being the most significant one.)
+    // Divide the value by 65536 (use float division) and multiply it by 360 (to map it to degrees in a full circle).
+    const angle = ((digest[0] + digest[1] * 256) / 65536.0) * 360;
 
-  // Convert HSLuv angle to RGB Hex notation
-  const hsluv = new Hsluv();
-  hsluv.hsluv_h = angle;
-  hsluv.hsluv_s = 100;
-  hsluv.hsluv_l = 50;
-  hsluv.hsluvToHex();
+    // Convert HSLuv angle to RGB Hex notation
+    const hsluv = new Hsluv();
+    hsluv.hsluv_h = angle;
+    hsluv.hsluv_s = 100;
+    hsluv.hsluv_l = 50;
+    hsluv.hsluvToHex();
 
-  cache.set(s, hsluv.hex);
-  return hsluv.hex;
+    cache.set(s, hsluv.hex);
+    return hsluv.hex;
 }

+ 0 - 1
src/headless/utils/index.js

@@ -32,7 +32,6 @@ const u = {
     mam: null,
 };
 
-
 /**
  * @param {Event} [event]
  */

+ 1 - 1
src/i18n/af/LC_MESSAGES/converse.po

@@ -1855,7 +1855,7 @@ msgstr "Hierdie groepgesprek is openbaar vindbaar"
 #: dist/converse-no-dependencies.js:102294
 #, javascript-format
 msgid "Groupchat info for %1$s"
-msgstr "Kennisgewing van %1$s"
+msgstr "Groepgesprek info oor %1$s"
 
 #. harmony default export
 #: dist/converse-no-dependencies.js:102315

+ 1 - 1
src/plugins/bookmark-views/tests/bookmarks-list.js

@@ -136,7 +136,7 @@ describe("The bookmarks list modal", function () {
         expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(true);
         expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(false);
 
-        controlbox.querySelector('.list-container--openrooms .open-room:first-child').click();
+        controlbox.querySelector('.list-container--openrooms .open-room:nth-child(2)').click();
         await u.waitUntil(() => controlbox.querySelector('.list-item.open').getAttribute('data-room-jid') === 'first@conference.shakespeare.lit');
         expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(false);
         expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(true);

+ 4 - 3
src/plugins/chatview/templates/chat-head.js

@@ -8,13 +8,14 @@ const { HEADLINES_TYPE } = constants;
 
 export default (o) => {
     const i18n_profile = __("The User's Profile Image");
+    const display_name = o.model.getDisplayName();
     const avatar = html`<span title="${i18n_profile}">
         <converse-avatar
+            .model=${o.model.contact || o.model}
             class="avatar chat-msg__avatar"
-            .data=${o.model.vcard?.attributes}
-            nonce=${o.model.vcard?.get('vcard_updated')}
+            name="${display_name}"
+            nonce=${o.model.contact?.vcard?.get('vcard_updated')}
             height="40" width="40"></converse-avatar></span>`;
-    const display_name = o.model.getDisplayName();
 
     return html`
         <div class="chatbox-title ${ o.status ? '' :  "chatbox-title--no-desc"}">

+ 1 - 1
src/plugins/chatview/tests/me-messages.js

@@ -30,7 +30,7 @@ describe("A Message", function () {
         message = '/me is as well';
         await mock.sendMessage(view, message);
         expect(view.querySelectorAll('.chat-msg--action').length).toBe(2);
-        await u.waitUntil(() => sizzle('.chat-msg__author:last', view).pop().textContent.trim() === '**Romeo');
+        await u.waitUntil(() => sizzle('.chat-msg__author:last', view).pop().textContent.trim() === '**Romeo Montague');
         const last_el = sizzle('.chat-msg__text:last', view).pop();
         await u.waitUntil(() => last_el.textContent === 'is as well');
         expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);

+ 93 - 0
src/plugins/chatview/tests/message-avatar.js

@@ -0,0 +1,93 @@
+/*global mock, converse */
+
+const { u, $msg } = converse.env;
+
+describe("A Chat Message", function () {
+
+    fit("has a default avatar with a XEP-0392 color and initials",
+            mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        const { api } = _converse;
+        await mock.waitForRoster(_converse, 'current', 1);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        const textarea = view.querySelector('textarea.chat-textarea');
+        const firstMessageText = 'But soft, what light through yonder airlock breaks?';
+
+        textarea.value = firstMessageText;
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+
+        let el = view.querySelector('converse-chat-message converse-avatar .avatar-initials');
+        expect(el.textContent).toBe('RM');
+        expect(getComputedStyle(el).backgroundColor).toBe('rgb(198, 84, 0)');
+
+        // Test messages from other user
+        const secondMessageText = 'Hello';
+        _converse.handleMessageStanza(
+            $msg({
+                'from': contact_jid,
+                'to': api.connection.get().jid,
+                'type': 'chat',
+                'id': u.getUniqueId()
+            }).c('body').t(secondMessageText).up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+        );
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+        el = view.querySelector(
+            `converse-chat-message div[data-from="${contact_jid}"] converse-avatar .avatar-initials`
+        );
+        expect(el.textContent).toBe('M');
+        expect(getComputedStyle(el).backgroundColor).toBe('rgb(195, 0, 249)');
+
+        // Change contact nickname and see that it reflects
+        const contact = await api.contacts.get(contact_jid);
+        contact.set('nickname', 'Wizzard');
+
+        await u.waitUntil(() => el.textContent === 'W');
+
+        // Change own nickname and see that it reflects
+        const own_jid = _converse.session.get('bare_jid');
+        const { xmppstatus } = _converse.state;
+
+        xmppstatus.vcard.set('fullname', 'Restless Romeo');
+        el = view.querySelector(
+            `converse-chat-message div[data-from="${own_jid}"] converse-avatar .avatar-initials`
+        );
+        await u.waitUntil(() => el.textContent === 'RR');
+        expect(getComputedStyle(el).backgroundColor).toBe('rgb(198, 84, 0)');
+
+        // eslint-disable-next-line max-len
+        const image = 'PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==';
+        const image_type = 'image/svg+xml';
+
+        // Change contact avatar and check that it reflects
+        contact.vcard.set({
+            image,
+            image_type,
+            vcard_updated: (new Date()).toISOString()
+        });
+        el = await u.waitUntil(() => view.querySelector(
+            `converse-chat-message div[data-from="${contact_jid}"] converse-avatar svg image`
+        ));
+        expect(el.getAttribute('href')).toBe(`data:image/svg+xml;base64,${image}`);
+
+        // Change contact avatar and check that it reflects
+        xmppstatus.vcard.set({
+            image,
+            image_type,
+            vcard_updated: (new Date()).toISOString()
+        });
+        el = await u.waitUntil(() => view.querySelector(
+            `converse-chat-message div[data-from="${own_jid}"] converse-avatar svg image`
+        ));
+        expect(el.getAttribute('href')).toBe(`data:image/svg+xml;base64,${image}`);
+    }));
+});

+ 1 - 1
src/plugins/chatview/tests/messages.js

@@ -657,7 +657,7 @@ describe("A Chat Message", function () {
         const msg_object = chatbox.messages.models[0];
 
         const msg_author = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__author');
-        expect(msg_author.textContent.trim()).toBe('Romeo');
+        expect(msg_author.textContent.trim()).toBe('Romeo Montague');
 
         const msg_time = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__time');
         const time = dayjs(msg_object.get('time')).format(api.settings.get('time_format'));

+ 2 - 2
src/plugins/chatview/tests/spoilers.js

@@ -142,7 +142,7 @@ describe("A spoiler message", function () {
         expect(body_el.textContent).toBe(spoiler);
 
         /* Test the HTML spoiler message */
-        expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo');
+        expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
 
         const message_content = view.querySelector('.chat-msg__text');
         await u.waitUntil(() => message_content.textContent === spoiler);
@@ -223,7 +223,7 @@ describe("A spoiler message", function () {
         const body_el = stanza.querySelector('body');
         expect(body_el.textContent).toBe(spoiler);
 
-        expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo');
+        expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
 
         const message_content = view.querySelector('.chat-msg__text');
         await u.waitUntil(() => message_content.textContent === spoiler);

+ 4 - 0
src/plugins/controlbox/styles/_controlbox.scss

@@ -272,9 +272,13 @@
                 padding-right: 1em;
                 align-items: center;
                 line-height: normal;
+
                 .change-status {
                     min-width: 25px;
                     text-align: center;
+                    converse-icon {
+                        padding-right: 0.5em;
+                    }
                 }
             }
 

+ 1 - 1
src/plugins/minimize/tests/minchats.js

@@ -155,7 +155,7 @@ describe("A Chatbox", function () {
 
         for (i=0; i<online_contacts.length; i++) {
             const el = online_contacts[i];
-            const jid = el.textContent.trim().replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            const jid = el.getAttribute('data-jid');
             const model = _converse.chatboxes.get(jid);
             model.set({'minimized': true});
         }

+ 26 - 0
src/plugins/muc-views/heading.js

@@ -12,6 +12,9 @@ import './styles/muc-head.scss';
 
 
 export default class MUCHeading extends CustomElement {
+    /**
+     * @typedef {import('@converse/headless/types/plugins/muc/muc').MUCOccupant} MUCOccupant
+     */
 
     async initialize () {
         const { chatboxes } = _converse.state;
@@ -35,6 +38,9 @@ export default class MUCHeading extends CustomElement {
         return (this.model && this.user_settings) ? tplMUCHead(this) : '';
     }
 
+    /**
+     * @param {MUCOccupant} occupant
+     */
     onOccupantAdded (occupant) {
         const bare_jid = _converse.session.get('bare_jid');
         if (occupant.get('jid') === bare_jid) {
@@ -42,6 +48,9 @@ export default class MUCHeading extends CustomElement {
         }
     }
 
+    /**
+     * @param {MUCOccupant} occupant
+     */
     onOccupantAffiliationChanged (occupant) {
         const bare_jid = _converse.session.get('bare_jid');
         if (occupant.get('jid') === bare_jid) {
@@ -49,16 +58,25 @@ export default class MUCHeading extends CustomElement {
         }
     }
 
+    /**
+     * @param {Event} ev
+     */
     showRoomDetailsModal (ev) {
         ev.preventDefault();
         api.modal.show('converse-muc-details-modal', { 'model': this.model }, ev);
     }
 
+    /**
+     * @param {Event} ev
+     */
     showInviteModal (ev) {
         ev.preventDefault();
         api.modal.show('converse-muc-invite-modal', { 'model': new Model(), 'chatroomview': this }, ev);
     }
 
+    /**
+     * @param {Event} ev
+     */
     toggleTopic (ev) {
         ev?.preventDefault?.();
         this.model.toggleSubjectHiddenState();
@@ -68,11 +86,17 @@ export default class MUCHeading extends CustomElement {
         this.model.session.set('view', converse.MUC.VIEWS.CONFIG);
     }
 
+    /**
+     * @param {Event} ev
+     */
     close (ev) {
         ev.preventDefault();
         this.model.close();
     }
 
+    /**
+     * @param {Event} ev
+     */
     destroy (ev) {
         ev.preventDefault();
         destroyMUC(this.model);
@@ -81,6 +105,8 @@ export default class MUCHeading extends CustomElement {
     /**
      * Returns a list of objects which represent buttons for the groupchat header.
      * @emits _converse#getHeadingButtons
+     *
+     * @param {boolean} subject_hidden
      */
     getHeadingButtons (subject_hidden) {
         const buttons = [];

+ 9 - 9
src/plugins/muc-views/message-form.js

@@ -35,18 +35,18 @@ export default class MUCMessageForm extends MessageForm {
 
     initMentionAutoComplete () {
         this.mention_auto_complete = new AutoComplete(this, {
-            'auto_first': true,
-            'auto_evaluate': false,
-            'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'),
-            'match_current_word': true,
-            'list': () => this.getAutoCompleteList(),
-            'filter':
+            auto_first: true,
+            auto_evaluate: false,
+            min_chars: api.settings.get('muc_mention_autocomplete_min_chars'),
+            match_current_word: true,
+            list: () => this.getAutoCompleteList(),
+            filter:
                 api.settings.get('muc_mention_autocomplete_filter') == 'contains'
                     ? FILTER_CONTAINS
                     : FILTER_STARTSWITH,
-            'ac_triggers': ['Tab', '@'],
-            'include_triggers': [],
-            'item': getAutoCompleteListItem
+            ac_triggers: ['Tab', '@'],
+            include_triggers: [],
+            item: (text, input) => getAutoCompleteListItem(this.model, text, input)
         });
         this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
     }

+ 2 - 2
src/plugins/muc-views/modals/muc-details.js

@@ -20,8 +20,8 @@ export default class MUCDetailsModal extends BaseModal {
         return tplMUCDetails(this.model);
     }
 
-    getModalTitle () { // eslint-disable-line class-methods-use-this
-        return __('Groupchat info for %1$s', this.model.getDisplayName());
+    getModalTitle () {
+        return __('Groupchat info', this.model.getDisplayName());
     }
 
 }

+ 40 - 23
src/plugins/muc-views/modals/templates/muc-details.js

@@ -1,22 +1,29 @@
+/* eslint max-len: 0 */
 import { __ } from 'i18n';
 import { html } from "lit";
 
 
-const subject = (o) => {
+/**
+ * @param {import('@converse/headless').MUC} model
+ */
+const subject = (model) => {
+    const subject = model.get('subject');
     const i18n_topic = __('Topic');
     const i18n_topic_author = __('Topic author');
     return html`
-        <p class="room-info"><strong>${i18n_topic}</strong>: <converse-rich-text text=${o.subject.text} render_styling></converse-rich-text></p>
-        <p class="room-info"><strong>${i18n_topic_author}</strong>: ${o.subject && o.subject.author}</p>
+        <p class="room-info"><strong>${i18n_topic}</strong>: <converse-rich-text text=${subject.text} render_styling></converse-rich-text></p>
+        <p class="room-info"><strong>${i18n_topic_author}</strong>: ${subject && subject.author}</p>
     `;
 }
 
 
+/**
+ * @param {import('@converse/headless').MUC} model
+ */
 export default (model) => {
-    const o = model.toJSON();
     const config = model.config.toJSON();
     const features = model.features.toJSON();
-    const num_occupants = model.occupants.filter(o => o.get('show') !== 'offline').length;
+    const num_occupants = model.occupants.filter((o) => o.get('show') !== 'offline').length;
 
     const i18n_address =  __('XMPP address');
     const i18n_archiving = __('Message archiving');
@@ -44,35 +51,45 @@ export default (model) => {
     const i18n_persistent = __('Persistent');
     const i18n_persistent_help = __('This groupchat persists even if it\'s unoccupied');
     const i18n_public = __('Public');
+    const i18n_public_help = __('This groupchat is publicly searchable');
     const i18n_semi_anon = __('Semi-anonymous');
     const i18n_semi_anon_help = __('Only moderators can see your XMPP address');
     const i18n_temporary = __('Temporary');
     const i18n_temporary_help = __('This groupchat will disappear once the last person leaves');
     return html`
         <div class="room-info">
-            <p class="room-info"><strong>${i18n_name}</strong>: ${o.name}</p>
-            <p class="room-info"><strong>${i18n_address}</strong>: <converse-rich-text text="xmpp:${o.jid}?join"></converse-rich-text></p>
+            <converse-avatar
+                .model=${model}
+                class="avatar align-self-center"
+                name="${model.getDisplayName()}"
+                nonce=${model.vcard?.get('vcard_updated')}
+                height="72" width="72"></converse-avatar>
+
+            <p class="room-info"><strong>${i18n_name}</strong>: ${model.get('name')}</p>
+            <p class="room-info"><strong>${i18n_address}</strong>: <converse-rich-text text="xmpp:${model.get('jid')}?join"></converse-rich-text></p>
+            <br/>
             <p class="room-info"><strong>${i18n_desc}</strong>: <converse-rich-text text="${config.description}" render_styling></converse-rich-text></p>
-            ${ (o.subject) ? subject(o) : '' }
+            ${ (model.get('subject')) ? subject(model) : '' }
             <p class="room-info"><strong>${i18n_online_users}</strong>: ${num_occupants}</p>
             <p class="room-info"><strong>${i18n_features}</strong>:
                 <div class="chatroom-features">
-                <ul class="features-list">
-                    ${ features.passwordprotected ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-lock"></converse-icon>${i18n_password_protected} - <em>${i18n_password_help}</em></li>` : '' }
-                    ${ features.unsecured ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-unlock"></converse-icon>${i18n_no_password_required} - <em>${i18n_no_pass_help}</em></li>` : '' }
-                    ${ features.hidden ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye-slash"></converse-icon>${i18n_hidden} - <em>${i18n_hidden_help}</em></li>` : '' }
-                    ${ features.public_room ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye"></converse-icon>${i18n_public} - <em>${o.__('This groupchat is publicly searchable') }</em></li>` : '' }
-                    ${ features.membersonly ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-address-book"></converse-icon>${i18n_members_only} - <em>${i18n_members_help}</em></li>` : '' }
-                    ${ features.open ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-globe"></converse-icon>${i18n_open} - <em>${i18n_open_help}</em></li>` : '' }
-                    ${ features.persistent ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-save"></converse-icon>${i18n_persistent} - <em>${i18n_persistent_help}</em></li>` : '' }
-                    ${ features.temporary ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-snowflake"></converse-icon>${i18n_temporary} - <em>${i18n_temporary_help}</em></li>` : '' }
-                    ${ features.nonanonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-id-card"></converse-icon>${i18n_not_anonymous} - <em>${i18n_not_anonymous_help}</em></li>` : '' }
-                    ${ features.semianonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-user-secret"></converse-icon>${i18n_semi_anon} - <em>${i18n_semi_anon_help}</em></li>` : '' }
-                    ${ features.moderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-gavel"></converse-icon>${i18n_moderated} - <em>${i18n_moderated_help}</em></li>` : '' }
-                    ${ features.unmoderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-info-circle"></converse-icon>${i18n_not_moderated} - <em>${i18n_not_moderated_help}</em></li>` : '' }
-                    ${ features.mam_enabled ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-database"></converse-icon>${i18n_archiving} - <em>${i18n_archiving_help}</em></li>` : '' }
-                </ul>
+                    <ul class="features-list">
+                    ${ features.passwordprotected ? html`<li class="feature"><converse-icon size="1em" class="fa fa-lock"></converse-icon>${i18n_password_protected} - <em>${i18n_password_help}</em></li>` : '' }
+                    ${ features.unsecured ? html`<li class="feature"><converse-icon size="1em" class="fa fa-unlock"></converse-icon>${i18n_no_password_required} - <em>${i18n_no_pass_help}</em></li>` : '' }
+                    ${ features.hidden ? html`<li class="feature"><converse-icon size="1em" class="fa fa-eye-slash"></converse-icon>${i18n_hidden} - <em>${i18n_hidden_help}</em></li>` : '' }
+                    ${ features.public_room ? html`<li class="feature"><converse-icon size="1em" class="fa fa-eye"></converse-icon>${i18n_public} - <em>${i18n_public_help}</em></li>` : '' }
+                    ${ features.membersonly ? html`<li class="feature"><converse-icon size="1em" class="fa fa-address-book"></converse-icon>${i18n_members_only} - <em>${i18n_members_help}</em></li>` : '' }
+                    ${ features.open ? html`<li class="feature"><converse-icon size="1em" class="fa fa-globe"></converse-icon>${i18n_open} - <em>${i18n_open_help}</em></li>` : '' }
+                    ${ features.persistent ? html`<li class="feature"><converse-icon size="1em" class="fa fa-save"></converse-icon>${i18n_persistent} - <em>${i18n_persistent_help}</em></li>` : '' }
+                    ${ features.temporary ? html`<li class="feature"><converse-icon size="1em" class="fa fa-snowflake"></converse-icon>${i18n_temporary} - <em>${i18n_temporary_help}</em></li>` : '' }
+                    ${ features.nonanonymous ? html`<li class="feature"><converse-icon size="1em" class="fa fa-id-card"></converse-icon>${i18n_not_anonymous} - <em>${i18n_not_anonymous_help}</em></li>` : '' }
+                    ${ features.semianonymous ? html`<li class="feature"><converse-icon size="1em" class="fa fa-user-secret"></converse-icon>${i18n_semi_anon} - <em>${i18n_semi_anon_help}</em></li>` : '' }
+                    ${ features.moderated ? html`<li class="feature"><converse-icon size="1em" class="fa fa-gavel"></converse-icon>${i18n_moderated} - <em>${i18n_moderated_help}</em></li>` : '' }
+                    ${ features.unmoderated ? html`<li class="feature"><converse-icon size="1em" class="fa fa-info-circle"></converse-icon>${i18n_not_moderated} - <em>${i18n_not_moderated_help}</em></li>` : '' }
+                    ${ features.mam_enabled ? html`<li class="feature"><converse-icon size="1em" class="fa fa-database"></converse-icon>${i18n_archiving} - <em>${i18n_archiving_help}</em></li>` : '' }
+                    </ul>
                 </div>
             </p>
+        </div>
     `;
 }

+ 2 - 1
src/plugins/muc-views/modals/templates/occupant.js

@@ -34,8 +34,9 @@ export default (el) => {
         <div class="row">
             <div class="col-auto">
                 <converse-avatar
+                    .model=${el.model}
                     class="avatar modal-avatar"
-                    .data=${vcard?.attributes}
+                    name="${el.model.getDisplayName()}"
                     nonce=${vcard?.get('vcard_updated')}
                     height="120" width="120"></converse-avatar>
             </div>

+ 5 - 1
src/plugins/muc-views/styles/controlbox.scss

@@ -14,7 +14,7 @@
             }
         }
 
-        .open-rooms-toggle, .open-rooms-toggle .fa {
+        .open-rooms-toggle, .open-rooms-toggle converse-icon {
             color: var(--groupchats-header-color) !important;
             &:hover {
                 color: var(--chatroom-head-bg-color-dark) !important;
@@ -23,6 +23,10 @@
 
         .open-rooms-toggle {
             white-space: nowrap;
+            converse-icon {
+                margin-left: 0.25em;
+                padding-bottom: 0.1em;
+            }
         }
     }
 }

+ 6 - 0
src/plugins/muc-views/styles/muc-details-modal.scss

@@ -4,6 +4,12 @@ converse-muc-details-modal {
     }
 
     .room-info {
+        converse-avatar {
+            display: block;
+            margin-bottom: 0.5em;
+            margin-right: 1em;
+            float: left;
+        }
         strong {
             color: var(--muc-color);
         }

+ 5 - 0
src/plugins/muc-views/styles/muc-occupants.scss

@@ -4,6 +4,11 @@
         .chat-status--avatar {
             background: var(--occupants-background-color);
             border: 1px solid var(--occupants-background-color);
+            font-size: 0.6rem;
+            margin-top: 0;
+            margin-left: -0.3em;
+            margin-bottom: -2em;
+            padding: 0.1em;
         }
 
         .badge-groupchat {

+ 23 - 16
src/plugins/muc-views/templates/muc-head.js

@@ -6,7 +6,7 @@ import { getStandaloneButtons, getDropdownButtons } from 'shared/chat/utils.js';
 import { html } from "lit";
 import { until } from 'lit/directives/until.js';
 
-
+/** @param {import('../heading').default} el} */
 export default (el) => {
     const o = el.model.toJSON();
     const subject_hidden = el.user_settings?.get('mucs_with_hidden_subject', [])?.includes(el.model.get('jid'));
@@ -15,27 +15,34 @@ export default (el) => {
     const i18n_bookmarked = __('This groupchat is bookmarked');
     const subject = o.subject ? o.subject.text : '';
     const show_subject = (subject && !subject_hidden);
-    const muc_vcard = el.model.vcard?.get('image');
     return html`
         <div class="chatbox-title ${ show_subject ? '' :  "chatbox-title--no-desc"}">
 
-            ${  muc_vcard && muc_vcard !== _converse.DEFAULT_IMAGE ? html`
-                <converse-avatar class="avatar align-self-center"
-                    .data=${el.model.vcard?.attributes}
+            <a data-room-jid="${el.model.get('jid')}"
+               title="${__('Show more information on this groupchat')}"
+               @click=${(ev) => el.showRoomDetailsModal(ev)}>
+
+                <converse-avatar
+                    .model=${el.model}
+                    class="avatar align-self-center"
+                    name="${el.model.getDisplayName()}"
                     nonce=${el.model.vcard?.get('vcard_updated')}
-                    height="40" width="40"></converse-avatar>` : ''
-            }
+                    height="40" width="40"></converse-avatar>
+            </a>
 
             <div class="chatbox-title--row">
-                ${ (!_converse.api.settings.get("singleton")) ?  html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
-                <div class="chatbox-title__text" title="${ (api.settings.get('locked_muc_domain') !== 'hidden') ? o.jid : '' }">${ el.model.getDisplayName() }
-                ${ (o.bookmarked) ?
-                    html`<converse-icon
-                            class="fa fa-bookmark chatbox-title__text--bookmarked"
-                            size="1em"
-                            color="var(--chatroom-head-color)"
-                            title="${i18n_bookmarked}">
-                        </converse-icon>` : '' }
+                ${ (!_converse.api.settings.get("singleton")) ?
+                        html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
+                <div class="chatbox-title__text"
+                     title="${ (api.settings.get('locked_muc_domain') !== 'hidden') ? o.jid : '' }">
+                    ${ el.model.getDisplayName() }
+                    ${ (o.bookmarked) ?
+                        html`<converse-icon
+                                class="fa fa-bookmark chatbox-title__text--bookmarked"
+                                size="1em"
+                                color="var(--chatroom-head-color)"
+                                title="${i18n_bookmarked}">
+                            </converse-icon>` : '' }
                 </div>
             </div>
             <div class="chatbox-title__buttons row no-gutters">

+ 16 - 4
src/plugins/muc-views/templates/occupant.js

@@ -1,12 +1,17 @@
+/**
+ * @typedef {import('@converse/headless').MUCOccupant} MUCOccupant
+ */
 import { PRETTY_CHAT_STATUS } from '../constants.js';
 import { __ } from 'i18n';
 import { html } from "lit";
 import { showOccupantModal } from '../utils.js';
 import { getAuthorStyle } from 'utils/color.js';
 
-const i18n_occupant_hint = (o) => __('Click to mention %1$s in your message.', o.get('nick'))
+const i18n_occupant_hint = /** @param {MUCOccupant} o */(o) => {
+    return __('Click to mention %1$s in your message.', o.get('nick'));
+}
 
-const occupant_title = (o) => {
+const occupant_title = /** @param {MUCOccupant} o */(o) => {
     const role = o.get('role');
     const hint_occupant = i18n_occupant_hint(o);
     const i18n_moderator_hint = __('This user is a moderator.');
@@ -25,6 +30,10 @@ const occupant_title = (o) => {
 }
 
 
+/**
+ * @param {MUCOccupant} o
+ * @param {Object} chat
+ */
 export default (o, chat) => {
     const affiliation = o.get('affiliation');
     const hint_show = PRETTY_CHAT_STATUS[o.get('show')];
@@ -53,8 +62,9 @@ export default (o, chat) => {
                 <div class="col-auto">
                     <a class="show-msg-author-modal" @click=${(ev) => showOccupantModal(ev, o)}>
                         <converse-avatar
+                            .model=${o}
                             class="avatar chat-msg__avatar"
-                            .data=${o.vcard?.attributes}
+                            name="${o.getDisplayName()}"
                             nonce=${o.vcard?.get('vcard_updated')}
                             height="30" width="30"></converse-avatar>
                         <converse-icon
@@ -66,7 +76,9 @@ export default (o, chat) => {
                     </a>
                 </div>
                 <div class="col occupant-nick-badge">
-                    <span class="occupant-nick" @click=${chat.onOccupantClicked} style="${getAuthorStyle(o)}">${o.getDisplayName()}</span>
+                    <span class="occupant-nick"
+                          @click=${chat.onOccupantClicked}
+                          style="${getAuthorStyle(o)}">${o.getDisplayName()}</span>
                     <span class="occupant-badges">
                         ${ (affiliation === "owner") ? html`<span class="badge badge-groupchat">${i18n_owner}</span>` : '' }
                         ${ (affiliation === "admin") ? html`<span class="badge badge-info">${i18n_admin}</span>` : '' }

+ 73 - 21
src/plugins/muc-views/tests/autocomplete.js

@@ -54,10 +54,21 @@ describe("The nickname autocomplete feature", function () {
         message_form.onKeyUp(at_event);
 
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
-        expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
+        const first_child = view.querySelector('.suggestion-box__results li:first-child converse-avatar');
+        expect(first_child.textContent).toBe('D');
+        expect(first_child.nextSibling.textContent).toBe('dick');
+
+        const second_child = view.querySelector('.suggestion-box__results li:nth-child(2) converse-avatar');
+        expect(second_child.textContent).toBe('H');
+        expect(second_child.nextSibling.textContent).toBe('harry');
+
+        const third_child = view.querySelector('.suggestion-box__results li:nth-child(3) converse-avatar');
+        expect(third_child.textContent).toBe('J');
+        expect(third_child.nextSibling.textContent).toBe('jane');
+
+        const fourth_child = view.querySelector('.suggestion-box__results li:nth-child(4) converse-avatar');
+        expect(fourth_child.textContent).toBe('T');
+        expect(fourth_child.nextSibling.textContent).toBe('tom');
     }));
 
     it("shows all autocompletion options when the user presses @ right after a new line",
@@ -108,10 +119,21 @@ describe("The nickname autocomplete feature", function () {
         message_form.onKeyUp(at_event);
 
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
-        expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
+        const first_child = view.querySelector('.suggestion-box__results li:first-child converse-avatar');
+        expect(first_child.textContent).toBe('D');
+        expect(first_child.nextSibling.textContent).toBe('dick');
+
+        const second_child = view.querySelector('.suggestion-box__results li:nth-child(2) converse-avatar');
+        expect(second_child.textContent).toBe('H');
+        expect(second_child.nextSibling.textContent).toBe('harry');
+
+        const third_child = view.querySelector('.suggestion-box__results li:nth-child(3) converse-avatar');
+        expect(third_child.textContent).toBe('J');
+        expect(third_child.nextSibling.textContent).toBe('jane');
+
+        const fourth_child = view.querySelector('.suggestion-box__results li:nth-child(4) converse-avatar');
+        expect(fourth_child.textContent).toBe('T');
+        expect(fourth_child.nextSibling.textContent).toBe('tom');
     }));
 
     it("shows all autocompletion options when the user presses @ right after an allowed character",
@@ -163,10 +185,21 @@ describe("The nickname autocomplete feature", function () {
         message_form.onKeyUp(at_event);
 
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
-        expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
-        expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
+        const first_child = view.querySelector('.suggestion-box__results li:first-child converse-avatar');
+        expect(first_child.textContent).toBe('D');
+        expect(first_child.nextSibling.textContent).toBe('dick');
+
+        const second_child = view.querySelector('.suggestion-box__results li:nth-child(2) converse-avatar');
+        expect(second_child.textContent).toBe('H');
+        expect(second_child.nextSibling.textContent).toBe('harry');
+
+        const third_child = view.querySelector('.suggestion-box__results li:nth-child(3) converse-avatar');
+        expect(third_child.textContent).toBe('J');
+        expect(third_child.nextSibling.textContent).toBe('jane');
+
+        const fourth_child = view.querySelector('.suggestion-box__results li:nth-child(4) converse-avatar');
+        expect(fourth_child.textContent).toBe('T');
+        expect(fourth_child.nextSibling.textContent).toBe('tom');
     }));
 
     it("should order by query index position and length", mock.initConverse(
@@ -204,16 +237,31 @@ describe("The nickname autocomplete feature", function () {
             textarea.value = '@ber';
             message_form.onKeyUp(at_event);
             await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3);
-            expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('bernard');
-            expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('naber');
-            expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('helberlo');
+
+            const first_child = view.querySelector('.suggestion-box__results li:first-child converse-avatar');
+            expect(first_child.textContent).toBe('B');
+            expect(first_child.nextElementSibling.textContent).toBe('ber');
+            expect(first_child.nextElementSibling.nextSibling.textContent).toBe('nard');
+
+            const second_child = view.querySelector('.suggestion-box__results li:nth-child(2) converse-avatar');
+            expect(second_child.textContent).toBe('N');
+            expect(second_child.nextSibling.textContent).toBe('na');
+            expect(second_child.nextElementSibling.textContent).toBe('ber');
+
+            const third_child = view.querySelector('.suggestion-box__results li:nth-child(3) converse-avatar');
+            expect(third_child.textContent).toBe('H');
+            expect(third_child.nextSibling.textContent).toBe('hel');
+            expect(third_child.nextSibling.nextSibling.textContent).toBe('ber');
+            expect(third_child.nextSibling.nextSibling.nextSibling.textContent).toBe('lo');
 
             // Test that when the query index is equal, results should be sorted by length
             textarea.value = '@jo';
             message_form.onKeyUp(at_event);
             await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2);
-            expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('john');
-            expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('jones');
+
+            // First char is the avatar initial
+            expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('Jjohn');
+            expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('Jjones');
     }));
 
     it("autocompletes when the user presses tab",
@@ -251,7 +299,9 @@ describe("The nickname autocomplete feature", function () {
         message_form.onKeyUp(tab_event);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
-        expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
+
+        // First char is the avatar initial
+        expect(view.querySelector('.suggestion-box__results li').textContent).toBe('Ssome1');
 
         const backspace_event = {
             'target': textarea,
@@ -293,8 +343,9 @@ describe("The nickname autocomplete feature", function () {
         message_form.onKeyDown(up_arrow_event);
         message_form.onKeyUp(up_arrow_event);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
-        expect(view.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1');
-        expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
+        // First char is the avatar initial
+        expect(view.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('Ssome1');
+        expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('Ssome2');
 
         message_form.onKeyDown({
             'target': textarea,
@@ -362,6 +413,7 @@ describe("The nickname autocomplete feature", function () {
         message_form.onKeyUp(backspace_event);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
-        expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
+        // First char is the avatar initial
+        expect(view.querySelector('.suggestion-box__results li').textContent).toBe('Ssome1');
     }));
 });

+ 202 - 0
src/plugins/muc-views/tests/muc-avatar.js

@@ -0,0 +1,202 @@
+/*global mock, converse */
+
+const { u } = converse.env;
+
+describe('Groupchats', () => {
+    describe('A Groupchat', () => {
+        it(
+            'has an avatar image',
+            mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: '' } }, async function (_converse) {
+                const muc_jid = 'lounge@montague.lit';
+                await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+
+                const view = _converse.chatboxviews.get(muc_jid);
+                const avatar = view.querySelector('.chat-head converse-avatar .avatar-initials');
+                expect(avatar.textContent).toBe('L');
+                expect(getComputedStyle(avatar).backgroundColor).toBe('rgb(0, 135, 113)');
+
+                // eslint-disable-next-line max-len
+                const image =
+                    'PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==';
+                const image_type = 'image/svg+xml';
+
+                view.model.vcard.set({
+                    image,
+                    image_type,
+                    vcard_updated: new Date().toISOString(),
+                });
+                const el = await u.waitUntil(() => view.querySelector(`.chat-head converse-avatar svg image`));
+                expect(el.getAttribute('href')).toBe(`data:image/svg+xml;base64,${image}`);
+            })
+        );
+
+        it(
+            'has an avatar which opens a details modal when clicked',
+            mock.initConverse(
+                ['chatBoxesFetched'],
+                {
+                    whitelisted_plugins: ['converse-roomslist'],
+                    allow_bookmarks: false, // Makes testing easier, otherwise we
+                    // have to mock stanza traffic.
+                },
+                async function (_converse) {
+                    const { Strophe, $iq, $pres, u } = converse.env;
+                    const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+                    const muc_jid = 'coven@chat.shakespeare.lit';
+                    await mock.waitForRoster(_converse, 'current', 0);
+                    await mock.openControlBox(_converse);
+                    await _converse.api.rooms.open(muc_jid, { 'nick': 'some1' });
+
+                    const selector = `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
+                    const features_query = await u.waitUntil(() =>
+                        IQ_stanzas.filter((iq) => iq.querySelector(selector)).pop()
+                    );
+                    const features_stanza = $iq({
+                        'from': 'coven@chat.shakespeare.lit',
+                        'id': features_query.getAttribute('id'),
+                        'to': 'romeo@montague.lit/desktop',
+                        'type': 'result',
+                    })
+                        .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info' })
+                        .c('identity', {
+                            'category': 'conference',
+                            'name': 'A Dark Cave',
+                            'type': 'text',
+                        })
+                        .up()
+                        .c('feature', { 'var': 'http://jabber.org/protocol/muc' })
+                        .up()
+                        .c('feature', { 'var': 'muc_passwordprotected' })
+                        .up()
+                        .c('feature', { 'var': 'muc_hidden' })
+                        .up()
+                        .c('feature', { 'var': 'muc_temporary' })
+                        .up()
+                        .c('feature', { 'var': 'muc_open' })
+                        .up()
+                        .c('feature', { 'var': 'muc_unmoderated' })
+                        .up()
+                        .c('feature', { 'var': 'muc_nonanonymous' })
+                        .up()
+                        .c('feature', { 'var': 'urn:xmpp:mam:0' })
+                        .up()
+                        .c('x', { 'xmlns': 'jabber:x:data', 'type': 'result' })
+                        .c('field', { 'var': 'FORM_TYPE', 'type': 'hidden' })
+                        .c('value')
+                        .t('http://jabber.org/protocol/muc#roominfo')
+                        .up()
+                        .up()
+                        .c('field', {
+                            'type': 'text-single',
+                            'var': 'muc#roominfo_description',
+                            'label': 'Description',
+                        })
+                        .c('value')
+                        .t('This is the description')
+                        .up()
+                        .up()
+                        .c('field', {
+                            'type': 'text-single',
+                            'var': 'muc#roominfo_occupants',
+                            'label': 'Number of occupants',
+                        })
+                        .c('value')
+                        .t(0);
+                    _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
+
+                    const view = _converse.chatboxviews.get(muc_jid);
+                    await u.waitUntil(
+                        () => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING
+                    );
+                    let presence = $pres({
+                        to: _converse.api.connection.get().jid,
+                        from: 'coven@chat.shakespeare.lit/some1',
+                        id: 'DC352437-C019-40EC-B590-AF29E879AF97',
+                    })
+                        .c('x')
+                        .attrs({ xmlns: 'http://jabber.org/protocol/muc#user' })
+                        .c('item')
+                        .attrs({
+                            affiliation: 'member',
+                            jid: _converse.bare_jid,
+                            role: 'participant',
+                        })
+                        .up()
+                        .c('status')
+                        .attrs({ code: '110' });
+                    _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+
+                    const avatar_el = await u.waitUntil(
+                        () => view.querySelector('converse-muc-heading converse-avatar')
+                    );
+
+                    const initials_el = avatar_el.querySelector('.avatar-initials');
+                    expect(initials_el.textContent).toBe('AC');
+                    expect(getComputedStyle(initials_el).backgroundColor).toBe('rgb(75, 103, 255)');
+                    avatar_el.click();
+
+                    const modal = _converse.api.modal.get('converse-muc-details-modal');
+                    await u.waitUntil(() => u.isVisible(modal), 1000);
+
+                    const modal_avatar_el = modal.querySelector('converse-avatar');
+                    const modal_initials_el = modal_avatar_el.querySelector('.avatar-initials');
+                    expect(modal_initials_el.textContent).toBe('AC');
+                    expect(getComputedStyle(modal_initials_el).backgroundColor).toBe('rgb(75, 103, 255)');
+
+                    let els = modal.querySelectorAll('p.room-info');
+                    expect(els[0].textContent).toBe('Name: A Dark Cave');
+
+                    expect(els[1].querySelector('strong').textContent).toBe('XMPP address');
+                    expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe(
+                        'xmpp:coven@chat.shakespeare.lit?join'
+                    );
+                    expect(els[2].querySelector('strong').textContent).toBe('Description');
+                    expect(els[2].querySelector('converse-rich-text').textContent).toBe('This is the description');
+
+                    expect(els[3].textContent).toBe('Online users: 1');
+                    const features_list = modal.querySelector('.features-list');
+                    expect(features_list.textContent.replace(/(\n|\s{2,})/g, '')).toBe(
+                        'Password protected - This groupchat requires a password before entry' +
+                        'Hidden - This groupchat is not publicly searchable' +
+                        'Open - Anyone can join this groupchat' +
+                        'Temporary - This groupchat will disappear once the last person leaves ' +
+                        'Not anonymous - All other groupchat participants can see your XMPP address' +
+                        'Not moderated - Participants entering this groupchat can write right away '
+                    );
+                    presence = $pres({
+                        to: 'romeo@montague.lit/_converse.js-29092160',
+                        from: 'coven@chat.shakespeare.lit/newguy',
+                    })
+                        .c('x', { xmlns: Strophe.NS.MUC_USER })
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': 'newguy@montague.lit/_converse.js-290929789',
+                            'role': 'participant',
+                        });
+                    _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+
+                    els = modal.querySelectorAll('p.room-info');
+                    expect(els[3].textContent).toBe('Online users: 2');
+
+                    view.model.set({ 'subject': { 'author': 'someone', 'text': 'Hatching dark plots' } });
+                    els = modal.querySelectorAll('p.room-info');
+                    expect(els[0].textContent).toBe('Name: A Dark Cave');
+
+                    expect(els[1].querySelector('strong').textContent).toBe('XMPP address');
+                    expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe(
+                        'xmpp:coven@chat.shakespeare.lit?join'
+                    );
+                    expect(els[2].querySelector('strong').textContent).toBe('Description');
+                    expect(els[2].querySelector('converse-rich-text').textContent).toBe('This is the description');
+                    expect(els[3].querySelector('strong').textContent).toBe('Topic');
+                    await u.waitUntil(
+                        () => els[3].querySelector('converse-rich-text').textContent === 'Hatching dark plots'
+                    );
+
+                    expect(els[4].textContent).toBe('Topic author: someone');
+                    expect(els[5].textContent).toBe('Online users: 2');
+                }
+            )
+        );
+    });
+});

+ 1 - 1
src/plugins/muc-views/tests/muc.js

@@ -26,7 +26,7 @@ describe("Groupchats", function () {
                     .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
             _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
 
-            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            const view = _converse.chatboxviews.get(muc_jid);
             spyOn(view.model, 'join').and.callThrough();
             await mock.waitForReservedNick(_converse, muc_jid, '');
             const input = await u.waitUntil(() => view.querySelector('input[name="nick"]'), 1000);

+ 53 - 19
src/plugins/muc-views/utils.js

@@ -1,5 +1,10 @@
+/**
+ * @typedef {import('@converse/headless/types/plugins/muc/muc.js').default} MUC
+ * @typedef {import("shared/avatar/avatar").default} Avatar
+ * @typedef {import("shared/autocomplete/suggestion").default} Suggestion
+ */
 import { html } from "lit";
-import { _converse, api, converse, log, constants } from "@converse/headless";
+import { api, converse, log, constants } from "@converse/headless";
 import './modals/occupant.js';
 import './modals/moderator-tools.js';
 import tplSpinner from 'templates/spinner.js';
@@ -43,12 +48,18 @@ export function confirmDirectMUCInvitation ({ contact, jid, reason }) {
     }
 }
 
+/**
+ * @param {string} jid
+ */
 export function clearHistory (jid) {
     if (location.hash === `converse/room?jid=${jid}`) {
         history.pushState(null, '', window.location.pathname);
     }
 }
 
+/**
+ * @param {MUC} model
+ */
 export async function destroyMUC (model) {
     const messages = [__('Are you sure you want to destroy this groupchat?')];
     let fields = [
@@ -80,6 +91,9 @@ export async function destroyMUC (model) {
     }
 }
 
+/**
+ * @param {MUC} model
+ */
 export function getNicknameRequiredTemplate (model) {
     const jid = model.get('jid');
     if (api.settings.get('muc_show_logs_before_join')) {
@@ -110,25 +124,35 @@ export function getChatRoomBodyTemplate (o) {
     }
 }
 
-export function getAutoCompleteListItem (text, input) {
+/**
+ * @param {MUC} muc
+ * @param {Suggestion} text
+ * @param {string} input
+ * @returns {HTMLLIElement}
+ */
+export function getAutoCompleteListItem (muc, text, input) {
     input = input.trim();
-    const element = document.createElement('li');
-    element.setAttribute('aria-selected', 'false');
+    const li = document.createElement('li');
+    li.setAttribute('aria-selected', 'false');
 
     if (api.settings.get('muc_mention_autocomplete_show_avatar')) {
-        const img = document.createElement('img');
-        let dataUri = 'data:' + _converse.DEFAULT_IMAGE_TYPE + ';base64,' + _converse.DEFAULT_IMAGE;
-
-        const { vcards } = _converse.state;
-        if (vcards) {
-            const vcard = vcards.findWhere({ 'nickname': text });
-            if (vcard) dataUri = 'data:' + vcard.get('image_type') + ';base64,' + vcard.get('image');
-        }
+        const t = text.label.toLowerCase();
+        const avatar_el = /** @type {Avatar} */(document.createElement('converse-avatar'));
 
-        img.setAttribute('src', dataUri);
-        img.setAttribute('width', '22');
-        img.setAttribute('class', 'avatar avatar-autocomplete');
-        element.appendChild(img);
+        avatar_el.model = muc.occupants.findWhere((o) => {
+            if (o.getDisplayName()?.toLowerCase()?.startsWith(t)) {
+                return o;
+            } else if (o.get('nickname')?.toLowerCase()?.startsWith(t)) {
+                return o;
+            } else if (o.get('jid')?.toLowerCase()?.startsWith(t)) {
+                return o;
+            }
+        });
+        avatar_el.setAttribute('name', avatar_el.model.getDisplayName());
+        avatar_el.setAttribute('height', '22');
+        avatar_el.setAttribute('width', '22');
+        avatar_el.setAttribute('class', 'avatar avatar-autocomplete');
+        li.appendChild(avatar_el);
     }
 
     const regex = new RegExp('(' + input + ')', 'ig');
@@ -138,13 +162,13 @@ export function getAutoCompleteListItem (text, input) {
         if (input && txt.match(regex)) {
             const match = document.createElement('mark');
             match.textContent = txt;
-            element.appendChild(match);
+            li.appendChild(match);
         } else {
-            element.appendChild(document.createTextNode(txt));
+            li.appendChild(document.createTextNode(txt));
         }
     });
 
-    return element;
+    return li;
 }
 
 export async function getAutoCompleteList () {
@@ -153,6 +177,9 @@ export async function getAutoCompleteList () {
     return jids;
 }
 
+/**
+ * @param {MUC} muc
+ */
 function setRole (muc, command, args, required_affiliations = [], required_roles = []) {
     const role = COMMAND_TO_ROLE[command];
     if (!role) {
@@ -176,6 +203,9 @@ function setRole (muc, command, args, required_affiliations = [], required_roles
 }
 
 
+/**
+ * @param {MUC} muc
+ */
 function verifyAndSetAffiliation (muc, command, args, required_affiliations) {
     const affiliation = COMMAND_TO_AFFILIATION[command];
     if (!affiliation) {
@@ -219,6 +249,10 @@ function verifyAndSetAffiliation (muc, command, args, required_affiliations) {
 }
 
 
+/**
+ * @param {MUC} muc
+ * @param {string} [affiliation]
+ */
 export function showModeratorToolsModal (muc, affiliation) {
     if (!muc.verifyRoles(['moderator'])) {
         return;

+ 1 - 1
src/plugins/profile/modals/profile.js

@@ -2,9 +2,9 @@
 /**
  * @typedef {import("@converse/headless").XMPPStatus} XMPPStatus
  */
+import Compress from 'client-compress';
 import BaseModal from "plugins/modal/modal.js";
 import tplProfileModal from "../templates/profile_modal.js";
-import Compress from 'client-compress';
 import { __ } from 'i18n';
 import { _converse, api, log } from "@converse/headless";
 import '../password-reset.js';

+ 13 - 3
src/plugins/profile/templates/profile.js

@@ -39,7 +39,8 @@ export default (el) => {
             <div class="controlbox-section profile d-flex">
                 <a class="show-profile" href="#" @click=${el.showProfileModal}>
                     <converse-avatar class="avatar align-self-center"
-                        .data=${el.model.vcard?.attributes}
+                        .model=${el.model}
+                        name="${el.model.getDisplayName()}"
                         nonce=${el.model.vcard?.get('vcard_updated')}
                         height="40" width="40"></converse-avatar>
                 </a>
@@ -48,9 +49,18 @@ export default (el) => {
                 ${api.settings.get('allow_logout') ? tplSignout() : ''}
             </div>
             <div class="d-flex xmpp-status">
-                <a class="change-status" title="${i18n_change_status}" data-toggle="modal" data-target="#changeStatusModal" @click=${el.showStatusChangeModal}>
+                <a class="change-status"
+                   title="${i18n_change_status}"
+                   data-toggle="modal"
+                   data-target="#changeStatusModal"
+                   @click=${el.showStatusChangeModal}>
+
                     <span class="${chat_status} w-100 align-self-center" data-value="${chat_status}">
-                    <converse-icon color="var(--${color})" css="margin-top: -0.1em" size="0.82em" class="${classes}"></converse-icon> ${status_message}</span>
+                        <converse-icon
+                                color="var(--${color})"
+                                css="margin-top: -0.1em"
+                                size="0.82em"
+                                class="${classes}"></converse-icon> ${status_message}</span>
                 </a>
             </div>
         </div>`

+ 1 - 1
src/plugins/profile/templates/profile_modal.js

@@ -73,7 +73,7 @@ export default (el) => {
                 <form class="converse-form converse-form--modal profile-form" action="#" @submit=${ev => el.onFormSubmitted(ev)}>
                     <div class="row">
                         <div class="col-auto">
-                            <converse-image-picker .data="${{image: o.image, image_type: o.image_type}}" width="128" height="128"></converse-image-picker>
+                            <converse-image-picker .model=${el.model} width="128" height="128"></converse-image-picker>
                         </div>
                         <div class="col">
                             <div class="form-group">

+ 6 - 0
src/plugins/roomslist/styles/roomsgroups.scss

@@ -1,4 +1,5 @@
 .conversejs {
+
     #chatrooms {
         .muc-domain-group-toggle {
             margin: 0.75em 0 0.25em 0;
@@ -10,5 +11,10 @@
                 color: var(--chatroom-head-bg-color-dark);
             }
         }
+
+        converse-rooms-list {
+            display: block;
+            margin-bottom: 0.5em;
+        }
     }
 }

+ 33 - 15
src/plugins/roomslist/templates/roomslist.js

@@ -56,7 +56,16 @@ function tplRoomItem (el, room) {
         <div class="list-item controlbox-padded available-chatroom d-flex flex-row ${ isCurrentlyOpen(room) ? 'open' : '' } ${ has_unread_msgs ? 'unread-msgs' : '' }"
             data-room-jid="${room.get('jid')}">
 
-            ${ room.get('num_unread') ? tplUnreadIndicator(room) : (room.get('has_activity') ? tplActivityIndicator() : '') }
+            <converse-avatar
+                .model=${room}
+                class="avatar avatar-muc"
+                name="${room.getDisplayName()}"
+                nonce=${room.vcard?.get('vcard_updated')}
+                height="30" width="30"></converse-avatar>
+
+            ${ room.get('num_unread') ?
+                    tplUnreadIndicator(room) :
+                    (room.get('has_activity') ? tplActivityIndicator() : '') }
 
             <a class="list-item-link open-room available-room w-100"
                 data-room-jid="${room.get('jid')}"
@@ -65,31 +74,35 @@ function tplRoomItem (el, room) {
 
             ${ api.settings.get('allow_bookmarks') ? tplBookmark(room) : '' }
 
-            <a class="list-item-action room-info"
-                data-room-jid="${room.get('jid')}"
-                title="${__('Show more information on this groupchat')}"
-                @click=${ev => el.showRoomDetailsModal(ev)}>
-
-                <converse-icon class="fa fa-info-circle" size="1.2em" color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon>
-            </a>
-
             <a class="list-item-action close-room"
                 data-room-jid="${room.get('jid')}"
                 data-room-name="${room.getDisplayName()}"
                 title="${i18n_leave_room}"
                 @click=${ev => el.closeRoom(ev)}>
-                <converse-icon class="fa fa-sign-out-alt" size="1.2em" color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon>
+                <converse-icon
+                    class="fa fa-sign-out-alt"
+                    size="1.2em"
+                    color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon>
             </a>
         </div>`;
 }
 
+/**
+ * @param {RoomsList} el
+ * @param {string} domain
+ * @param {MUC[]} rooms
+ */
 function tplRoomDomainGroup (el, domain, rooms) {
     const i18n_title = __('Click to hide these rooms');
     const collapsed = el.model.get('collapsed_domains');
     const is_collapsed = collapsed.includes(domain);
     return html`
     <div class="muc-domain-group" data-domain="${domain}">
-        <a href="#" class="list-toggle muc-domain-group-toggle controlbox-padded" title="${i18n_title}" @click=${ev => el.toggleDomainList(ev, domain)}>
+        <a href="#"
+           class="list-toggle muc-domain-group-toggle controlbox-padded"
+           title="${i18n_title}"
+           @click=${ev => el.toggleDomainList(ev, domain)}>
+
             <converse-icon
                 class="fa ${ is_collapsed ? 'fa-caret-right' : 'fa-caret-down' }"
                 size="1em"
@@ -102,6 +115,10 @@ function tplRoomDomainGroup (el, domain, rooms) {
     </div>`;
 }
 
+/**
+ * @param {RoomsList} el
+ * @param {MUC[]} rooms
+ */
 function tplRoomDomainGroupList (el, rooms) {
     // The rooms should stay sorted as they are iterated and added in order
     const grouped_rooms = new Map();
@@ -125,7 +142,7 @@ function tplRoomDomainGroupList (el, rooms) {
 export default (el) => {
     const group_by_domain = api.settings.get('muc_grouped_by_domain');
     const { chatboxes } = _converse.state;
-    const rooms = chatboxes.filter(m => m.get('type') === CHATROOMS_TYPE);
+    const rooms = chatboxes.filter((m) => m.get('type') === CHATROOMS_TYPE);
     rooms.sort((a, b) => (a.getDisplayName().toLowerCase() <= b.getDisplayName().toLowerCase() ? -1 : 1));
 
     const i18n_desc_rooms = __('Click to toggle the list of open groupchats');
@@ -165,11 +182,12 @@ export default (el) => {
                    title="${i18n_desc_rooms}"
                    @click=${ev => el.toggleRoomsList(ev)}>
 
-                    <converse-icon
+                    ${i18n_heading_chatrooms}
+
+                    ${rooms.length ? html`<converse-icon
                         class="fa ${ is_closed ? 'fa-caret-right' : 'fa-caret-down' }"
                         size="1em"
-                        color="var(--muc-color)"></converse-icon>
-                    ${i18n_heading_chatrooms}
+                        color="var(--muc-color)"></converse-icon>` : '' }
                 </a>
             </span>
             <converse-dropdown class="dropleft" .items=${btns}></converse-dropdown>

+ 111 - 102
src/plugins/roomslist/tests/roomslist.js

@@ -1,6 +1,6 @@
 /* global mock, converse */
 
-const { $msg, u } = converse.env;
+const { $msg, u, Strophe, $iq, sizzle } = converse.env;
 
 
 describe("A list of open groupchats", function () {
@@ -149,7 +149,7 @@ describe("A list of open groupchats", function () {
         expect(Array.from(list.classList).includes('hidden')).toBeFalsy();
         const items = list.querySelectorAll('.list-item');
         expect(items.length).toBe(1);
-        await u.waitUntil(() => list.querySelector('.list-item').textContent.trim() === 'Bookmarked Lounge');
+        await u.waitUntil(() => list.querySelector('.list-item .open-room').textContent.trim() === 'Bookmarked Lounge');
         expect(_converse.bookmarks.fetchBookmarks).toHaveBeenCalled();
     }));
 });
@@ -175,7 +175,7 @@ describe("A groupchat shown in the groupchats list", function () {
         let item = room_els[0];
         await u.waitUntil(() => _converse.chatboxes.get(muc_jid).get('hidden') === false);
         await u.waitUntil(() => u.hasClass('open', item), 1000);
-        expect(item.textContent.trim()).toBe('coven@chat.shakespeare.lit');
+        expect(item.querySelector('.open-room').textContent.trim()).toBe('coven@chat.shakespeare.lit');
         await _converse.api.rooms.open('balcony@chat.shakespeare.lit', {'nick': 'some1'}, true);
         await u.waitUntil(() => lview.querySelectorAll(".open-room").length > 1);
         room_els = lview.querySelectorAll(".open-room");
@@ -184,125 +184,134 @@ describe("A groupchat shown in the groupchats list", function () {
         room_els = lview.querySelectorAll(".available-chatroom.open");
         expect(room_els.length).toBe(1);
         item = room_els[0];
-        expect(item.textContent.trim()).toBe('balcony@chat.shakespeare.lit');
+        expect(item.querySelector('a').textContent.trim()).toBe('balcony@chat.shakespeare.lit');
     }));
 
-    it("has an info icon which opens a details modal when clicked", mock.initConverse(
+    it("shows the MUC avatar", mock.initConverse(
             ['chatBoxesFetched'],
             { whitelisted_plugins: ['converse-roomslist'],
             allow_bookmarks: false // Makes testing easier, otherwise we
                                     // have to mock stanza traffic.
             }, async function (_converse) {
 
-        const { Strophe, $iq, $pres } = converse.env;
         const u = converse.env.utils;
-        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
-        const room_jid = 'coven@chat.shakespeare.lit';
+        const muc_jid = 'coven@chat.shakespeare.lit';
         await mock.waitForRoster(_converse, 'current', 0);
         await mock.openControlBox(_converse);
-        await _converse.api.rooms.open(room_jid, {'nick': 'some1'});
-
-        const selector = `iq[to="${room_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
-        const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop());
-        const features_stanza = $iq({
-                'from': 'coven@chat.shakespeare.lit',
-                'id': features_query.getAttribute('id'),
-                'to': 'romeo@montague.lit/desktop',
-                'type': 'result'
-            })
-            .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                .c('identity', {
-                    'category': 'conference',
-                    'name': 'A Dark Cave',
-                    'type': 'text'
-                }).up()
-                .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                .c('feature', {'var': 'muc_passwordprotected'}).up()
-                .c('feature', {'var': 'muc_hidden'}).up()
-                .c('feature', {'var': 'muc_temporary'}).up()
-                .c('feature', {'var': 'muc_open'}).up()
-                .c('feature', {'var': 'muc_unmoderated'}).up()
-                .c('feature', {'var': 'muc_nonanonymous'}).up()
-                .c('feature', {'var': 'urn:xmpp:mam:0'}).up()
-                .c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
-                    .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                        .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
-                    .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
-                        .c('value').t('This is the description').up().up()
-                    .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
-                        .c('value').t(0);
-        _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
-
-        const view = _converse.chatboxviews.get(room_jid);
-        await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)
-        let presence = $pres({
-                to: _converse.api.connection.get().jid,
-                from: 'coven@chat.shakespeare.lit/some1',
-                id: 'DC352437-C019-40EC-B590-AF29E879AF97'
-        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({
-                affiliation: 'member',
-                jid: _converse.bare_jid,
-                role: 'participant'
-            }).up()
-            .c('status').attrs({code:'110'});
-        _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
 
         const rooms_list = document.querySelector('converse-rooms-list');
         await u.waitUntil(() => rooms_list.querySelectorAll(".open-room").length, 500);
         const room_els = rooms_list.querySelectorAll(".open-room");
         expect(room_els.length).toBe(1);
-        const info_el = rooms_list.querySelector(".room-info");
-        info_el.click();
-
-        const modal = _converse.api.modal.get('converse-muc-details-modal');
-        await u.waitUntil(() => u.isVisible(modal), 1000);
-        let els = modal.querySelectorAll('p.room-info');
-        expect(els[0].textContent).toBe("Name: A Dark Cave")
-
-        expect(els[1].querySelector('strong').textContent).toBe("XMPP address");
-        expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe("xmpp:coven@chat.shakespeare.lit?join");
-        expect(els[2].querySelector('strong').textContent).toBe("Description");
-        expect(els[2].querySelector('converse-rich-text').textContent).toBe("This is the description");
-
-        expect(els[3].textContent).toBe("Online users: 1")
-        const features_list = modal.querySelector('.features-list');
-        expect(features_list.textContent.replace(/(\n|\s{2,})/g, '')).toBe(
-            'Password protected - This groupchat requires a password before entry'+
-            'Hidden - This groupchat is not publicly searchable'+
-            'Open - Anyone can join this groupchat'+
-            'Temporary - This groupchat will disappear once the last person leaves '+
-            'Not anonymous - All other groupchat participants can see your XMPP address'+
-            'Not moderated - Participants entering this groupchat can write right away '
-        );
-        presence = $pres({
-                to: 'romeo@montague.lit/_converse.js-29092160',
-                from: 'coven@chat.shakespeare.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            });
-        _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        const avatar_el = rooms_list.querySelector("converse-avatar");
+        expect(avatar_el).toBeDefined();
+
+        let initials_el = rooms_list.querySelector('converse-avatar .avatar-initials');
+        expect(initials_el.textContent).toBe('C');
+        expect(getComputedStyle(initials_el).backgroundColor).toBe('rgb(75, 103, 255)');
+
+        const muc_el = _converse.chatboxviews.get(muc_jid);
+        let muc_initials_el = muc_el.querySelector('converse-muc-heading converse-avatar .avatar-initials');
+        expect(muc_initials_el.textContent).toBe(initials_el.textContent);
+        expect(getComputedStyle(muc_initials_el).backgroundColor).toBe(getComputedStyle(initials_el).backgroundColor);
+
+        // Change MUC name
+        // ---------------
+        muc_el.querySelector('.configure-chatroom-button').click();
+        const { IQ_stanzas } = _converse.api.connection.get();
+        const sel = 'iq query[xmlns="http://jabber.org/protocol/muc#owner"]';
+        let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        // Check that an IQ is sent out, asking for the configuration form.
+        expect(Strophe.serialize(iq)).toBe(
+            `<iq id="${iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                `<query xmlns="http://jabber.org/protocol/muc#owner"/>`+
+            `</iq>`);
+
+        const jid = _converse.session.get('jid');
+
+        /* Server responds with the configuration form.
+         * See: // https://xmpp.org/extensions/xep-0045.html#example-165
+         */
+        const config_stanza = $iq({from: 'coven@chat.shakespeare.lit',
+            'id': iq.getAttribute('id'),
+            'to': jid,
+            'type': 'result'})
+        .c('query', { 'xmlns': 'http://jabber.org/protocol/muc#owner'})
+            .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form'})
+                .c('title').t('Configuration for "coven" Room').up()
+                .c('instructions').t('Complete this form to modify the configuration of your room.').up()
+                .c('field', {'type': 'hidden', 'var': 'FORM_TYPE'})
+                    .c('value').t('http://jabber.org/protocol/muc#roomconfig').up().up()
+                .c('field', {
+                    'label': 'Natural-Language Room Name',
+                    'type': 'text-single',
+                    'var': 'muc#roomconfig_roomname'})
+                    .c('value').t('Coven').up().up()
+        _converse.api.connection.get()._dataRecv(mock.createRequest(config_stanza));
+
+        const name_el = await u.waitUntil(() => muc_el.querySelector('input[name="muc#roomconfig_roomname"]'));
+        name_el.value = 'New room name';
+        muc_el.querySelector('.chatroom-form input[type="submit"]').click();
+
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.matches(`iq[to="${muc_jid}"][type="set"]`)).pop());
+        IQ_stanzas.length = 0; // Empty the array
+        const result = $iq({
+            "xmlns": "jabber:client",
+            "type": "result",
+            "to": jid,
+            "from": muc_jid,
+            "id": iq.getAttribute('id')
+        });
+        _converse.api.connection.get()._dataRecv(mock.createRequest(result));
+
+        iq = await u.waitUntil(() => IQ_stanzas.filter(
+            iq => iq.querySelector(
+                `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+            )).pop());
+
+        const features_stanza = $iq({
+            'from': muc_jid,
+            'id': iq.getAttribute('id'),
+            'to': 'romeo@montague.lit/desktop',
+            'type': 'result'
+        }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+            .c('identity', {
+                'category': 'conference',
+                'name': 'New room name',
+                'type': 'text'
+            }).up();
+        _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
+
+        await u.waitUntil(() => new Promise(success => muc_el.model.features.on('change', success)));
+
+        initials_el = rooms_list.querySelector('converse-avatar .avatar-initials');
+        expect(initials_el.textContent).toBe('NN');
+        expect(getComputedStyle(initials_el).backgroundColor).toBe('rgb(75, 103, 255)');
+
+        muc_initials_el = muc_el.querySelector('converse-muc-heading converse-avatar .avatar-initials');
+        expect(muc_initials_el.textContent).toBe(initials_el.textContent);
+        expect(getComputedStyle(muc_initials_el).backgroundColor).toBe(getComputedStyle(initials_el).backgroundColor);
 
-        els = modal.querySelectorAll('p.room-info');
-        expect(els[3].textContent).toBe("Online users: 2")
+        // eslint-disable-next-line max-len
+        const image = 'PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==';
+        const image_type = 'image/svg+xml';
 
-        view.model.set({'subject': {'author': 'someone', 'text': 'Hatching dark plots'}});
-        els = modal.querySelectorAll('p.room-info');
-        expect(els[0].textContent).toBe("Name: A Dark Cave")
+        // Change MUC avatar and check that it reflects
+        muc_el.model.vcard.set({
+            image,
+            image_type,
+            vcard_updated: (new Date()).toISOString()
+        });
 
-        expect(els[1].querySelector('strong').textContent).toBe("XMPP address");
-        expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe("xmpp:coven@chat.shakespeare.lit?join");
-        expect(els[2].querySelector('strong').textContent).toBe("Description");
-        expect(els[2].querySelector('converse-rich-text').textContent).toBe("This is the description");
-        expect(els[3].querySelector('strong').textContent).toBe("Topic");
-        await u.waitUntil(() => els[3].querySelector('converse-rich-text').textContent === "Hatching dark plots");
+        const muc_heading_avatar = await u.waitUntil(() => muc_el.querySelector(
+            `converse-muc-heading converse-avatar svg image`
+        ));
+        expect(muc_heading_avatar.getAttribute('href')).toBe(`data:image/svg+xml;base64,${image}`);
 
-        expect(els[4].textContent).toBe("Topic author: someone")
-        expect(els[5].textContent).toBe("Online users: 2")
+        const list_el_image = rooms_list.querySelector('converse-avatar svg image');
+        expect(list_el_image.getAttribute('href')).toBe(`data:image/svg+xml;base64,${image}`);
     }));
 
     it("can be closed", mock.initConverse(

+ 1 - 9
src/plugins/roomslist/view.js

@@ -26,6 +26,7 @@ export class RoomsList extends CustomElement {
         this.listenTo(chatboxes, 'remove', this.renderIfChatRoom);
         this.listenTo(chatboxes, 'destroy', this.renderIfChatRoom);
         this.listenTo(chatboxes, 'change', this.renderIfRelevantChange);
+        this.listenTo(chatboxes, 'vcard:change', () => this.requestUpdate());
         this.listenTo(this.model, 'change', () => this.requestUpdate());
 
         this.requestUpdate();
@@ -49,15 +50,6 @@ export class RoomsList extends CustomElement {
         }
     }
 
-    /** @param {Event} ev */
-    showRoomDetailsModal (ev) {
-        const target = /** @type {HTMLElement} */(ev.currentTarget);
-        const jid = target.getAttribute('data-room-jid');
-        const room = _converse.state.chatboxes.get(jid);
-        ev.preventDefault();
-        api.modal.show('converse-muc-details-modal', {'model': room}, ev);
-    }
-
     /** @param {Event} ev */
     async openRoom (ev) {
         ev.preventDefault();

+ 1 - 1
src/plugins/rosterview/contactview.js

@@ -40,7 +40,7 @@ export default class RosterContact extends CustomElement {
                 })
             );
         } else {
-            return tplRosterItem(this, this.model);
+            return tplRosterItem(this);
         }
     }
 

+ 4 - 0
src/plugins/rosterview/styles/roster.scss

@@ -10,6 +10,10 @@
 
         .open-contacts-toggle {
             white-space: nowrap;
+            converse-icon {
+                margin-left: 0.25em;
+                padding-bottom: 0.1em;
+            }
         }
 
     }

+ 4 - 3
src/plugins/rosterview/templates/roster.js

@@ -81,12 +81,13 @@ export default (el) => {
         <div class="d-flex controlbox-padded">
             <span class="w-100 controlbox-heading controlbox-heading--contacts">
                 <a class="list-toggle open-contacts-toggle" title="${i18n_toggle_contacts}" @click=${el.toggleRoster}>
-                    <converse-icon
+                    ${i18n_heading_contacts}
+
+                    ${ roster.length ? html`<converse-icon
                         class="fa ${is_closed ? 'fa-caret-right' : 'fa-caret-down'}"
                         size="1em"
                         color="var(--chat-color)"
-                    ></converse-icon>
-                    ${i18n_heading_contacts}
+                        ></converse-icon>` : '' }
                 </a>
             </span>
             <converse-dropdown class="chatbox-btn dropleft dropdown--contacts" .items=${btns}></converse-dropdown>

+ 26 - 11
src/plugins/rosterview/templates/roster_item.js

@@ -1,10 +1,16 @@
+/**
+ * @typedef {import('../contactview').default} RosterContact
+ */
 import { __ } from 'i18n';
 import { api } from "@converse/headless";
 import { html } from "lit";
 import { STATUSES } from '../constants.js';
 
-const tplRemoveLink = (el, item) => {
-   const display_name = item.getDisplayName();
+/**
+ * @param {RosterContact} el
+ */
+const tplRemoveLink = (el) => {
+   const display_name = el.model.getDisplayName();
    const i18n_remove = __('Click to remove %1$s as a contact', display_name);
    return html`
       <a class="list-item-action remove-xmpp-contact" @click=${el.removeContact} title="${i18n_remove}" href="#">
@@ -13,8 +19,11 @@ const tplRemoveLink = (el, item) => {
    `;
 }
 
-export default  (el, item) => {
-   const show = item.presence.get('show') || 'offline';
+/**
+ * @param {RosterContact} el
+ */
+export default  (el) => {
+   const show = el.model.presence.get('show') || 'offline';
     let classes, color;
     if (show === 'online') {
         [classes, color] = ['fa fa-circle', 'chat-status-online'];
@@ -26,15 +35,21 @@ export default  (el, item) => {
         [classes, color] = ['fa fa-circle', 'subdued-color'];
     }
    const desc_status = STATUSES[show];
-   const num_unread = item.get('num_unread') || 0;
-   const display_name = item.getDisplayName();
-   const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, el.model.get('jid'));
+   const num_unread = el.model.get('num_unread') || 0;
+   const display_name = el.model.getDisplayName();
+   const jid = el.model.get('jid');
+   const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, jid);
    return html`
-   <a class="list-item-link cbox-list-item open-chat ${ num_unread ? 'unread-msgs' : '' }" title="${i18n_chat}" href="#" @click=${el.openChat}>
+      <a class="list-item-link cbox-list-item open-chat ${ num_unread ? 'unread-msgs' : '' }"
+         title="${i18n_chat}"
+         href="#"
+         data-jid=${jid}
+         @click=${el.openChat}>
       <span>
          <converse-avatar
+            .model=${el.model}
             class="avatar"
-            .data=${el.model.vcard?.attributes}
+            name="${el.model.getDisplayName()}"
             nonce=${el.model.vcard?.get('vcard_updated')}
             height="30" width="30"></converse-avatar>
          <converse-icon
@@ -44,7 +59,7 @@ export default  (el, item) => {
             class="${classes} chat-status chat-status--avatar"></converse-icon>
       </span>
       ${ num_unread ? html`<span class="msgs-indicator">${ num_unread }</span>` : '' }
-      <span class="contact-name contact-name--${el.show} ${ num_unread ? 'unread-msgs' : ''}">${display_name}</span>
+      <span class="contact-name contact-name--${show} ${ num_unread ? 'unread-msgs' : ''}">${display_name}</span>
    </a>
-   ${ api.settings.get('allow_contact_removal') ? tplRemoveLink(el, item) : '' }`;
+   ${ api.settings.get('allow_contact_removal') ? tplRemoveLink(el) : '' }`;
 }

+ 1 - 1
src/plugins/rosterview/tests/protocol.js

@@ -291,7 +291,7 @@ describe("The Protocol", function () {
             expect(u.hasClass('both', contacts[0])).toBeFalsy();
             expect(u.hasClass('current-xmpp-contact', contacts[0])).toBeTruthy();
 
-            await u.waitUntil(() => contacts[0].textContent.trim() === 'Nicky');
+            await u.waitUntil(() => contacts[0].querySelector('.contact-name')?.textContent.trim() === 'Nicky');
 
             expect(contact.presence.get('show')).toBe('offline');
 

+ 7 - 6
src/plugins/rosterview/tests/roster.js

@@ -321,7 +321,8 @@ describe("The Contacts Roster", function () {
             // Only one roster contact is now visible
             let visible_contacts = sizzle('li', roster).filter(u.isVisible);
             expect(visible_contacts.length).toBe(1);
-            expect(visible_contacts.pop().textContent.trim()).toBe('Juliet Capulet');
+            expect(visible_contacts.pop().querySelector('.contact-name').textContent.trim()).toBe('Juliet Capulet');
+
             // Only one foster group is still visible
             expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(1);
             const visible_group = sizzle('.roster-group', roster).filter(u.isVisible).pop();
@@ -448,7 +449,7 @@ describe("The Contacts Roster", function () {
             u.triggerEvent(filter, 'change');
 
             await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900);
-            expect(sizzle('li', roster).filter(u.isVisible).pop().textContent.trim()).toBe('Lord Montague');
+            expect(sizzle('li', roster).filter(u.isVisible).pop().querySelector('.contact-name').textContent.trim()).toBe('Lord Montague');
 
             let ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
             expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('Family');
@@ -456,7 +457,7 @@ describe("The Contacts Roster", function () {
             filter.value = "dnd";
             u.triggerEvent(filter, 'change');
 
-            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().textContent.trim() === 'Friar Laurence', 900);
+            await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().querySelector('.contact-name').textContent.trim() === 'Friar Laurence', 900);
             ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
             expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences');
             expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1);
@@ -808,7 +809,7 @@ describe("The Contacts Roster", function () {
             await u.waitUntil(() => sizzle('li', rosterview.querySelector(`ul[data-group="Pending contacts"]`)).filter(u.isVisible).length);
             // Check that they are sorted alphabetically
             const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`));
-            const spans = el.querySelectorAll('.pending-xmpp-contact span');
+            const spans = el.querySelectorAll('.pending-xmpp-contact .contact-name');
 
             await u.waitUntil(
                 () => Array.from(spans).reduce((result, value) => result + value.textContent?.trim(), '') ===
@@ -908,7 +909,7 @@ describe("The Contacts Roster", function () {
             }));
             await u.waitUntil(() => sizzle('li', rosterview).length);
             // Check that they are sorted alphabetically
-            const els = sizzle('.current-xmpp-contact.offline a.open-chat', rosterview)
+            const els = sizzle('.current-xmpp-contact.offline a.open-chat .contact-name', rosterview)
             const t = els.reduce((result, value) => (result + value.textContent.trim()), '');
             expect(t).toEqual(mock.cur_names.slice(0,mock.cur_names.length).sort().join(''));
         }));
@@ -1394,8 +1395,8 @@ describe("The Contacts Roster", function () {
             await Promise.all(mock.cur_names.map(async name => {
                 const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
                 const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop());
+                expect(el.querySelector('.contact-name').textContent.trim()).toBe(name);
                 const child = el.firstElementChild.firstElementChild;
-                expect(child.textContent.trim()).toBe(name);
                 expect(child.getAttribute('title')).toContain(name);
                 expect(child.getAttribute('title')).toContain(jid);
             }));

+ 46 - 23
src/shared/avatar/avatar.js

@@ -1,44 +1,67 @@
+import { api } from '@converse/headless';
 import { CustomElement } from 'shared/components/element.js';
+import { html } from 'lit';
+import { until } from 'lit/directives/until.js';
 import tplAvatar from './templates/avatar.js';
-import { _converse, api } from '@converse/headless';
 
 import './avatar.scss';
 
-
 export default class Avatar extends CustomElement {
-
-    static get properties () {
+    static get properties() {
         return {
-            data: { type: Object },
+            model: { type: Object },
+            name: { type: String },
             width: { type: String },
             height: { type: String },
             nonce: { type: String }, // Used to trigger rerenders
-        }
+        };
     }
 
-    constructor () {
+    constructor() {
         super();
-        this.data = null;
+        this.model = null;
         this.width = 36;
         this.height = 36;
+        this.name = '';
+    }
+
+    render() {
+        const { image_type, image, data_uri } = this.model?.vcard?.attributes || {};
+        if (image_type && (image || data_uri)) {
+            return tplAvatar({
+                classes: this.getAttribute('class'),
+                height: this.height,
+                width: this.width,
+                image: data_uri || `data:${image_type};base64,${image}`,
+                image_type,
+            });
+        }
+
+        const default_bg_css = `background-color: gray;`;
+        const css = `
+            width: ${this.width}px;
+            height: ${this.height}px;
+            font: ${this.width / 2}px Arial;
+            line-height: ${this.height}px;`;
+
+        const author_style = this.model.getAvatarStyle(css);
+        return html`<div class="avatar-initials" style="${until(author_style, default_bg_css + css)}">
+            ${this.getInitials(this.name)}
+        </div>`;
     }
 
-    render  () {
-        const image_type = this.data?.image_type || _converse.DEFAULT_IMAGE_TYPE;
-        let image;
-        if (this.data?.data_uri) {
-            image = this.data?.data_uri;
-        } else {
-            const image_data = this.data?.image || _converse.DEFAULT_IMAGE;
-            image = "data:" + image_type + ";base64," + image_data;
+    /**
+     * @param {string} name
+     * @returns {string}
+     */
+    getInitials(name) {
+        const names = name?.split(' ');
+        if (names?.length > 1) {
+            return names[0].charAt(0).toUpperCase() + names[names.length - 1].charAt(0).toUpperCase();
+        } else if (names?.length === 1) {
+            return names[0].charAt(0).toUpperCase();
         }
-        return tplAvatar({
-            'classes': this.getAttribute('class'),
-            'height': this.height,
-            'width': this.width,
-            image,
-            image_type,
-        });
+        return '';
     }
 }
 

+ 12 - 0
src/shared/avatar/avatar.scss

@@ -2,6 +2,10 @@ converse-avatar {
     border: 0;
     background: transparent;
 
+    &.avatar-muc {
+        margin-right: 0.5em;
+    }
+
     &.modal-avatar {
         display: block;
         margin-bottom: 1em;
@@ -10,4 +14,12 @@ converse-avatar {
     .avatar {
         border-radius: var(--avatar-border-radius);
     }
+
+
+    .avatar-initials {
+        color: white;
+        border-radius: var(--avatar-border-radius);
+        text-align: center;
+        display: inline-block;
+    }
 }

+ 21 - 8
src/shared/avatar/templates/avatar.js

@@ -1,16 +1,29 @@
-import { html } from "lit";
+import { html } from 'lit';
 
+/**
+ * @param {string} image
+ * @param {string} image_type
+ */
 const getImgHref = (image, image_type) => {
     return image.startsWith('data:') ? image : `data:${image_type};base64,${image}`;
-}
+};
 
-export default  (o) => {
+export default (o) => {
     if (o.image) {
-        return html`
-            <svg xmlns="http://www.w3.org/2000/svg" class="avatar ${o.classes}" width="${o.width}" height="${o.height}">
-                <image width="${o.width}" height="${o.height}" preserveAspectRatio="xMidYMid meet" href="${getImgHref(o.image, o.image_type)}"/>
-            </svg>`;
+        return html` <svg
+            xmlns="http://www.w3.org/2000/svg"
+            class="avatar ${o.classes}"
+            width="${o.width}"
+            height="${o.height}"
+        >
+            <image
+                width="${o.width}"
+                height="${o.height}"
+                preserveAspectRatio="xMidYMid meet"
+                href="${getImgHref(o.image, o.image_type)}"
+            />
+        </svg>`;
     } else {
         return '';
     }
-}
+};

+ 3 - 1
src/shared/chat/message.js

@@ -53,8 +53,10 @@ export default class Message extends CustomElement {
 
         this.listenTo(this.chatbox, 'change:first_unread_id', () => this.requestUpdate());
         this.listenTo(this.model, 'change', () => this.requestUpdate());
-        this.model.vcard && this.listenTo(this.model.vcard, 'change', () => this.requestUpdate());
+        this.listenTo(this.model, 'contact:change', () => this.requestUpdate());
+        this.listenTo(this.model, 'vcard:change', () => this.requestUpdate());
 
+        // TODO: refactor MUC to trigger `occupant:change`
         if (this.model.get('type') === 'groupchat') {
             if (this.model.occupant) {
                 this.listenTo(this.model.occupant, 'change', () => this.requestUpdate());

+ 5 - 2
src/shared/chat/templates/file-progress.js

@@ -9,12 +9,15 @@ export default (el) => {
     const i18n_uploading = __('Uploading file:');
     const filename = el.model.file.name;
     const size = filesize(el.model.file.size);
+    const contact = el.model.occupant || el.model.contact;
     return html`
         <div class="message chat-msg">
             ${ el.shouldShowAvatar() ?
                 html`<a class="show-msg-author-modal" @click=${el.showUserModal}>
-                    <converse-avatar class="avatar align-self-center"
-                        .data=${el.model.vcard?.attributes}
+                    <converse-avatar
+                        .model=${contact || el.model}
+                        class="avatar align-self-center"
+                        name="${el.model.getDisplayName()}"
                         nonce=${el.model.vcard?.get('vcard_updated')}
                         height="40" width="40"></converse-avatar>
                 </a>` : '' }

+ 8 - 6
src/shared/chat/templates/message.js

@@ -5,8 +5,8 @@ import 'shared/avatar/avatar.js';
 import 'shared/chat/unfurl.js';
 import { __ } from 'i18n';
 import { html } from "lit";
-import { shouldRenderMediaFromURL } from 'utils/url.js';
-import { getAuthorStyle } from 'utils/color.js';
+import { shouldRenderMediaFromURL } from '../../../utils/url.js';
+import { getAuthorStyle } from '../../../utils/color.js';
 
 
 /**
@@ -16,8 +16,9 @@ import { getAuthorStyle } from 'utils/color.js';
 export default (el, o) => {
     const i18n_new_messages = __('New messages');
     const is_followup = el.model.isFollowup();
-    const occupant = el.model.occupant;
-    const author_style = getAuthorStyle(occupant);
+
+    const contact = el.model.occupant || el.model.contact;
+    const author_style = getAuthorStyle(contact);
 
     return html`
         ${ o.is_first_unread ? html`<div class="message separator">
@@ -36,9 +37,10 @@ export default (el, o) => {
             ${ (o.should_show_avatar && !is_followup) ?
                 html`<a class="show-msg-author-modal" @click=${el.showUserModal}>
                     <converse-avatar
+                        .model=${contact || el.model}
                         class="avatar align-self-center"
-                        .data=${el.model.vcard?.attributes}
-                        nonce=${el.model.vcard?.get('vcard_updated')}
+                        name="${el.model.getDisplayName()}"
+                        nonce="${el.model.vcard?.get('vcard_updated')}"
                         height="40" width="40"></converse-avatar>
                 </a>` : '' }
 

+ 13 - 5
src/shared/components/image-picker.js

@@ -10,34 +10,42 @@ export default class ImagePicker extends CustomElement {
 
     constructor () {
         super();
+        this.model = null;
         this.width = null;
         this.height = null;
     }
 
     static get properties () {
         return {
-            'height': { type: Number },
-            'data': { type: Object},
-            'width': { type: Number },
+            height: { type: Number },
+            model: { type: Object},
+            width: { type: Number },
         }
     }
 
     render () {
         return html`
             <a class="change-avatar" @click=${this.openFileSelection} title="${i18n_profile_picture}">
-                <converse-avatar class="avatar" .data=${this.data} height="${this.height}" width="${this.width}"></converse-avatar>
+                <converse-avatar
+                        .model=${this.model}
+                        class="avatar"
+                        name="${this.model.getDisplayName()}"
+                        height="${this.height}"
+                        width="${this.width}"></converse-avatar>
             </a>
             <input @change=${this.updateFilePreview} class="hidden" name="image" type="file"/>
         `;
     }
 
+    /** @param {Event} ev */
     openFileSelection (ev) {
         ev.preventDefault();
         /** @type {HTMLInputElement} */(this.querySelector('input[type="file"]')).click();
     }
 
+    /** @param {InputEvent} ev */
     updateFilePreview (ev) {
-        const file = ev.target.files[0];
+        const file = /** @type {HTMLInputElement} */(ev.target).files[0];
         const reader = new FileReader();
         reader.onloadend = () => {
             this.data = {

+ 34 - 8
src/types/plugins/muc-views/heading.d.ts

@@ -1,21 +1,47 @@
 export default class MUCHeading extends CustomElement {
+    /**
+     * @typedef {import('@converse/headless/types/plugins/muc/muc').MUCOccupant} MUCOccupant
+     */
     initialize(): Promise<void>;
     model: any;
     user_settings: any;
     render(): import("lit-html").TemplateResult<1> | "";
-    onOccupantAdded(occupant: any): void;
-    onOccupantAffiliationChanged(occupant: any): void;
-    showRoomDetailsModal(ev: any): void;
-    showInviteModal(ev: any): void;
-    toggleTopic(ev: any): void;
+    /**
+     * @param {MUCOccupant} occupant
+     */
+    onOccupantAdded(occupant: import("@converse/headless/types/plugins/muc/occupant.js").default): void;
+    /**
+     * @param {MUCOccupant} occupant
+     */
+    onOccupantAffiliationChanged(occupant: import("@converse/headless/types/plugins/muc/occupant.js").default): void;
+    /**
+     * @param {Event} ev
+     */
+    showRoomDetailsModal(ev: Event): void;
+    /**
+     * @param {Event} ev
+     */
+    showInviteModal(ev: Event): void;
+    /**
+     * @param {Event} ev
+     */
+    toggleTopic(ev: Event): void;
     getAndRenderConfigurationForm(): void;
-    close(ev: any): void;
-    destroy(ev: any): void;
+    /**
+     * @param {Event} ev
+     */
+    close(ev: Event): void;
+    /**
+     * @param {Event} ev
+     */
+    destroy(ev: Event): void;
     /**
      * Returns a list of objects which represent buttons for the groupchat header.
      * @emits _converse#getHeadingButtons
+     *
+     * @param {boolean} subject_hidden
      */
-    getHeadingButtons(subject_hidden: any): any;
+    getHeadingButtons(subject_hidden: boolean): any;
 }
 import { CustomElement } from "shared/components/element.js";
 //# sourceMappingURL=heading.d.ts.map

+ 1 - 1
src/types/plugins/muc-views/modals/templates/muc-details.d.ts

@@ -1,3 +1,3 @@
-declare function _default(model: any): import("lit-html").TemplateResult<1>;
+declare function _default(model: import('@converse/headless').MUC): import("lit-html").TemplateResult<1>;
 export default _default;
 //# sourceMappingURL=muc-details.d.ts.map

+ 1 - 1
src/types/plugins/muc-views/templates/muc-head.d.ts

@@ -1,3 +1,3 @@
-declare function _default(el: any): import("lit-html").TemplateResult<1>;
+declare function _default(el: import('../heading').default): import("lit-html").TemplateResult<1>;
 export default _default;
 //# sourceMappingURL=muc-head.d.ts.map

+ 2 - 1
src/types/plugins/muc-views/templates/occupant.d.ts

@@ -1,3 +1,4 @@
-declare function _default(o: any, chat: any): import("lit-html").TemplateResult<1>;
+declare function _default(o: MUCOccupant, chat: any): import("lit-html").TemplateResult<1>;
 export default _default;
+export type MUCOccupant = import('@converse/headless').MUCOccupant;
 //# sourceMappingURL=occupant.d.ts.map

+ 27 - 5
src/types/plugins/muc-views/utils.d.ts

@@ -8,13 +8,35 @@ export function confirmDirectMUCInvitation({ contact, jid, reason }: {
     jid: any;
     reason: any;
 }): any;
-export function clearHistory(jid: any): void;
-export function destroyMUC(model: any): Promise<any>;
-export function getNicknameRequiredTemplate(model: any): import("lit-html").TemplateResult<1>;
+/**
+ * @param {string} jid
+ */
+export function clearHistory(jid: string): void;
+/**
+ * @param {MUC} model
+ */
+export function destroyMUC(model: MUC): Promise<any>;
+/**
+ * @param {MUC} model
+ */
+export function getNicknameRequiredTemplate(model: MUC): import("lit-html").TemplateResult<1>;
 export function getChatRoomBodyTemplate(o: any): import("lit-html").TemplateResult<1>;
-export function getAutoCompleteListItem(text: any, input: any): HTMLLIElement;
+/**
+ * @param {MUC} muc
+ * @param {Suggestion} text
+ * @param {string} input
+ * @returns {HTMLLIElement}
+ */
+export function getAutoCompleteListItem(muc: MUC, text: Suggestion, input: string): HTMLLIElement;
 export function getAutoCompleteList(): Promise<any[]>;
-export function showModeratorToolsModal(muc: any, affiliation: any): void;
+/**
+ * @param {MUC} muc
+ * @param {string} [affiliation]
+ */
+export function showModeratorToolsModal(muc: MUC, affiliation?: string): void;
 export function showOccupantModal(ev: any, occupant: any): void;
 export function parseMessageForMUCCommands(data: any, handled: any): any;
+export type MUC = import('@converse/headless/types/plugins/muc/muc.js').default;
+export type Avatar = import("shared/avatar/avatar").default;
+export type Suggestion = import("shared/autocomplete/suggestion").default;
 //# sourceMappingURL=utils.d.ts.map

+ 0 - 2
src/types/plugins/roomslist/view.d.ts

@@ -7,8 +7,6 @@ export class RoomsList extends CustomElement {
     /** @param {Model} model */
     renderIfRelevantChange(model: Model): void;
     /** @param {Event} ev */
-    showRoomDetailsModal(ev: Event): void;
-    /** @param {Event} ev */
     openRoom(ev: Event): Promise<void>;
     /** @param {Event} ev */
     closeRoom(ev: Event): Promise<void>;

+ 2 - 1
src/types/plugins/rosterview/templates/roster_item.d.ts

@@ -1,3 +1,4 @@
-declare function _default(el: any, item: any): import("lit-html").TemplateResult<1>;
+declare function _default(el: RosterContact): import("lit-html").TemplateResult<1>;
 export default _default;
+export type RosterContact = import('../contactview').default;
 //# sourceMappingURL=roster_item.d.ts.map

+ 11 - 2
src/types/shared/avatar/avatar.d.ts

@@ -1,8 +1,11 @@
 export default class Avatar extends CustomElement {
     static get properties(): {
-        data: {
+        model: {
             type: ObjectConstructor;
         };
+        name: {
+            type: StringConstructor;
+        };
         width: {
             type: StringConstructor;
         };
@@ -13,10 +16,16 @@ export default class Avatar extends CustomElement {
             type: StringConstructor;
         };
     };
-    data: any;
+    model: any;
     width: number;
     height: number;
+    name: string;
     render(): import("lit-html").TemplateResult<1> | "";
+    /**
+     * @param {string} name
+     * @returns {string}
+     */
+    getInitials(name: string): string;
 }
 import { CustomElement } from "shared/components/element.js";
 //# sourceMappingURL=avatar.d.ts.map

+ 7 - 4
src/types/shared/components/image-picker.d.ts

@@ -3,21 +3,24 @@ export default class ImagePicker extends CustomElement {
         height: {
             type: NumberConstructor;
         };
-        data: {
+        model: {
             type: ObjectConstructor;
         };
         width: {
             type: NumberConstructor;
         };
     };
+    model: any;
     width: any;
     height: any;
     render(): import("lit-html").TemplateResult<1>;
-    openFileSelection(ev: any): void;
-    updateFilePreview(ev: any): void;
+    /** @param {Event} ev */
+    openFileSelection(ev: Event): void;
+    /** @param {InputEvent} ev */
+    updateFilePreview(ev: InputEvent): void;
     data: {
         data_uri: string | ArrayBuffer;
-        image_type: any;
+        image_type: string;
     };
 }
 import { CustomElement } from "./element.js";

+ 3 - 3
src/types/utils/color.d.ts

@@ -1,9 +1,9 @@
 /**
- * @param {MUCOccupant} occupant
+ * @param {ColorAwareModel} occupant
  * @returns {string|TemplateResult}
  */
-export function getAuthorStyle(occupant: MUCOccupant): string | TemplateResult;
+export function getAuthorStyle(occupant: ColorAwareModel): string | TemplateResult;
 export type TemplateResult = import('lit').TemplateResult;
 export type Message = import('shared/chat/message').default;
-export type MUCOccupant = import('@converse/headless/types/plugins/muc/occupant').default;
+export type ColorAwareModel = import('@converse/headless/types/shared/color').ColorAwareModel;
 //# sourceMappingURL=color.d.ts.map

+ 13 - 9
src/utils/color.js

@@ -1,28 +1,32 @@
 /**
  * @typedef {import('lit').TemplateResult} TemplateResult
  * @typedef {import('shared/chat/message').default} Message
- * @typedef {import('@converse/headless/types/plugins/muc/occupant').default} MUCOccupant
+ * @typedef {import('@converse/headless/types/shared/color').ColorAwareModel} ColorAwareModel
  */
-import { html } from "lit";
+import { html } from 'lit';
 import { until } from 'lit/directives/until.js';
 import { api } from '@converse/headless';
 
-/** @param {string} color */
-function getStyle (color) {
-    return `color: ${color}!important;`;
+
+/**
+ * @param {string} color
+ * @param {string} append_style
+ */
+function getCSS(color, append_style = '') {
+    return `color: ${color}!important;${append_style}`;
 }
 
 /**
- * @param {MUCOccupant} occupant
+ * @param {ColorAwareModel} occupant
  * @returns {string|TemplateResult}
  */
-export function getAuthorStyle (occupant) {
+export function getAuthorStyle(occupant) {
     if (api.settings.get('colorize_username')) {
         const color = occupant?.get('color');
         if (color) {
-            return getStyle(color);
+            return getCSS(color);
         } else {
-            return occupant ? html`${until(occupant?.getColor().then(getStyle), '')}` : '';
+            return occupant ? html`${until(occupant?.getColor().then(getCSS), '')}` : '';
         }
     }
 }