Prechádzať zdrojové kódy

Allow clearing of avatar image

JC Brand 2 mesiacov pred
rodič
commit
58c175d2da

+ 4 - 0
src/headless/plugins/disco/api.js

@@ -328,6 +328,10 @@ export default {
                 if (!jid) throw new TypeError('api.disco.feature.has: You need to provide an entity JID');
 
                 const entity = await api.disco.entities.get(jid, true);
+                if (!entity) {
+                    log.warn(`api.disco.has: could not get entity for ${jid}`);
+                    return false;
+                }
 
                 if (_converse.state.disco_entities === undefined && !api.connection.connected()) {
                     // Happens during tests when disco lookups happen asynchronously after teardown.

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

@@ -36,7 +36,7 @@ export async function onVCardData(iq) {
         vcard_error: undefined,
     };
     if (result.image) {
-        const buffer = u.base64ToArrayBuffer(result["image"]);
+        const buffer = u.base64ToArrayBuffer(result.image);
         const ab = await crypto.subtle.digest("SHA-1", buffer);
         result["image_hash"] = u.arrayBufferToHex(ab);
     }

+ 16 - 5
src/plugins/profile/modals/profile.js

@@ -14,6 +14,10 @@ export default class ProfileModal extends BaseModal {
      * @typedef {import("@converse/headless").Profile} Profile
      */
 
+    static properties = {
+        _submitting: { state: true }
+    }
+
     /**
      * @param {Object} options
      */
@@ -55,8 +59,9 @@ export default class ProfileModal extends BaseModal {
                 __("Sorry, an error happened while trying to save your profile data."),
                 __("You can check your browser's developer console for any error output.")
             ].join(" "));
-            return;
+            return false;
         }
+        return true;
     }
 
     /**
@@ -64,6 +69,8 @@ export default class ProfileModal extends BaseModal {
      */
     async onFormSubmitted (ev) {
         ev.preventDefault();
+        this._submitting = true;
+
         const form_data = new FormData(/** @type {HTMLFormElement} */(ev.target));
         const image_file = /** @type {File} */(form_data.get('avatar_image'));
 
@@ -83,8 +90,9 @@ export default class ProfileModal extends BaseModal {
                     image: btoa(/** @type {string} */(reader.result)),
                     image_type: image_file.type
                 });
-                await this.setVCard(data);
-                this.modal.hide();
+                if (await this.setVCard(data)) {
+                    this.modal.hide();
+                }
             };
             reader.readAsBinaryString(image_data);
         } else {
@@ -92,9 +100,12 @@ export default class ProfileModal extends BaseModal {
                 image: this.model.vcard.get('image'),
                 image_type: this.model.vcard.get('image_type')
             });
-            await this.setVCard(data);
-            this.modal.hide();
+            if (await this.setVCard(data)) {
+                this.modal.hide();
+                api.toast.show('vcard-updated', { body: __("Profile updated successfully") });
+            }
         }
+        this._submitting = false;
     }
 }
 

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

@@ -141,7 +141,10 @@ export default (el) => {
                     </div>
                     <hr/>
                     <div>
-                        <button type="submit" class="save-form btn btn-primary">${i18n_save}</button>
+                        ${el._submitting ?
+                            html`<converse-spinner></converse-spinner>` :
+                            html`<button type="submit" class="save-form btn btn-primary">${i18n_save}</button>`
+                        }
                     </div>
                 </form>
             </div>

+ 10 - 5
src/shared/avatar/avatar.js

@@ -29,11 +29,16 @@ export default class Avatar extends CustomElement {
     }
 
     render() {
-        const { image_type, image, data_uri } = Object.assign(
-            {},
-            this.pickerdata?.attributes,
-            this.model?.vcard?.attributes
-        );
+        let image_type;
+        let image;
+        let data_uri;
+        if (this.pickerdata) {
+            image_type = this.pickerdata.image_type;
+            data_uri = this.pickerdata.data_uri;
+        } else {
+            image_type = this.model?.vcard?.get('image_type');
+            image = this.model?.vcard?.get('image');
+        }
 
         if (image_type && (image || data_uri)) {
             return tplAvatar({

+ 52 - 29
src/shared/components/image-picker.js

@@ -1,69 +1,92 @@
 import { html } from 'lit';
-import { Model } from '@converse/skeletor';
-import { api } from "@converse/headless";
+import { api } from '@converse/headless';
 import { CustomElement } from './element.js';
 import { __ } from 'i18n';
+import './styles/image-picker.scss';
 
 const i18n_profile_picture = __('Click to set a new picture');
-
+const i18n_clear_picture = __('Clear picture');
 
 export default class ImagePicker extends CustomElement {
-
-    constructor () {
+    constructor() {
         super();
         this.model = null;
         this.width = null;
         this.height = null;
-        this.data = new Model();
         this.nonce = null;
     }
 
-    static get properties () {
+    static get properties() {
         return {
             height: { type: Number },
             model: { type: Object },
             width: { type: Number },
-        }
+        };
     }
 
-    render () {
+    render() {
         return html`
-            <a class="change-avatar" @click=${this.openFileSelection} title="${i18n_profile_picture}">
-                <converse-avatar
-                    .model=${this.model}
-                    .pickerdata=${this.data}
-                    class="avatar"
-                    name="${this.model.getDisplayName()}"
-                    height="${this.height}"
-                    nonce=${this.nonce || this.model.vcard?.get('vcard_updated')}
-                    width="${this.width}"></converse-avatar>
-            </a>
-            <input @change=${this.updateFilePreview} class="hidden" name="avatar_image" type="file"/>
+            <div class="image-picker">
+                <a class="change-avatar" @click=${this.openFileSelection} title="${i18n_profile_picture}">
+                    <converse-avatar
+                        .model=${this.model}
+                        .pickerdata=${this.data}
+                        class="avatar"
+                        name="${this.model.getDisplayName()}"
+                        height="${this.height}"
+                        nonce="${this.nonce || this.model.vcard?.get('vcard_updated')}"
+                        width="${this.width}"
+                    ></converse-avatar>
+                </a>
+                ${this.data?.data_uri || this.model?.vcard?.get('image')
+                    ? html`<button class="clear-image" @click=${this.clearImage} title="${i18n_clear_picture}">
+                          <converse-icon class="fa fa-trash-alt" size="1.5em"></converse-icon>
+                      </button>`
+                    : ''}
+                <input @change=${this.updateFilePreview} class="hidden" name="avatar_image" type="file" />
+            </div>
         `;
     }
 
+    /**
+     * Clears the selected image.
+     * @param {Event} ev
+     */
+    clearImage(ev) {
+        ev.preventDefault();
+        const input = /** @type {HTMLInputElement} */(this.querySelector('input[name="avatar_image'));
+        input.value = '';
+        this.model.vcard.set({
+            image: null,
+            image_type: null,
+        });
+        this.data = { data_uri: null, image_type: null };
+        this.nonce = new Date().toISOString(); // Update nonce to trigger re-render
+        this.requestUpdate();
+    }
+
     /**
      * @param {Event} ev
      */
-    openFileSelection (ev) {
+    openFileSelection(ev) {
         ev.preventDefault();
-        /** @type {HTMLInputElement} */(this.querySelector('input[type="file"]')).click();
+        /** @type {HTMLInputElement} */ (this.querySelector('input[type="file"]')).click();
     }
 
     /**
      * @param {InputEvent} ev
      */
-    updateFilePreview (ev) {
-        const file = /** @type {HTMLInputElement} */(ev.target).files[0];
+    updateFilePreview(ev) {
+        const file = /** @type {HTMLInputElement} */ (ev.target).files[0];
         const reader = new FileReader();
         reader.onloadend = () => {
-            this.data.set({
-                'data_uri': reader.result,
-                'image_type': file.type
-            });
+            this.data = {
+                data_uri: reader.result,
+                image_type: file.type,
+            };
             this.nonce = new Date().toISOString();
             this.requestUpdate();
-        }
+        };
         reader.readAsDataURL(file);
     }
 }

+ 18 - 0
src/shared/components/styles/image-picker.scss

@@ -0,0 +1,18 @@
+converse-image-picker {
+    .image-picker {
+        position: relative;
+        display: inline-block;
+    }
+
+    .clear-image {
+        background: rgba(255, 255, 255, 0.7); /* Semi-transparent background */
+        border-radius: 50%;
+        border: none;
+        cursor: pointer;
+        padding: 5px;
+        position: absolute;
+        right: 5px;
+        top: 5px;
+        z-index: 10;
+    }
+}

+ 1 - 1
src/shared/modals/tests/user-details-modal.js

@@ -108,7 +108,7 @@ describe("The User Details Modal", function () {
         expect(header.textContent).toBe("Error");
         expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim())
             .toBe("Sorry, an error occurred while trying to remove Mercutio as a contact");
-        document.querySelector('.alert-danger .btn-close').click();
+        document.querySelector('.alert-danger .btn[aria-label="Close"]').click();
 
         show_modal_button = view.querySelector('.show-msg-author-modal');
         show_modal_button.click();