浏览代码

Move the MUC config form into a modal

Also allow setting a MUC avatar via the same modal.

Made some changes to image compression as well. Don't compress PNG
images with an alpha channel (i.e. transparency).

Closes #2980
JC Brand 11 月之前
父节点
当前提交
88a7d79dc2

+ 2 - 0
CHANGES.md

@@ -2,9 +2,11 @@
 
 ## 11.0.0 (Unreleased)
 
+- #1174: Show MUC avatars in the rooms list
 - #1195: Add actions to quote and copy messages
 - #1349: XEP-0392 Consistent Color Generation
 - #2716: Fix issue with chat display when opening via URL
+- #2980: Allow setting an avatar for MUCs
 - #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
 - #3155: Some adhoc commands aren't working

+ 14 - 2
src/headless/plugins/vcard/api.js

@@ -9,6 +9,17 @@ import { createStanza, getVCard } from './utils.js';
 
 const { dayjs, u } = converse.env;
 
+/**
+ * @typedef {Object} VCardData
+ * @property {string} [VCardData.fn]
+ * @property {string} [VCardData.nickname]
+ * @property {string} [VCardData.role]
+ * @property {string} [VCardData.email]
+ * @property {string} [VCardData.url]
+ * @property {string} [VCardData.image_type]
+ * @property {string} [VCardData.image]
+ */
+
 export default {
     /**
      * The XEP-0054 VCard API
@@ -28,7 +39,8 @@ export default {
          *
          * @method _converse.api.vcard.set
          * @param {string} jid The JID for which the VCard should be set
-         * @param {object} data A map of VCard keys and values
+         * @param {VCardData} data A map of VCard keys and values
+         *
          * @example
          * let jid = _converse.bare_jid;
          * _converse.api.vcard.set( jid, {
@@ -124,7 +136,7 @@ export default {
          */
         async update (model, force) {
             const data = await this.get(model, force);
-            model = typeof model === 'string' ? _converse.exports.vcards.get(model) : model;
+            model = typeof model === 'string' ? _converse.state.vcards.get(model) : model;
             if (!model) {
                 log.error(`Could not find a VCard model for ${model}`);
                 return;

+ 12 - 2
src/headless/types/plugins/vcard/api.d.ts

@@ -9,7 +9,8 @@ declare namespace _default {
          *
          * @method _converse.api.vcard.set
          * @param {string} jid The JID for which the VCard should be set
-         * @param {object} data A map of VCard keys and values
+         * @param {VCardData} data A map of VCard keys and values
+         *
          * @example
          * let jid = _converse.bare_jid;
          * _converse.api.vcard.set( jid, {
@@ -21,7 +22,7 @@ declare namespace _default {
          *     // Failure, e is your error object
          * }).
          */
-        function set(jid: string, data: any): Promise<any>;
+        function set(jid: string, data: VCardData): Promise<any>;
         /**
          * @method _converse.api.vcard.get
          * @param {Model|string} model Either a `Model` instance, or a string JID.
@@ -66,4 +67,13 @@ declare namespace _default {
 }
 export default _default;
 export type Model = import('@converse/skeletor').Model;
+export type VCardData = {
+    fn?: string;
+    nickname?: string;
+    role?: string;
+    email?: string;
+    url?: string;
+    image_type?: string;
+    image?: string;
+};
 //# sourceMappingURL=api.d.ts.map

+ 3 - 1
src/plugins/bookmark-views/utils.js

@@ -16,7 +16,9 @@ export function getHeadingButtons (view, buttons) {
         const names = buttons.map(t => t.name);
         const idx = names.indexOf('details');
         const data_promise = Bookmarks.checkBookmarksSupport().then((s) => (s ? data : null));
-        return idx > -1 ? [...buttons.slice(0, idx), data_promise, ...buttons.slice(idx)] : [data_promise, ...buttons];
+        return idx > -1
+            ? [...buttons.slice(0, idx+1), data_promise, ...buttons.slice(idx+1)]
+            : [data_promise, ...buttons];
     }
     return buttons;
 }

+ 0 - 69
src/plugins/muc-views/config-form.js

@@ -1,69 +0,0 @@
-import tplMUCConfigForm from "./templates/muc-config-form.js";
-import { CustomElement } from 'shared/components/element';
-import { __ } from 'i18n';
-import { _converse, api, converse, log } from "@converse/headless";
-
-const { sizzle } = converse.env;
-const u = converse.env.utils;
-
-
-class MUCConfigForm extends CustomElement {
-
-    constructor () {
-        super();
-        this.jid = null;
-    }
-
-    static get properties () {
-        return {
-            'jid': { type: String }
-        }
-    }
-
-    connectedCallback () {
-        super.connectedCallback();
-        this.model = _converse.state.chatboxes.get(this.jid);
-        this.listenTo(this.model.features, 'change:passwordprotected', () => this.requestUpdate());
-        this.listenTo(this.model.session, 'change:config_stanza', () => this.requestUpdate());
-        this.getConfig();
-    }
-
-    render () {
-        return tplMUCConfigForm({
-            'model': this.model,
-            'closeConfigForm': ev => this.closeForm(ev),
-            'submitConfigForm': ev => this.submitConfigForm(ev),
-        });
-    }
-
-    async getConfig () {
-        const iq = await this.model.fetchRoomConfiguration();
-        this.model.session.set('config_stanza', iq.outerHTML);
-    }
-
-    async submitConfigForm (ev) {
-        ev.preventDefault();
-        const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
-        const config_array = inputs.map(u.webForm2xForm).filter(f => f);
-        try {
-            await this.model.sendConfiguration(config_array);
-        } catch (e) {
-            log.error(e);
-            const message =
-                __("Sorry, an error occurred while trying to submit the config form.") + " " +
-                __("Check your browser's developer console for details.");
-            api.alert('error', __('Error'), message);
-        }
-        await this.model.refreshDiscoInfo();
-        this.closeForm();
-    }
-
-    closeForm (ev) {
-        ev?.preventDefault?.();
-        this.model.session.set('view', null);
-    }
-}
-
-api.elements.define('converse-muc-config-form', MUCConfigForm);
-
-export default MUCConfigForm

+ 10 - 5
src/plugins/muc-views/heading.js

@@ -1,3 +1,4 @@
+import './modals/config.js';
 import './modals/muc-details.js';
 import './modals/muc-invite.js';
 import './modals/nickname.js';
@@ -63,7 +64,7 @@ export default class MUCHeading extends CustomElement {
      */
     showRoomDetailsModal (ev) {
         ev.preventDefault();
-        api.modal.show('converse-muc-details-modal', { 'model': this.model }, ev);
+        api.modal.show('converse-muc-details-modal', { model: this.model }, ev);
     }
 
     /**
@@ -71,7 +72,7 @@ export default class MUCHeading extends CustomElement {
      */
     showInviteModal (ev) {
         ev.preventDefault();
-        api.modal.show('converse-muc-invite-modal', { 'model': new Model(), 'chatroomview': this }, ev);
+        api.modal.show('converse-muc-invite-modal', { model: new Model(), 'chatroomview': this }, ev);
     }
 
     /**
@@ -82,8 +83,12 @@ export default class MUCHeading extends CustomElement {
         this.model.toggleSubjectHiddenState();
     }
 
-    getAndRenderConfigurationForm () {
-        this.model.session.set('view', converse.MUC.VIEWS.CONFIG);
+    /**
+     * @param {Event} ev
+     */
+    showConfigModal(ev) {
+        ev.preventDefault();
+        api.modal.show('converse-muc-config-modal', { model: this.model }, ev);
     }
 
     /**
@@ -123,7 +128,7 @@ export default class MUCHeading extends CustomElement {
             buttons.push({
                 'i18n_text': __('Configure'),
                 'i18n_title': __('Configure this groupchat'),
-                'handler': () => this.getAndRenderConfigurationForm(),
+                'handler': ev => this.showConfigModal(ev),
                 'a_class': 'configure-chatroom-button',
                 'icon_class': 'fa-wrench',
                 'name': 'configure'

+ 103 - 0
src/plugins/muc-views/modals/config.js

@@ -0,0 +1,103 @@
+/**
+ * @typedef {import('@converse/headless/types/plugins/vcard/api').VCardData} VCardData
+ */
+import tplMUCConfigForm from './templates/muc-config.js';
+import BaseModal from 'plugins/modal/modal.js';
+import { __ } from 'i18n';
+import { api, converse, log } from '@converse/headless';
+import { compressImage, isImageWithAlphaChannel } from 'utils/file.js';
+
+const { sizzle } = converse.env;
+const u = converse.env.utils;
+
+export default class MUCConfigModal extends BaseModal {
+
+    constructor (options) {
+        super(options);
+        this.id = 'converse-muc-config-modal';
+    }
+
+    initialize () {
+        super.initialize();
+        this.listenTo(this.model, 'change', () => this.render());
+        this.listenTo(this.model.features, 'change:passwordprotected', () => this.render());
+        this.listenTo(this.model.session, 'change:config_stanza', () => this.render());
+    }
+
+    renderModal () {
+        return tplMUCConfigForm(this);
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.getConfig();
+    }
+
+    getModalTitle () {
+        return __('Configure %1$s', this.model.getDisplayName());
+    }
+
+    async getConfig () {
+        const iq = await this.model.fetchRoomConfiguration();
+        this.model.session.set('config_stanza', iq.outerHTML);
+    }
+
+    /**
+     * @param {SubmitEvent} ev
+     */
+    async setAvatar (ev) {
+        const form_data = new FormData(/** @type {HTMLFormElement} */ (ev.target));
+        const image_file = /** @type {File} */ (form_data.get('avatar_image'));
+
+        if (image_file.size) {
+            const image_data = isImageWithAlphaChannel ? image_file : await compressImage(image_file);
+            const reader = new FileReader();
+            reader.onloadend = async () => {
+                const vcard_data = /** @type {VCardData} */ ({
+                    image: btoa(/** @type {string} */ (reader.result)),
+                    image_type: image_file.type,
+                });
+                await api.vcard.set(this.model.get('jid'), vcard_data);
+            };
+            reader.readAsBinaryString(image_data);
+        }
+    }
+
+    /**
+     * @param {SubmitEvent} ev
+     */
+    async submitConfigForm (ev) {
+        ev.preventDefault();
+        const inputs = sizzle(
+            ':input:not([type=button]):not([type=submit]):not([name="avatar_image"][type="file"])',
+            ev.target
+        );
+        const config_array = inputs.map(u.webForm2xForm).filter((f) => f);
+
+        try {
+            await this.model.sendConfiguration(config_array);
+        } catch (e) {
+            log.error(e);
+            const message =
+                __("Sorry, an error occurred while trying to submit the config form.") + " " +
+                __("Check your browser's developer console for details.");
+            api.alert('error', __('Error'), message);
+        }
+
+        try {
+            await this.setAvatar(ev);
+        } catch (err) {
+            log.fatal(err);
+            this.alert([
+                __("Sorry, an error happened while trying to save the MUC avatar."),
+                __("You can check your browser's developer console for any error output.")
+            ].join(" "));
+            return;
+        }
+
+        await this.model.refreshDiscoInfo();
+        this.modal.hide();
+    }
+}
+
+api.elements.define('converse-muc-config-modal', MUCConfigModal);

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

@@ -9,7 +9,7 @@ const u = converse.env.utils;
 export default class MUCInviteModal extends BaseModal {
     constructor (options) {
         super(options);
-        this.id = 'converse-modtools-modal';
+        this.id = 'converse-muc-invite-modal';
         this.chatroomview = options.chatroomview;
     }
 
@@ -27,7 +27,7 @@ export default class MUCInviteModal extends BaseModal {
     }
 
     getAutoCompleteList () {
-        return _converse.state.roster.map((i) => ({ 'label': i.getDisplayName(), 'value': i.get('jid') }));
+        return _converse.state.roster.map((i) => ({ label: i.getDisplayName(), value: i.get('jid') }));
     }
 
     submitInviteForm (ev) {

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

@@ -0,0 +1,5 @@
+converse-muc-config-modal {
+  converse-image-picker {
+    padding-top: 1em;
+  }
+}

+ 15 - 12
src/plugins/muc-views/templates/muc-config-form.js → src/plugins/muc-views/modals/templates/muc-config.js

@@ -2,16 +2,20 @@ import tplSpinner from 'templates/spinner.js';
 import { __ } from 'i18n';
 import { api, converse, parsers } from '@converse/headless';
 import { html } from 'lit';
+import '../styles/config.scss';
 
 const u = converse.env.utils;
 
-export default (o) => {
+/**
+ * @param {import('../config').default} el
+ */
+export default (el) => {
     const whitelist = api.settings.get('roomconfig_whitelist');
-    const config_stanza = o.model.session.get('config_stanza');
     let fieldTemplates = [];
     let instructions = '';
     let title = __('Loading configuration form');
 
+    const config_stanza = el.model.session.get('config_stanza');
     if (config_stanza) {
         const stanza = u.toStanza(config_stanza);
         let { fields } = parsers.parseXForm(stanza);
@@ -20,8 +24,8 @@ export default (o) => {
             fields = fields.filter((f) => whitelist.includes(f.var));
         }
         const options = {
-            new_password: !o.model.features.get('passwordprotected'),
-            fixed_username: o.model.get('jid'),
+            new_password: !el.model.features.get('passwordprotected'),
+            fixed_username: el.model.get('jid'),
         };
         fieldTemplates = fields.map((f) => u.xFormField2TemplateResult(f, stanza, options));
         instructions = stanza.querySelector('instructions')?.textContent;
@@ -29,27 +33,26 @@ export default (o) => {
     }
 
     const i18n_save = __('Save');
-    const i18n_cancel = __('Cancel');
     return html`
         <form
             class="converse-form chatroom-form ${fieldTemplates.length ? '' : 'converse-form--spinner'}"
             autocomplete="off"
-            @submit=${o.submitConfigForm}
+            @submit=${ev => el.submitConfigForm(ev)}
         >
             <fieldset class="form-group">
                 <legend class="centered">${title}</legend>
                 ${title !== instructions ? html`<p class="form-help">${instructions}</p>` : ''}
+
+                ${fieldTemplates.length ? html`<div class="row">
+                    <converse-image-picker .model=${el.model} width="96" height="96"></converse-image-picker>
+                </div>` : ''}
+
                 ${fieldTemplates.length ? fieldTemplates : tplSpinner({ 'classes': 'hor_centered' })}
             </fieldset>
+
             ${fieldTemplates.length
                 ? html` <fieldset>
                       <input type="submit" class="btn btn-primary" value="${i18n_save}" />
-                      <input
-                          type="button"
-                          class="btn btn-secondary button-cancel"
-                          value="${i18n_cancel}"
-                          @click=${o.closeConfigForm}
-                      />
                   </fieldset>`
                 : ''}
         </form>

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

@@ -1,8 +1,3 @@
-converse-muc-config-form {
-    width: 100%;
-    overflow: auto;
-}
-
 .conversejs {
     .chatroom {
         .box-flyout {

+ 0 - 1
src/plugins/muc-views/templates/muc.js

@@ -1,5 +1,4 @@
 import '../chatarea.js';
-import '../config-form.js';
 import '../destroyed.js';
 import '../disconnected.js';
 import '../heading.js';

+ 53 - 17
src/plugins/muc-views/tests/muc.js

@@ -1062,7 +1062,6 @@ describe("Groupchats", function () {
         }));
 
         it("can be configured if you're its owner", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
-
             let sent_IQ, IQ_id;
             const sendIQ = _converse.api.connection.get().sendIQ;
             spyOn(_converse.api.connection.get(), 'sendIQ').and.callFake(function (iq, callback, errback) {
@@ -1070,7 +1069,8 @@ describe("Groupchats", function () {
                 IQ_id = sendIQ.bind(this)(iq, callback, errback);
             });
 
-            await _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'});
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            await _converse.api.rooms.open(muc_jid, {'nick': 'some1'});
             const view = await u.waitUntil(() => _converse.chatboxviews.get('coven@chat.shakespeare.lit'));
             await u.waitUntil(() => u.isVisible(view));
             // We pretend this is a new room, so no disco info is returned.
@@ -1247,33 +1247,66 @@ describe("Groupchats", function () {
                         .c('value').t('cauldronburn');
             _converse.api.connection.get()._dataRecv(mock.createRequest(config_stanza));
 
-            const membersonly = await u.waitUntil(() => view.querySelector('input[name="muc#roomconfig_membersonly"]'));
+            const modal = _converse.api.modal.get('converse-muc-config-modal');
+
+            const membersonly = await u.waitUntil(() => modal.querySelector('input[name="muc#roomconfig_membersonly"]'));
             expect(membersonly.getAttribute('type')).toBe('checkbox');
             membersonly.checked = true;
 
-            const moderated = view.querySelectorAll('input[name="muc#roomconfig_moderatedroom"]');
+            const moderated = modal.querySelectorAll('input[name="muc#roomconfig_moderatedroom"]');
             expect(moderated.length).toBe(1);
             expect(moderated[0].getAttribute('type')).toBe('checkbox');
             moderated[0].checked = true;
 
-            const password = view.querySelectorAll('input[name="muc#roomconfig_roomsecret"]');
+            const password = modal.querySelectorAll('input[name="muc#roomconfig_roomsecret"]');
             expect(password.length).toBe(1);
             expect(password[0].getAttribute('type')).toBe('password');
 
-            const allowpm = view.querySelectorAll('select[name="muc#roomconfig_allowpm"]');
+            const allowpm = modal.querySelectorAll('select[name="muc#roomconfig_allowpm"]');
             expect(allowpm.length).toBe(1);
             allowpm[0].value = 'moderators';
 
-            const presencebroadcast = view.querySelectorAll('select[name="muc#roomconfig_presencebroadcast"]');
+            const presencebroadcast = modal.querySelectorAll('select[name="muc#roomconfig_presencebroadcast"]');
             expect(presencebroadcast.length).toBe(1);
             presencebroadcast[0].value = ['moderator'];
 
-            view.querySelector('.chatroom-form input[type="submit"]').click();
+            // Set image file for avatar upload
+            const avatar_picker = modal.querySelector('converse-image-picker input[type="file"]');
+            const image_file = new File([_converse.default_avatar_image], 'avatar.svg', {
+                type: _converse.default_avatar_image_type,
+                lastModified: new Date(),
+            });
+            const dataTransfer = new DataTransfer();
+            dataTransfer.items.add(image_file);
+            avatar_picker.files = dataTransfer.files;
+
+            modal.querySelector('.chatroom-form input[type="submit"]').click();
 
-            expect(sent_IQ.querySelector('field[var="muc#roomconfig_membersonly"] value').textContent.trim()).toBe('1');
-            expect(sent_IQ.querySelector('field[var="muc#roomconfig_moderatedroom"] value').textContent.trim()).toBe('1');
-            expect(sent_IQ.querySelector('field[var="muc#roomconfig_allowpm"] value').textContent.trim()).toBe('moderators');
-            expect(sent_IQ.querySelector('field[var="muc#roomconfig_presencebroadcast"] value').textContent.trim()).toBe('moderator');
+            console.log(Strophe.serialize(sent_IQ));
+
+            expect(Strophe.serialize(sent_IQ)).toBe(
+            `<iq id="${IQ_id}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                `<query xmlns="http://jabber.org/protocol/muc#owner">`+
+                `<x type="submit" xmlns="jabber:x:data">`+
+                    `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#roomconfig</value></field>`+
+                    `<field var="muc#roomconfig_roomname"><value>A Dark Cave</value></field>`+
+                    `<field var="muc#roomconfig_roomdesc"><value>The place for all good witches!</value></field>`+
+                    `<field var="muc#roomconfig_enablelogging"><value>0</value></field>`+
+                    `<field var="muc#roomconfig_changesubject"><value>0</value></field>`+
+                    `<field var="muc#roomconfig_allowinvites"><value>0</value></field>`+
+                    `<field var="muc#roomconfig_allowpm"><value>moderators</value></field>`+
+                    `<field var="muc#roomconfig_presencebroadcast"><value>moderator</value></field>`+
+                    `<field var="muc#roomconfig_getmemberlist"><value>moderator</value>,<value>participant</value>,<value>visitor</value></field>`+
+                    `<field var="muc#roomconfig_publicroom"><value>0</value></field>`+
+                    `<field var="muc#roomconfig_publicroom"><value>0</value></field>`+
+                    `<field var="muc#roomconfig_persistentroom"><value>0</value></field>`+
+                    `<field var="muc#roomconfig_moderatedroom"><value>1</value></field>`+
+                    `<field var="muc#roomconfig_membersonly"><value>1</value></field>`+
+                    `<field var="muc#roomconfig_passwordprotectedroom"><value>1</value></field>`+
+                    `<field var="muc#roomconfig_roomsecret"><value>cauldronburn</value></field>`+
+                `</x>`+
+                `</query>`+
+            `</iq>`);
         }));
 
 
@@ -1734,11 +1767,14 @@ describe("Groupchats", function () {
                  </query>
                  </iq>`);
             _converse.api.connection.get()._dataRecv(mock.createRequest(response_el));
-            await u.waitUntil(() => document.querySelector('.chatroom-form input'));
-            expect(view.querySelector('.chatroom-form legend').textContent.trim()).toBe("Configuration for room@conference.example.org");
-            sizzle('[name="muc#roomconfig_membersonly"]', view).pop().click();
-            sizzle('[name="muc#roomconfig_roomname"]', view).pop().value = "New room name"
-            view.querySelector('.chatroom-form input[type="submit"]').click();
+
+
+            modal = _converse.api.modal.get('converse-muc-config-modal');
+            await u.waitUntil(() => modal.querySelector('.chatroom-form input'));
+            expect(modal.querySelector('.chatroom-form legend').textContent.trim()).toBe("Configuration for room@conference.example.org");
+            sizzle('[name="muc#roomconfig_membersonly"]', modal).pop().click();
+            sizzle('[name="muc#roomconfig_roomname"]', modal).pop().value = "New room name"
+            modal.querySelector('.chatroom-form input[type="submit"]').click();
 
             iq = await u.waitUntil(() => IQs.filter(iq => iq.matches(`iq[to="${muc_jid}"][type="set"]`)).pop());
             const result = $iq({

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

@@ -1,22 +1,16 @@
 
 /**
+ * @typedef {import('@converse/headless/types/plugins/vcard/api').VCardData} VCardData
  * @typedef {import("@converse/headless").XMPPStatus} XMPPStatus
  */
-import Compress from 'client-compress';
+import { _converse, api, log } from "@converse/headless";
 import BaseModal from "plugins/modal/modal.js";
 import tplProfileModal from "../templates/profile_modal.js";
 import { __ } from 'i18n';
-import { _converse, api, log } from "@converse/headless";
 import '../password-reset.js';
+import { compressImage, isImageWithAlphaChannel } from 'utils/file.js';
 
 
-const compress = new Compress({
-    targetSize: 0.1,
-    quality: 0.75,
-    maxWidth: 256,
-    maxHeight: 256
-});
-
 export default class ProfileModal extends BaseModal {
 
     constructor (options) {
@@ -44,6 +38,9 @@ export default class ProfileModal extends BaseModal {
         return __('Your Profile');
     }
 
+    /**
+     * @param {VCardData} data
+     */
     async setVCard (data) {
         const bare_jid = _converse.session.get('bare_jid');
         try {
@@ -56,40 +53,43 @@ export default class ProfileModal extends BaseModal {
             ].join(" "));
             return;
         }
-        this.modal.hide();
     }
 
-    onFormSubmitted (ev) {
+    /**
+     * @param {SubmitEvent} ev
+     */
+    async onFormSubmitted (ev) {
         ev.preventDefault();
-        const reader = new FileReader();
-        const form_data = new FormData(ev.target);
-        const image_file = /** @type {File} */(form_data.get('image'));
-        const data = {
-            'fn': form_data.get('fn'),
-            'nickname': form_data.get('nickname'),
-            'role': form_data.get('role'),
-            'email': form_data.get('email'),
-            'url': form_data.get('url'),
-        };
-        if (!image_file.size) {
-            Object.assign(data, {
-                'image': this.model.vcard.get('image'),
-                'image_type': this.model.vcard.get('image_type')
-            });
-            this.setVCard(data);
+        const form_data = new FormData(/** @type {HTMLFormElement} */(ev.target));
+        const image_file = /** @type {File} */(form_data.get('avatar_image'));
+
+        const data = /** @type {VCardData} */({
+            fn: form_data.get('fn'),
+            nickname: form_data.get('nickname'),
+            role: form_data.get('role'),
+            email: form_data.get('email'),
+            url: form_data.get('url'),
+        });
+
+        if (image_file.size) {
+            const image_data = isImageWithAlphaChannel ? image_file : await compressImage(image_file);
+            const reader = new FileReader();
+            reader.onloadend = async () => {
+                Object.assign(data, {
+                    image: btoa(/** @type {string} */(reader.result)),
+                    image_type: image_file.type
+                });
+                await this.setVCard(data);
+                this.modal.hide();
+            };
+            reader.readAsBinaryString(image_data);
         } else {
-            const files = [image_file];
-            compress.compress(files).then((conversions) => {
-                const { photo, } = conversions[0];
-                reader.onloadend = () => {
-                    Object.assign(data, {
-                        'image': btoa(/** @type {string} */(reader.result)),
-                        'image_type': image_file.type
-                    });
-                    this.setVCard(data);
-                };
-                reader.readAsBinaryString(photo.data);
+            Object.assign(data, {
+                image: this.model.vcard.get('image'),
+                image_type: this.model.vcard.get('image_type')
             });
+            await this.setVCard(data);
+            this.modal.hide();
         }
     }
 }

+ 11 - 10
src/plugins/roomslist/tests/roomslist.js

@@ -251,9 +251,10 @@ describe("A groupchat shown in the groupchats list", function () {
                     .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"]'));
+        const modal = _converse.api.modal.get('converse-muc-config-modal');
+        const name_el = await u.waitUntil(() => modal.querySelector('input[name="muc#roomconfig_roomname"]'));
         name_el.value = 'New room name';
-        muc_el.querySelector('.chatroom-form input[type="submit"]').click();
+        modal.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
@@ -294,24 +295,24 @@ describe("A groupchat shown in the groupchats list", function () {
         expect(muc_initials_el.textContent).toBe(initials_el.textContent);
         expect(getComputedStyle(muc_initials_el).backgroundColor).toBe(getComputedStyle(initials_el).backgroundColor);
 
-        // eslint-disable-next-line max-len
-        const image = 'PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==';
-        const image_type = 'image/svg+xml';
-
         // Change MUC avatar and check that it reflects
         muc_el.model.vcard.set({
-            image,
-            image_type,
+            image: _converse.default_avatar_image,
+            image_type: _converse.default_avatar_image_type,
             vcard_updated: (new Date()).toISOString()
         });
 
         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}`);
+
+        const { default_avatar_image, default_avatar_image_type } = _converse;
+        expect(muc_heading_avatar.getAttribute('href'))
+            .toBe(`data:${default_avatar_image_type};base64,${default_avatar_image}`);
 
         const list_el_image = rooms_list.querySelector('converse-avatar svg image');
-        expect(list_el_image.getAttribute('href')).toBe(`data:image/svg+xml;base64,${image}`);
+        expect(list_el_image.getAttribute('href'))
+            .toBe(`data:${default_avatar_image_type};base64,${default_avatar_image}`);
     }));
 
     it("can be closed", mock.initConverse(

+ 15 - 9
src/shared/components/image-picker.js

@@ -3,7 +3,7 @@ import { __ } from 'i18n';
 import { api } from "@converse/headless";
 import { html } from 'lit';
 
-const i18n_profile_picture = __('Your profile picture');
+const i18n_profile_picture = __('Click to set a new picture');
 
 
 export default class ImagePicker extends CustomElement {
@@ -27,24 +27,30 @@ export default class ImagePicker extends CustomElement {
         return html`
             <a class="change-avatar" @click=${this.openFileSelection} title="${i18n_profile_picture}">
                 <converse-avatar
-                        .model=${this.model}
-                        class="avatar"
-                        name="${this.model.getDisplayName()}"
-                        height="${this.height}"
-                        width="${this.width}"></converse-avatar>
+                    .model=${this.model}
+                    class="avatar"
+                    name="${this.model.getDisplayName()}"
+                    height="${this.height}"
+                    nonce=${this.model.vcard?.get('vcard_updated')}
+                    width="${this.width}"></converse-avatar>
             </a>
-            <input @change=${this.updateFilePreview} class="hidden" name="image" type="file"/>
+            <input @change=${this.updateFilePreview} class="hidden" name="avatar_image" type="file"/>
         `;
     }
 
-    /** @param {Event} ev */
+    /**
+     * @param {Event} ev
+     */
     openFileSelection (ev) {
         ev.preventDefault();
         /** @type {HTMLInputElement} */(this.querySelector('input[type="file"]')).click();
     }
 
-    /** @param {InputEvent} ev */
+    /**
+     * @param {InputEvent} ev
+     */
     updateFilePreview (ev) {
+        // FIXME: this doesn't nothing currently
         const file = /** @type {HTMLInputElement} */(ev.target).files[0];
         const reader = new FileReader();
         reader.onloadend = () => {

+ 6 - 0
src/shared/tests/mock.js

@@ -38,8 +38,14 @@ function initConverse (promise_names=[], settings=null, func) {
         }
         document.title = "Converse Tests";
 
+
         await _initConverse(settings);
         await Promise.all((promise_names || []).map(_converse.api.waitUntil));
+
+        // eslint-disable-next-line max-len
+        _converse.default_avatar_image = 'PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+CiA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgZmlsbD0iIzU1NSIvPgogPGNpcmNsZSBjeD0iNjQiIGN5PSI0MSIgcj0iMjQiIGZpbGw9IiNmZmYiLz4KIDxwYXRoIGQ9Im0yOC41IDExMiB2LTEyIGMwLTEyIDEwLTI0IDI0LTI0IGgyMyBjMTQgMCAyNCAxMiAyNCAyNCB2MTIiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==';
+        _converse.default_avatar_image_type = 'image/svg+xml';
+
         try {
             await func(_converse);
         } catch(e) {

+ 4 - 1
src/types/plugins/muc-views/heading.d.ts

@@ -26,7 +26,10 @@ export default class MUCHeading extends CustomElement {
      * @param {Event} ev
      */
     toggleTopic(ev: Event): void;
-    getAndRenderConfigurationForm(): void;
+    /**
+     * @param {Event} ev
+     */
+    showConfigModal(ev: Event): void;
     /**
      * @param {Event} ev
      */

+ 17 - 0
src/types/plugins/muc-views/modals/config.d.ts

@@ -0,0 +1,17 @@
+export default class MUCConfigModal extends BaseModal {
+    renderModal(): import("lit-html").TemplateResult<1>;
+    connectedCallback(): void;
+    getModalTitle(): any;
+    getConfig(): Promise<void>;
+    /**
+     * @param {SubmitEvent} ev
+     */
+    setAvatar(ev: SubmitEvent): Promise<void>;
+    /**
+     * @param {SubmitEvent} ev
+     */
+    submitConfigForm(ev: SubmitEvent): Promise<void>;
+}
+export type VCardData = import('@converse/headless/types/plugins/vcard/api').VCardData;
+import BaseModal from "plugins/modal/modal.js";
+//# sourceMappingURL=config.d.ts.map

+ 3 - 0
src/types/plugins/muc-views/modals/templates/muc-config.d.ts

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

+ 9 - 2
src/types/plugins/profile/modals/profile.d.ts

@@ -2,9 +2,16 @@ export default class ProfileModal extends BaseModal {
     tab: string;
     renderModal(): import("lit-html").TemplateResult<1>;
     getModalTitle(): any;
-    setVCard(data: any): Promise<void>;
-    onFormSubmitted(ev: any): void;
+    /**
+     * @param {VCardData} data
+     */
+    setVCard(data: VCardData): Promise<void>;
+    /**
+     * @param {SubmitEvent} ev
+     */
+    onFormSubmitted(ev: SubmitEvent): Promise<void>;
 }
+export type VCardData = import('@converse/headless/types/plugins/vcard/api').VCardData;
 export type XMPPStatus = import("@converse/headless").XMPPStatus;
 import BaseModal from "plugins/modal/modal.js";
 //# sourceMappingURL=profile.d.ts.map

+ 6 - 2
src/types/shared/components/image-picker.d.ts

@@ -14,9 +14,13 @@ export default class ImagePicker extends CustomElement {
     width: any;
     height: any;
     render(): import("lit-html").TemplateResult<1>;
-    /** @param {Event} ev */
+    /**
+     * @param {Event} ev
+     */
     openFileSelection(ev: Event): void;
-    /** @param {InputEvent} ev */
+    /**
+     * @param {InputEvent} ev
+     */
     updateFilePreview(ev: InputEvent): void;
     data: {
         data_uri: string | ArrayBuffer;

+ 24 - 0
src/types/utils/file.d.ts

@@ -1,3 +1,21 @@
+/**
+ * Returns true if the passed in image file is a PNG image with transparency.
+ * @param {File} image_file
+ * @returns {Promise<boolean>}
+ */
+export function isImageWithAlphaChannel(image_file: File): Promise<boolean>;
+/**
+ * @typedef {Object} CompressionOptions
+ * @property {number} targetSize
+ * @property {number} quality
+ * @property {number} maxWidth
+ * @property {number} maxHeight
+ *
+ * @param {File} file
+ * @param {CompressionOptions} options
+ * @returns {Promise<Blob>}
+ */
+export function compressImage(file: File, options?: CompressionOptions): Promise<Blob>;
 export const MIMETYPES_MAP: {
     aac: string;
     abw: string;
@@ -77,4 +95,10 @@ export const MIMETYPES_MAP: {
     '3g2': string;
     '7z': string;
 };
+export type CompressionOptions = {
+    targetSize: number;
+    quality: number;
+    maxWidth: number;
+    maxHeight: number;
+};
 //# sourceMappingURL=file.d.ts.map

+ 133 - 77
src/utils/file.js

@@ -1,79 +1,135 @@
+import Compress from 'client-compress';
+
 export const MIMETYPES_MAP = {
-  'aac': 'audio/aac',
-  'abw': 'application/x-abiword',
-  'arc': 'application/x-freearc',
-  'avi': 'video/x-msvideo',
-  'azw': 'application/vnd.amazon.ebook',
-  'bin': 'application/octet-stream',
-  'bmp': 'image/bmp',
-  'bz': 'application/x-bzip',
-  'bz2': 'application/x-bzip2',
-  'cda': 'application/x-cdf',
-  'csh': 'application/x-csh',
-  'css': 'text/css',
-  'csv': 'text/csv',
-  'doc': 'application/msword',
-  'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-  'eot': 'application/vnd.ms-fontobject',
-  'epub': 'application/epub+zip',
-  'gif': 'image/gif',
-  'gz': 'application/gzip',
-  'htm': 'text/html',
-  'html': 'text/html',
-  'ico': 'image/vnd.microsoft.icon',
-  'ics': 'text/calendar',
-  'jar': 'application/java-archive',
-  'jpeg': 'image/jpeg',
-  'jpg': 'image/jpeg',
-  'js': 'text/javascript',
-  'json': 'application/json',
-  'jsonld': 'application/ld+json',
-  'm4a': 'audio/mp4',
-  'mid': 'audio/midi',
-  'midi': 'audio/midi',
-  'mjs': 'text/javascript',
-  'mp3': 'audio/mpeg',
-  'mp4': 'video/mp4',
-  'mpeg': 'video/mpeg',
-  'mpkg': 'application/vnd.apple.installer+xml',
-  'odp': 'application/vnd.oasis.opendocument.presentation',
-  'ods': 'application/vnd.oasis.opendocument.spreadsheet',
-  'odt': 'application/vnd.oasis.opendocument.text',
-  'oga': 'audio/ogg',
-  'ogv': 'video/ogg',
-  'ogx': 'application/ogg',
-  'opus': 'audio/opus',
-  'otf': 'font/otf',
-  'png': 'image/png',
-  'pdf': 'application/pdf',
-  'php': 'application/x-httpd-php',
-  'ppt': 'application/vnd.ms-powerpoint',
-  'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
-  'rar': 'application/vnd.rar',
-  'rtf': 'application/rtf',
-  'sh': 'application/x-sh',
-  'svg': 'image/svg+xml',
-  'swf': 'application/x-shockwave-flash',
-  'tar': 'application/x-tar',
-  'tif': 'image/tiff',
-  'tiff': 'image/tiff',
-  'ts': 'video/mp2t',
-  'ttf': 'font/ttf',
-  'txt': 'text/plain',
-  'vsd': 'application/vnd.visio',
-  'wav': 'audio/wav',
-  'weba': 'audio/webm',
-  'webm': 'video/webm',
-  'webp': 'image/webp',
-  'woff': 'font/woff',
-  'woff2': 'font/woff2',
-  'xhtml': 'application/xhtml+xml',
-  'xls': 'application/vnd.ms-excel',
-  'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-  'xml': 'text/xml',
-  'xul': 'application/vnd.mozilla.xul+xml',
-  'zip': 'application/zip',
-  '3gp': 'video/3gpp',
-  '3g2': 'video/3gpp2',
-  '7z': 'application/x-7z-compressed'
+    'aac': 'audio/aac',
+    'abw': 'application/x-abiword',
+    'arc': 'application/x-freearc',
+    'avi': 'video/x-msvideo',
+    'azw': 'application/vnd.amazon.ebook',
+    'bin': 'application/octet-stream',
+    'bmp': 'image/bmp',
+    'bz': 'application/x-bzip',
+    'bz2': 'application/x-bzip2',
+    'cda': 'application/x-cdf',
+    'csh': 'application/x-csh',
+    'css': 'text/css',
+    'csv': 'text/csv',
+    'doc': 'application/msword',
+    'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    'eot': 'application/vnd.ms-fontobject',
+    'epub': 'application/epub+zip',
+    'gif': 'image/gif',
+    'gz': 'application/gzip',
+    'htm': 'text/html',
+    'html': 'text/html',
+    'ico': 'image/vnd.microsoft.icon',
+    'ics': 'text/calendar',
+    'jar': 'application/java-archive',
+    'jpeg': 'image/jpeg',
+    'jpg': 'image/jpeg',
+    'js': 'text/javascript',
+    'json': 'application/json',
+    'jsonld': 'application/ld+json',
+    'm4a': 'audio/mp4',
+    'mid': 'audio/midi',
+    'midi': 'audio/midi',
+    'mjs': 'text/javascript',
+    'mp3': 'audio/mpeg',
+    'mp4': 'video/mp4',
+    'mpeg': 'video/mpeg',
+    'mpkg': 'application/vnd.apple.installer+xml',
+    'odp': 'application/vnd.oasis.opendocument.presentation',
+    'ods': 'application/vnd.oasis.opendocument.spreadsheet',
+    'odt': 'application/vnd.oasis.opendocument.text',
+    'oga': 'audio/ogg',
+    'ogv': 'video/ogg',
+    'ogx': 'application/ogg',
+    'opus': 'audio/opus',
+    'otf': 'font/otf',
+    'png': 'image/png',
+    'pdf': 'application/pdf',
+    'php': 'application/x-httpd-php',
+    'ppt': 'application/vnd.ms-powerpoint',
+    'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+    'rar': 'application/vnd.rar',
+    'rtf': 'application/rtf',
+    'sh': 'application/x-sh',
+    'svg': 'image/svg+xml',
+    'swf': 'application/x-shockwave-flash',
+    'tar': 'application/x-tar',
+    'tif': 'image/tiff',
+    'tiff': 'image/tiff',
+    'ts': 'video/mp2t',
+    'ttf': 'font/ttf',
+    'txt': 'text/plain',
+    'vsd': 'application/vnd.visio',
+    'wav': 'audio/wav',
+    'weba': 'audio/webm',
+    'webm': 'video/webm',
+    'webp': 'image/webp',
+    'woff': 'font/woff',
+    'woff2': 'font/woff2',
+    'xhtml': 'application/xhtml+xml',
+    'xls': 'application/vnd.ms-excel',
+    'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    'xml': 'text/xml',
+    'xul': 'application/vnd.mozilla.xul+xml',
+    'zip': 'application/zip',
+    '3gp': 'video/3gpp',
+    '3g2': 'video/3gpp2',
+    '7z': 'application/x-7z-compressed',
+};
+
+/**
+ * Returns true if the passed in image file is a PNG image with transparency.
+ * @param {File} image_file
+ * @returns {Promise<boolean>}
+ */
+export async function isImageWithAlphaChannel(image_file) {
+    if (image_file.type === MIMETYPES_MAP['png']) {
+        const buff_reader = new FileReader();
+        return await new Promise((resolve) => {
+            buff_reader.onloadend = (e) => {
+                const view = new DataView(/** @type {ArrayBuffer} */ (e.target.result));
+                // Check for alpha channel in PNG
+                if (view.getUint32(0) === 0x89504e47 && view.getUint32(4) === 0x0d0a1a0a) {
+                    // https://www.w3.org/TR/png/#11IHDR
+                    // The chunk exists at offset 8 +8 bytes (size, name) +8 (depth) & +9 (type)
+                    const type = view.getUint8(8 + 8 + 9);
+                    const greyscale_with_alpha = 4;
+                    const color_with_alpha = 6;
+                    resolve(type === greyscale_with_alpha || type === color_with_alpha);
+                }
+                resolve(false);
+            };
+            buff_reader.readAsArrayBuffer(image_file);
+        });
+    }
+    return false;
+}
+
+/**
+ * @typedef {Object} CompressionOptions
+ * @property {number} targetSize
+ * @property {number} quality
+ * @property {number} maxWidth
+ * @property {number} maxHeight
+ *
+ * @param {File} file
+ * @param {CompressionOptions} options
+ * @returns {Promise<Blob>}
+ */
+export async function compressImage(
+    file,
+    options = {
+        targetSize: 0.1,
+        quality: 0.75,
+        maxWidth: 256,
+        maxHeight: 256,
+    }
+) {
+    const compress = new Compress(options);
+    const conversions = await compress.compress([file]);
+    const { photo } = conversions[0];
+    return photo.data;
 }