Forráskód Böngészése

Allow setting a custom name and the group for an existing contact

Fixes #690

- Align values vertically
- Make sure the modal updates when the model changes
JC Brand 5 hónapja
szülő
commit
f2c2b2b678

+ 1 - 0
CHANGES.md

@@ -5,6 +5,7 @@
 ### Github Issues
 - #122: Set horizontal layout direction based on the language
 - #317: Add the ability to render audio streams. New config option [fetch_url_headers](https://conversejs.org/docs/html/configuration.html#fetch-url-headers)
+- #690: Allow setting a custom name and the group for an existing contact
 - #698: Add support for MUC private messages
 - #1021: Message from non-roster contacts don't appear in fullscreen view_mode
 - #1038: Support setting node config manually

+ 2 - 2
src/plugins/omemo/fingerprints.js

@@ -1,6 +1,6 @@
-import tplFingerprints from './templates/fingerprints.js';
-import { CustomElement } from 'shared/components/element.js';
 import { api } from "@converse/headless";
+import { CustomElement } from 'shared/components/element.js';
+import tplFingerprints from './templates/fingerprints.js';
 
 export class Fingerprints extends CustomElement {
 

+ 0 - 1
src/plugins/omemo/templates/fingerprints.js

@@ -50,7 +50,6 @@ export default (el) => {
     const i18n_no_devices = __("No OMEMO-enabled devices found");
     const devices = el.devicelist.devices;
     return html`
-        <hr/>
         <ul class="list-group fingerprints">
             <li class="list-group-item active">${i18n_fingerprints}</li>
             ${ devices.length ?

+ 60 - 39
src/plugins/omemo/tests/omemo.js

@@ -1129,51 +1129,72 @@ describe("The OMEMO module", function() {
         await u.waitUntil(() => u.isVisible(modal), 1000);
 
         let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
-        expect(Strophe.serialize(iq_stanza)).toBe(
-            `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+
-                `<pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub>`+
-            `</iq>`);
+        expect(iq_stanza).toEqualStanza(stx`
+            <iq from="romeo@montague.lit"
+                    id="${iq_stanza.getAttribute("id")}"
+                    to="mercutio@montague.lit"
+                    type="get"
+                    xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub>
+            </iq>`);
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest($iq({
-            'from': contact_jid,
-            'id': iq_stanza.getAttribute('id'),
-            'to': _converse.bare_jid,
-            'type': 'result',
-        }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
-            .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
-                .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
-                    .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
-                        .c('device', {'id': '555'})
-        ));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<iq from="${contact_jid}"
+                    id="${iq_stanza.getAttribute('id')}"
+                    to="${_converse.bare_jid}"
+                    xmlns="jabber:client"
+                    type="result">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="eu.siacs.conversations.axolotl.devicelist">
+                        <item xmlns="http://jabber.org/protocol/pubsub">
+                            <list xmlns="eu.siacs.conversations.axolotl">
+                                <device id="555"/>
+                            </list>
+                        </item>
+                    </items>
+                </pubsub>
+            </iq>`));
 
         await u.waitUntil(() => u.isVisible(modal), 1000);
 
         iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
-        expect(Strophe.serialize(iq_stanza)).toBe(
-            `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+
-                `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
-                    `<items node="eu.siacs.conversations.axolotl.bundles:555"/>`+
-                `</pubsub>`+
-            `</iq>`);
+        expect(iq_stanza).toEqualStanza(stx`
+            <iq from="romeo@montague.lit"
+                    id="${iq_stanza.getAttribute("id")}"
+                    to="mercutio@montague.lit"
+                    type="get"
+                    xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <items node="eu.siacs.conversations.axolotl.bundles:555"/>
+                </pubsub>
+            </iq>`);
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest($iq({
-            'from': contact_jid,
-            'id': iq_stanza.getAttribute('id'),
-            'to': _converse.bare_jid,
-            'type': 'result',
-        }).c('pubsub', {
-            'xmlns': 'http://jabber.org/protocol/pubsub'
-            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
-                .c('item')
-                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
-                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
-                        .c('signedPreKeySignature').t(btoa('2222')).up()
-                        .c('identityKey').t('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI').up()
-                        .c('prekeys')
-                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
-                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
-                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'))
-        ));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<iq from="${contact_jid}"
+                id="${iq_stanza.getAttribute('id')}"
+                to="${_converse.bare_jid}"
+                xmlns="jabber:client"
+                type="result">
+            <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                <items node="eu.siacs.conversations.axolotl.bundles:555">
+                    <item>
+                        <bundle xmlns="eu.siacs.conversations.axolotl">
+                            <signedPreKeyPublic signedPreKeyId="4223">${btoa('1111')}</signedPreKeyPublic>
+                            <signedPreKeySignature>${btoa('2222')}</signedPreKeySignature>
+                            <identityKey>${'BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI'}</identityKey>
+                            <prekeys>
+                                <preKeyPublic preKeyId="1">${btoa('1001')}</preKeyPublic>
+                                <preKeyPublic preKeyId="2">${btoa('1002')}</preKeyPublic>
+                                <preKeyPublic preKeyId="3">${btoa('1003')}</preKeyPublic>
+                            </prekeys>
+                        </bundle>
+                    </item>
+                </items>
+            </pubsub>
+        </iq>`));
+
+        // Open the OMEMO tab
+        modal.querySelector('.nav-item #omemo-tab').click();
 
         await u.waitUntil(() => modal.querySelectorAll('.fingerprints .fingerprint').length);
         expect(modal.querySelectorAll('.fingerprints .fingerprint').length).toBe(1);

+ 6 - 7
src/plugins/profile/modals/profile.js

@@ -1,19 +1,18 @@
-
-/**
- * @typedef {import('@converse/headless/types/plugins/vcard/api').VCardData} VCardData
- * @typedef {import("@converse/headless").XMPPStatus} XMPPStatus
- */
 import { _converse, api, log } from "@converse/headless";
+import { compressImage, isImageWithAlphaChannel } from 'utils/file.js';
 import BaseModal from "plugins/modal/modal.js";
-import tplProfileModal from "../templates/profile_modal.js";
 import { __ } from 'i18n';
 import '../password-reset.js';
-import { compressImage, isImageWithAlphaChannel } from 'utils/file.js';
+import tplProfileModal from "../templates/profile_modal.js";
 
 import './styles/profile.scss';
 
 
 export default class ProfileModal extends BaseModal {
+    /**
+     * @typedef {import('@converse/headless/types/plugins/vcard/api').VCardData} VCardData
+     * @typedef {import("@converse/headless").XMPPStatus} XMPPStatus
+     */
 
     constructor (options) {
         super(options);

+ 3 - 3
src/plugins/rosterview/modals/templates/accept-contact-request.js

@@ -13,12 +13,12 @@ export default (el) => {
 
     return html` <div class="modal-body">
         ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ""}
-        <form class="converse-form add-xmpp-contact" @submit=${(ev) => el.acceptContactRequest(ev)}>
-            <div class="add-xmpp-contact__name mb-3">
+        <form class="converse-form" @submit=${(ev) => el.acceptContactRequest(ev)}>
+            <div class="mb-3">
                 <label class="form-label clearfix" for="name">${i18n_nickname}:</label>
                 <input type="text" name="name" value="${el.contact.vcard?.get('fullname') || ''}" class="form-control" />
             </div>
-            <div class="add-xmpp-contact__group mb-3">
+            <div class="mb-3">
                 <label class="form-label clearfix" for="name">${i18n_group}:</label>
                 <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="group"></converse-autocomplete>
             </div>

+ 3 - 3
src/plugins/rosterview/modals/templates/add-contact.js

@@ -24,7 +24,7 @@ export default (el) => {
         <div class="modal-body">
             ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ''}
             <form class="converse-form add-xmpp-contact" @submit=${ev => el.addContactFromForm(ev)}>
-                <div class="add-xmpp-contact__jid mb-3">
+                <div class="mb-3">
                     <label class="form-label clearfix" for="jid">${i18n_xmpp_address}:</label>
                     ${api.settings.get('autocomplete_add_contact') ?
                         html`<converse-autocomplete
@@ -45,7 +45,7 @@ export default (el) => {
                     }
                 </div>
 
-                <div class="add-xmpp-contact__name mb-3">
+                <div class="mb-3">
                     <label class="form-label clearfix" for="name">${i18n_nickname}:</label>
                     ${api.settings.get('autocomplete_add_contact') && typeof api.settings.get('xhr_user_search_url') === 'string' ?
                         html`<converse-autocomplete
@@ -59,7 +59,7 @@ export default (el) => {
                             class="form-control" />`
                     }
                 </div>
-                <div class="add-xmpp-contact__group mb-3">
+                <div class="mb-3">
                     <label class="form-label clearfix" for="name">${i18n_group}:</label>
                     <converse-autocomplete
                         .list=${getGroupsAutoCompleteList()}

+ 19 - 2
src/shared/modals/styles/user-details.scss

@@ -1,5 +1,22 @@
 .conversejs {
-    .remove-contact {
-        margin-inline-end: 0.5em;
+    converse-user-details-modal {
+        .nav-pills {
+            margin-bottom: 1em;
+        }
+
+        .badge-roster-group {
+            background-color: var(--chat-color);
+        }
+
+        #profile-tabpanel {
+            label {
+                color: var(--subdued-color);
+                margin-inline-end: 0.5em;
+            }
+        }
+
+        .remove-contact {
+            margin-inline-end: 0.5em;
+        }
     }
 }

+ 204 - 44
src/shared/modals/templates/user-details.js

@@ -1,8 +1,9 @@
-import { html } from 'lit';
-import { until } from 'lit/directives/until.js';
-import { api, converse, _converse } from '@converse/headless';
-import { __ } from 'i18n';
-import avatar from 'shared/avatar/templates/avatar.js';
+import { html } from "lit";
+import { until } from "lit/directives/until.js";
+import { api, converse, _converse } from "@converse/headless";
+import { getGroupsAutoCompleteList } from "plugins/rosterview/utils.js";
+import { __ } from "i18n";
+import avatar from "shared/avatar/templates/avatar.js";
 
 const { Strophe } = converse.env;
 
@@ -10,7 +11,7 @@ const { Strophe } = converse.env;
  * @param {import('../user-details').default} el
  */
 function tplUnblockButton(el) {
-    const i18n_block = __('Remove from blocklist');
+    const i18n_block = __("Remove from blocklist");
     return html`
         <button type="button" @click="${(ev) => el.unblockContact(ev)}" class="btn btn-danger">
             <converse-icon class="fas fa-times" color="var(--background-color)" size="1em"></converse-icon
@@ -23,7 +24,7 @@ function tplUnblockButton(el) {
  * @param {import('../user-details').default} el
  */
 function tplBlockButton(el) {
-    const i18n_block = __('Add to blocklist');
+    const i18n_block = __("Add to blocklist");
     return html`
         <button type="button" @click="${(ev) => el.blockContact(ev)}" class="btn btn-danger">
             <converse-icon class="fas fa-times" color="var(--background-color)" size="1em"></converse-icon
@@ -36,7 +37,7 @@ function tplBlockButton(el) {
  * @param {import('../user-details').default} el
  */
 function tplRemoveButton(el) {
-    const i18n_remove_contact = __('Remove as contact');
+    const i18n_remove_contact = __("Remove as contact");
     return html`
         <button type="button" @click="${(ev) => el.removeContact(ev)}" class="btn btn-danger remove-contact">
             <converse-icon class="fas fa-trash-alt" color="var(--background-color)" size="1em"></converse-icon
@@ -54,15 +55,15 @@ export function tplUserDetailsModal(el) {
     const o = { ...el.model.toJSON(), ...vcard_json };
 
     const is_roster_contact = el.model.contact !== undefined;
-    const allow_contact_removal = api.settings.get('allow_contact_removal');
+    const allow_contact_removal = api.settings.get("allow_contact_removal");
 
-    const domain = _converse.session.get('domain');
+    const domain = _converse.session.get("domain");
     const blocking_supported = api.disco.supports(Strophe.NS.BLOCKING, domain).then(
         /** @param {boolean} supported */
         async (supported) => {
             const blocklist = await api.blocklist.get();
             if (supported) {
-                if (blocklist.get(el.model.get('jid'))) {
+                if (blocklist.get(el.model.get("jid"))) {
                     tplUnblockButton(el);
                 } else {
                     tplBlockButton(el);
@@ -71,45 +72,204 @@ export function tplUserDetailsModal(el) {
         }
     );
 
-    const i18n_address = __('XMPP Address');
-    const i18n_email = __('Email');
-    const i18n_full_name = __('Full Name');
-    const i18n_nickname = __('Nickname');
-    const i18n_profile = __("The User's Profile Image");
-    const i18n_role = __('Role');
-    const i18n_url = __('URL');
+    const i18n_address = __("XMPP Address");
+    const i18n_email = __("Email");
+    const i18n_full_name = __("Full Name");
+    const i18n_nickname = __("Nickname");
+    const i18n_role = __("Role");
+    const i18n_url = __("URL");
+    const i18n_groups = __("Groups");
+    const i18n_groups_help = __("Use commas to separate multiple values");
+    const i18n_omemo = __("OMEMO");
+    const i18n_profile = __("Profile");
+    const ii18n_edit = __("Edit");
 
     const avatar_data = {
-        alt_text: i18n_profile,
-        extra_classes: 'mb-3',
-        height: '120',
-        width: '120',
+        alt_text: __("The User's Profile Image"),
+        extra_classes: "mb-3",
+        height: "160",
+        width: "160",
     };
 
+    const navigation_tabs = [
+        html`<li role="presentation" class="nav-item">
+            <a
+                class="nav-link ${el.tab === "profile" ? "active" : ""}"
+                id="profile-tab"
+                href="#profile-tabpanel"
+                aria-controls="profile-tabpanel"
+                role="tab"
+                @click="${(ev) => el.switchTab(ev)}"
+                data-name="profile"
+                data-toggle="tab"
+                >${i18n_profile}</a
+            >
+        </li>`,
+    ];
+
+    navigation_tabs.push(
+        html`<li role="presentation" class="nav-item">
+            <a
+                class="nav-link ${el.tab === "edit" ? "active" : ""}"
+                id="edit-tab"
+                href="#edit-tabpanel"
+                aria-controls="edit-tabpanel"
+                role="tab"
+                @click="${(ev) => el.switchTab(ev)}"
+                data-name="edit"
+                data-toggle="tab"
+                >${ii18n_edit}</a
+            >
+        </li>`
+    );
+
+    if (_converse.pluggable.plugins["converse-omemo"]?.enabled(_converse)) {
+        navigation_tabs.push(
+            html`<li role="presentation" class="nav-item">
+                <a
+                    class="nav-link ${el.tab === "omemo" ? "active" : ""}"
+                    id="omemo-tab"
+                    href="#omemo-tabpanel"
+                    aria-controls="omemo-tabpanel"
+                    role="tab"
+                    @click="${(ev) => el.switchTab(ev)}"
+                    data-name="omemo"
+                    data-toggle="tab"
+                    >${i18n_omemo}</a
+                >
+            </li>`
+        );
+    }
+
+    const { contact } = el.model;
+    const name = contact.get("nickname") || contact.vcard?.get('fullname');
+    const groups = contact.get("groups");
+
     return html`
-        <span>
-            ${o.image ? html`<div class="mb-4">${avatar(Object.assign(o, avatar_data))}</div>` : ''}
-            ${o.fullname ? html`<p><label>${i18n_full_name}:</label> ${o.fullname}</p>` : ''}
-            <p><label>${i18n_address}:</label> <a href="xmpp:${o.jid}">${o.jid}</a></p>
-            ${o.nickname ? html`<p><label>${i18n_nickname}:</label> ${o.nickname}</p>` : ''}
-            ${o.url
-                ? html`<p>
-                      <label>${i18n_url}:</label> <a target="_blank" rel="noopener" href="${o.url}">${o.url}</a>
-                  </p>`
-                : ''}
-            ${o.email ? html`<p><label>${i18n_email}:</label> <a href="mailto:${o.email}">${o.email}</a></p>` : ''}
-            ${o.role ? html`<p><label>${i18n_role}:</label> ${o.role}</p>` : ''}
-
-            <hr />
-            <div>
-                ${allow_contact_removal && is_roster_contact ? tplRemoveButton(el) : ''}
-                ${until(
-                    blocking_supported.then(() => tplBlockButton(el)),
-                    ''
-                )}
+        <ul class="nav nav-pills justify-content-center">
+            ${navigation_tabs}
+        </ul>
+        <div class="tab-content">
+            <div
+                class="tab-pane ${el.tab === "profile" ? "active" : ""}"
+                id="profile-tabpanel"
+                role="tabpanel"
+                aria-labelledby="profile-tab"
+            >
+                ${o.image ? html`<div class="mb-4">${avatar(Object.assign(o, avatar_data))}</div>` : ""}
+                ${o.fullname
+                    ? html`
+                          <div class="row mb-2">
+                              <div class="col-sm-4"><label>${i18n_full_name}:</label></div>
+                              <div class="col-sm-8">${o.fullname}</div>
+                          </div>
+                      `
+                    : ""}
+                <div class="row mb-2">
+                    <div class="col-sm-4"><label>${i18n_address}:</label></div>
+                    <div class="col-sm-8"><a href="xmpp:${o.jid}">${o.jid}</a></div>
+                </div>
+                ${o.nickname
+                    ? html`
+                          <div class="row mb-2">
+                              <div class="col-sm-4"><label>${i18n_nickname}:</label></div>
+                              <div class="col-sm-8">${o.nickname}</div>
+                          </div>
+                      `
+                    : ""}
+                ${o.url
+                    ? html`
+                          <div class="row mb-2">
+                              <div class="col-sm-4"><label>${i18n_url}:</label></div>
+                              <div class="col-sm-8">
+                                  <a target="_blank" rel="noopener" href="${o.url}">${o.url}</a>
+                              </div>
+                          </div>
+                      `
+                    : ""}
+                ${o.email
+                    ? html`
+                          <div class="row mb-2">
+                              <div class="col-sm-4"><label>${i18n_email}:</label></div>
+                              <div class="col-sm-8"><a href="mailto:${o.email}">${o.email}</a></div>
+                          </div>
+                      `
+                    : ""}
+                ${o.role
+                    ? html`
+                          <div class="row mb-2">
+                              <div class="col-sm-4"><label>${i18n_role}:</label></div>
+                              <div class="col-sm-8">${o.role}</div>
+                          </div>
+                      `
+                    : ""}
+                ${groups.length
+                    ? html`
+                          <div class="row mb-2">
+                              <div class="col-sm-4"><label>${i18n_groups}:</label></div>
+                              <div class="col-sm-8">
+                                  ${groups.map(
+                                      /** @param {string} group */ (group) =>
+                                          html`<span class="badge badge-roster-group me-1">${group}</span>`
+                                  )}
+                              </div>
+                          </div>
+                      `
+                    : ""}
+            </div>
+
+            <div
+                class="tab-pane ${el.tab === "edit" ? "active" : ""}"
+                id="edit-tabpanel"
+                role="tabpanel"
+                aria-labelledby="edit-tab"
+            >
+                ${el.tab === "edit"
+                    ? html`<form class="converse-form" @submit=${(ev) => el.updateContact(ev)}>
+                              <div class="mb-3">
+                                  <label class="form-label clearfix" for="name">${__("Name")}:</label>
+                                  <input
+                                      type="text"
+                                      name="name"
+                                      value="${name}"
+                                      class="form-control"
+                                  />
+                              </div>
+                              <div class="mb-3">
+                                  <label class="form-label clearfix" for="name">${i18n_groups}:</label>
+                                  <div class="mb-1">
+                                      <small class="form-text text-muted">${i18n_groups_help}</small>
+                                  </div>
+                                  <converse-autocomplete
+                                      .list=${getGroupsAutoCompleteList()}
+                                      name="groups"
+                                      value="${groups}"
+                                  ></converse-autocomplete>
+                              </div>
+                              <button type="submit" class="btn btn-primary">${__("Update")}</button>
+                          </form>
+                          <hr />
+
+                          ${allow_contact_removal && is_roster_contact ? tplRemoveButton(el) : ""}
+                          ${until(
+                              blocking_supported.then(() => tplBlockButton(el)),
+                              ""
+                          )}`
+                    : ""}
             </div>
 
-            <converse-omemo-fingerprints jid=${o.jid}></converse-omemo-fingerprints>
-        </span>
+            ${_converse.pluggable.plugins["converse-omemo"]?.enabled(_converse)
+                ? html` <div
+                      class="tab-pane ${el.tab === "omemo" ? "active" : ""}"
+                      id="omemo-tabpanel"
+                      role="tabpanel"
+                      aria-labelledby="omemo-tab"
+                  >
+                      ${el.tab === "omemo"
+                          ? html`<converse-omemo-fingerprints jid=${o.jid}></converse-omemo-fingerprints>`
+                          : ""}
+                  </div>`
+                : ""}
+        </div>
     `;
 }

+ 48 - 10
src/shared/modals/user-details.js

@@ -9,27 +9,49 @@ import './styles/user-details.scss';
 
 export default class UserDetailsModal extends BaseModal {
 
+    constructor (options) {
+        super(options);
+        this.tab = 'profile';
+    }
+
     initialize () {
         super.initialize();
+        this.addListeners();
+        /**
+         * Triggered once the UserDetailsModal has been initialized
+         * @event _converse#userDetailsModalInitialized
+         * @type {import('@converse/headless').ChatBox}
+         * @example _converse.api.listen.on('userDetailsModalInitialized', (chatbox) => { ... });
+         */
+        api.trigger('userDetailsModalInitialized', this.model);
+    }
+
+    addListeners() {
+        this.listenTo(this.model, 'change', () => this.requestUpdate());
+
         this.model.rosterContactAdded.then(() => {
             this.registerContactEventHandlers();
             api.vcard.update(this.model.contact.vcard, true);
         });
-        this.listenTo(this.model, 'change', this.render);
 
         if (this.model.contact !== undefined) {
             this.registerContactEventHandlers();
             // Refresh the vcard
             api.vcard.update(this.model.contact.vcard, true);
         }
+    }
 
-        /**
-         * Triggered once the UserDetailsModal has been initialized
-         * @event _converse#userDetailsModalInitialized
-         * @type {import('@converse/headless').ChatBox}
-         * @example _converse.api.listen.on('userDetailsModalInitialized', (chatbox) => { ... });
-         */
-        api.trigger('userDetailsModalInitialized', this.model);
+    /**
+     * @param {Map<string, any>} changed
+     */
+    shouldUpdate(changed) {
+        if (changed.has('model') && this.model) {
+            this.stopListening();
+            this.addListeners();
+            this.tab = 'profile';
+            this.requestUpdate();
+        }
+        return true;
     }
 
     renderModal () {
@@ -41,8 +63,8 @@ export default class UserDetailsModal extends BaseModal {
     }
 
     registerContactEventHandlers () {
-        this.listenTo(this.model.contact, 'change', this.render);
-        this.listenTo(this.model.contact.vcard, 'change', this.render);
+        this.listenTo(this.model.contact, 'change', () => this.requestUpdate());
+        this.listenTo(this.model.contact.vcard, 'change', () => this.requestUpdate());
         this.model.contact.on('destroy', () => {
             delete this.model.contact;
             this.close();
@@ -52,6 +74,22 @@ export default class UserDetailsModal extends BaseModal {
         api.vcard.update(this.model.contact.vcard, true);
     }
 
+    /**
+     * @param {MouseEvent} ev
+     */
+    async updateContact(ev) {
+        ev?.preventDefault?.();
+        const form = /** @type {HTMLFormElement} */ (ev.target);
+        const data = new FormData(form);
+        const name = /** @type {string} */ (data.get("name") || "").trim();
+        const groups = /** @type {string} */(data.get('groups'))?.split(',') || [];
+        this.model.contact.save({
+            nickname: name,
+            groups,
+        });
+        this.modal.hide();
+    }
+
     /**
      * @param {MouseEvent} ev
      */

+ 1 - 2
src/shared/styles/forms.scss

@@ -108,9 +108,8 @@
             }
 
             .text-muted {
-                color: var(--secondary-color) !important;
+                color: var(--subdued-color) !important;
                 font-size: 85%;
-                padding-top: 0.5em;
                 a {
                     color: var(--link-color);
                 }

+ 2 - 0
src/shared/styles/themes/classic.scss

@@ -28,6 +28,7 @@
     --warning-color: var(--orange);
     --info-color: var(--light-blue);
     --converse-highlight-color: var(--dark-red);
+    --converse-btn-close-color: var(--background-color);
 
     // Online status indicators
     --chat-status-away: var(--orange);
@@ -42,6 +43,7 @@
     --heading-color: var(--background-color);
     --headlines-color: var(--orange);
     --link-color: var(--dark-blue);
+    --subdued-color: gray;
 
     // The background when selecting text with your mouse
     --selection-color: black;

+ 2 - 0
src/shared/styles/themes/cyberpunk.scss

@@ -32,6 +32,7 @@
     --warning-color: var(--orange);
     --info-color: var(--yellow);
     --converse-highlight-color: var(--yellow);
+    --converse-btn-close-color: var(--background-color);
 
     // Online status indicators
     --chat-status-away: var(--orange);
@@ -46,6 +47,7 @@
     --heading-color: var(--purple);
     --headlines-color: var(--indigo);
     --link-color: var(--cyan);
+    --subdued-color: gray;
 
     // The background when selecting text with your mouse
     --selection-color: black;

+ 2 - 0
src/shared/styles/themes/dracula.scss

@@ -26,6 +26,7 @@
     --danger-color: var(--red);
     --warning-color: var(--orange);
     --info-color: var(--yellow);
+    --converse-btn-close-color: var(--background-color);
 
     --converse-body-bg: var(--background-color);
     --converse-highlight-color: var(--yellow) !important;
@@ -49,6 +50,7 @@
     --heading-color: var(--purple);
     --headlines-color: var(--pink);
     --link-color: var(--cyan);
+    --subdued-color: var(--gray);
 
     // The background when selecting text with your mouse
     --selection-color: black;

+ 2 - 0
src/shared/styles/themes/nord.scss

@@ -39,6 +39,7 @@
     --warning-color: var(--orange);
     --info-color: var(--purple);
     --converse-highlight-color: var(--red);
+    --converse-btn-close-color: var(--background-color);
 
     // Online status indicators
     --chat-status-away: var(--orange);
@@ -53,6 +54,7 @@
     --heading-color: var(--purple);
     --headlines-color: var(--yellow);
     --link-color: var(--frost-4);
+    --subdued-color: gray;
 
     // The background when selecting text with your mouse
     --selection-color: var(--frost-1);