浏览代码

Add support for toast messages

JC Brand 1 周之前
父节点
当前提交
173ceefba6

+ 67 - 32
src/plugins/modal/api.js

@@ -1,10 +1,13 @@
 import './alert.js';
 import Confirm from './confirm.js';
 import { Model } from '@converse/skeletor';
+import { api } from '@converse/headless';
 
 let modals = [];
 let modals_map = {};
 
+let toasts_map = {};
+
 const modal_api = {
     /**
      * API namespace for methods relating to modals
@@ -20,7 +23,7 @@ const modal_api = {
          * @param {Object} [properties] - Optional properties that will be set on a newly created modal instance.
          * @param {Event} [ev] - The DOM event that causes the modal to be shown.
          */
-        show (name, properties, ev) {
+        show(name, properties, ev) {
             let modal;
             if (typeof name === 'string') {
                 modal = this.get(name) ?? this.create(name, properties);
@@ -37,10 +40,10 @@ const modal_api = {
 
         /**
          * Return a modal with the passed-in identifier, if it exists.
-         * @param { String } id
+         * @param {String} id
          */
-        get (id) {
-            return modals_map[id] ?? modals.filter(m => m.id == id).pop();
+        get(id) {
+            return modals_map[id] ?? modals.filter((m) => m.id == id).pop();
         },
 
         /**
@@ -49,17 +52,17 @@ const modal_api = {
          * @param {Object} [properties] - Optional properties that will be
          *  set on the modal instance.
          */
-        create (name, properties) {
+        create(name, properties) {
             const ModalClass = customElements.get(name);
-            const modal = modals_map[name] = new ModalClass(properties);
+            const modal = (modals_map[name] = new ModalClass(properties));
             return modal;
         },
 
         /**
          * Remove a particular modal
-         * @param { String } name
+         * @param {String} name
          */
-        remove (name) {
+        remove(name) {
             let modal;
             if (typeof name === 'string') {
                 modal = modals_map[name];
@@ -67,7 +70,7 @@ const modal_api = {
             } else {
                 // Legacy...
                 modal = name;
-                modals = modals.filter(m => m !== modal);
+                modals = modals.filter((m) => m !== modal);
             }
             modal?.remove();
         },
@@ -75,11 +78,11 @@ const modal_api = {
         /**
          * Remove all modals
          */
-        removeAll () {
-            modals.forEach(m => m.remove());
+        removeAll() {
+            modals.forEach((m) => m.remove());
             modals = [];
             modals_map = {};
-        }
+        },
     },
 
     /**
@@ -91,12 +94,12 @@ const modal_api = {
      * @returns {Promise<Array|false>} A promise which resolves with an array of
      *  filled in fields or `false` if the confirm dialog was closed or canceled.
      */
-    async confirm (title, messages=[], fields=[]) {
+    async confirm(title, messages = [], fields = []) {
         if (typeof messages === 'string') {
             messages = [messages];
         }
-        const model = new Model({title, messages, fields, 'type': 'confirm'})
-        const confirm = new Confirm({model});
+        const model = new Model({ title, messages, fields, 'type': 'confirm' });
+        const confirm = new Confirm({ model });
         confirm.show();
         let result;
         try {
@@ -117,20 +120,22 @@ const modal_api = {
      * @returns { Promise<String|false> } A promise which resolves with the text provided by the
      *  user or `false` if the user canceled the prompt.
      */
-    async prompt (title, messages=[], placeholder='') {
+    async prompt(title, messages = [], placeholder = '') {
         if (typeof messages === 'string') {
             messages = [messages];
         }
         const model = new Model({
             title,
             messages,
-            'fields': [{
-                'name': 'reason',
-                'placeholder': placeholder,
-            }],
-            'type': 'prompt'
-        })
-        const prompt = new Confirm({model});
+            fields: [
+                {
+                    'name': 'reason',
+                    'placeholder': placeholder,
+                },
+            ],
+            type: 'prompt',
+        });
+        const prompt = new Confirm({ model });
         prompt.show();
         let result;
         try {
@@ -149,7 +154,7 @@ const modal_api = {
      * @param { String } title - The header text for the alert.
      * @param { (Array<String>|String) } messages - The alert text to show to the user.
      */
-    alert (type, title, messages) {
+    alert(type, title, messages) {
         if (typeof messages === 'string') {
             messages = [messages];
         }
@@ -162,14 +167,44 @@ const modal_api = {
             level = 'alert-warning';
         }
 
-        const model = new Model({
-            'title': title,
-            'messages': messages,
-            'level': level,
-            'type': 'alert'
-        })
+        const model = new Model({ title, messages, level, 'type': 'alert' });
         modal_api.modal.show('converse-alert-modal', { model });
-    }
-}
+    },
+
+    /**
+     * API namespace for methods relating to toast messages
+     * @namespace _converse.api.toast
+     * @memberOf _converse.api
+     */
+    toast: {
+        /**
+         * @param {string} name
+         * @param {import('./types').ToastProperties} [properties] - Optional properties that will be set on a newly created toast instance.
+         */
+        show(name, properties) {
+            toasts_map[name] = properties;
+            api.trigger('showToast', properties);
+        },
+
+        /**
+         * @param {String} [name]
+         */
+        get(name) {
+            if (name) {
+                return toasts_map[name];
+            } else {
+                return Object.keys(toasts_map).map((name) => ({ name, ...toasts_map[name] }));
+            }
+        },
+
+        /**
+         * @param {String} [name]
+         */
+        remove(name) {
+            delete toasts_map[name];
+            api.trigger('hideToast');
+        },
+    },
+};
 
 export default modal_api;

+ 3 - 1
src/plugins/modal/index.js

@@ -6,7 +6,9 @@ import { _converse, api, converse } from "@converse/headless";
 import modal_api from "./api.js";
 import BaseModal from "./modal.js";
 import Popover from "./popover.js";
+import Toast from './toast.js';
 import './modals.js';
+import './toasts.js';
 
 converse.plugins.add("converse-modal", {
     initialize() {
@@ -19,7 +21,7 @@ converse.plugins.add("converse-modal", {
 
         api.listen.on("clearSession", () => api.modal.removeAll());
 
-        Object.assign(_converse.exports, { BaseModal, Popover });
+        Object.assign(_converse.exports, { BaseModal, Popover, Toast });
         Object.assign(_converse.api, modal_api);
     },
 });

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

@@ -4,7 +4,7 @@ import { CustomElement } from 'shared/components/element.js';
 
 export class ModalsContainer extends CustomElement {
     render() {
-        return html` <converse-about-modal></converse-about-modal> `;
+        return html`<converse-about-modal></converse-about-modal>`;
     }
 }
 

+ 22 - 0
src/plugins/modal/styles/toast.scss

@@ -0,0 +1,22 @@
+.conversejs {
+    converse-toasts {
+        converse-toast {
+            .toast {
+                position: relative;
+                margin-block-start: 1em;
+                margin-inline-end: 1em;
+                float: right;
+                .toast-header {
+                    img {
+                        height: 1.5em;
+                    }
+                }
+                .toast-body__container {
+                    .btn-close {
+                        margin-inline-end: 1em;
+                    }
+                }
+            }
+        }
+    }
+}

+ 67 - 0
src/plugins/modal/toast.js

@@ -0,0 +1,67 @@
+import { html } from 'lit';
+import { api } from '@converse/headless';
+import { __ } from 'i18n';
+import { CustomElement } from 'shared/components/element.js';
+import './styles/toast.scss';
+
+export default class Toast extends CustomElement {
+    static get properties() {
+        return {
+            name: { type: String },
+            title: { type: String },
+            body: { type: String },
+        };
+    }
+
+    constructor() {
+        super();
+        this.name = '';
+        this.body = '';
+        this.header = '';
+    }
+
+    initialize() {
+        super.initialize();
+        setTimeout(() => this.hide(), 5000);
+    }
+
+    render() {
+        return html`<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
+            ${this.title
+                ? html` <div class="toast-header">
+                      <img src="/logo/conversejs-filled.svg" class="rounded me-2" alt="${__('Converse logo')}" />
+                      <strong class="me-auto">${this.title ?? ''}</strong>
+                      <button
+                          @click="${this.hide}"
+                          type="button"
+                          class="btn-close"
+                          aria-label="${__('Close')}"
+                      ></button>
+                  </div>`
+                : ''}
+            ${this.body
+                ? html`<div class="d-flex justify-content-between toast-body__container">
+                      <div class="toast-body w-100">${this.body ?? ''}</div>
+                      ${!this.title
+                          ? html`<button
+                                @click="${this.hide}"
+                                type="button"
+                                class="btn-close centered"
+                                aria-label="${__('Close')}"
+                            ></button>`
+                          : ''}
+                  </div>`
+                : ''}
+        </div>`;
+    }
+
+    /**
+     * @param {MouseEvent} [ev]
+     */
+    hide(ev) {
+        ev?.preventDefault();
+        api.toast.remove(this.name);
+    }
+}
+
+api.elements.define('converse-toast', Toast);

+ 28 - 0
src/plugins/modal/toasts.js

@@ -0,0 +1,28 @@
+import { html } from 'lit';
+import { api } from '@converse/headless';
+import { CustomElement } from 'shared/components/element.js';
+
+export class ToastsContainer extends CustomElement {
+    initialize() {
+        super.initialize();
+        api.listen.on('showToast', () => this.requestUpdate());
+        api.listen.on('hideToast', () => this.requestUpdate());
+    }
+
+    render() {
+        const toasts = api.toast.get();
+        return html`${toasts.map(
+            /** @param {import('./types').ToastProperties} toast */
+            (toast) =>
+                html`<converse-toast
+                    name="${toast.name}"
+                    title="${toast.title ?? ''}"
+                    body="${toast.body ?? ''}"
+                ></converse-toast>`
+        )}`;
+    }
+}
+
+api.elements.define('converse-toasts', ToastsContainer);
+
+export default ToastsContainer;

+ 6 - 0
src/plugins/modal/types.ts

@@ -8,3 +8,9 @@ export type Field = {
     required?: boolean; // Whether the field is required or not
     value?: string;
 }
+
+export type ToastProperties = {
+    title?: string;
+    body?: string;
+    name: string;
+}

+ 1 - 0
src/plugins/rootview/templates/root.js

@@ -9,6 +9,7 @@ export default () => {
         ${api.settings.get('show_background') ? html`<converse-bg logo></converse-bg>` : ''}
         <converse-chats class="converse-chatboxes row justify-content-start g-0 ${extra_classes.join(' ')}"></converse-chats>
         <converse-modals id="converse-modals" class="modals"></converse-modals>
+        <converse-toasts></converse-toasts>
         <converse-fontawesome></converse-fontawesome>
     `;
 };

+ 1 - 0
src/plugins/rosterview/modals/add-contact.js

@@ -58,6 +58,7 @@ export default class AddContactModal extends BaseModal {
         api.chats.open(jid, {}, true);
         form.reset();
         this.model.clear();
+        api.toast.show('contact-added', { body: __("Contact added successfully") });
         this.modal.hide();
     }
 

+ 1 - 1
src/shared/styles/index.scss

@@ -41,7 +41,7 @@ $prefix: 'converse-';
     @import "bootstrap/scss/progress";
     @import "bootstrap/scss/list-group";
     @import "bootstrap/scss/close";
-    // @import "bootstrap/scss/toasts";
+    @import "bootstrap/scss/toasts";
     @import "bootstrap/scss/modal";
     @import "bootstrap/scss/tooltip";
     @import "bootstrap/scss/popover";

+ 17 - 2
src/types/plugins/modal/api.d.ts

@@ -12,7 +12,7 @@ declare namespace modal_api {
         function show(name: string | any, properties?: any, ev?: Event): any;
         /**
          * Return a modal with the passed-in identifier, if it exists.
-         * @param { String } id
+         * @param {String} id
          */
         function get(id: string): any;
         /**
@@ -24,7 +24,7 @@ declare namespace modal_api {
         function create(name: string, properties?: any): HTMLElement;
         /**
          * Remove a particular modal
-         * @param { String } name
+         * @param {String} name
          */
         function remove(name: string): void;
         /**
@@ -60,5 +60,20 @@ declare namespace modal_api {
      * @param { (Array<String>|String) } messages - The alert text to show to the user.
      */
     function alert(type: ("info" | "warn" | "error"), title: string, messages: (Array<string> | string)): void;
+    namespace toast {
+        /**
+         * @param {string} name
+         * @param {import('./types').ToastProperties} [properties] - Optional properties that will be set on a newly created toast instance.
+         */
+        function show(name: string, properties?: import("./types").ToastProperties): void;
+        /**
+         * @param {String} [name]
+         */
+        function get(name?: string): any;
+        /**
+         * @param {String} [name]
+         */
+        function remove(name?: string): void;
+    }
 }
 //# sourceMappingURL=api.d.ts.map

+ 1 - 1
src/types/plugins/modal/modal.d.ts

@@ -53,6 +53,6 @@ declare class BaseModal extends CustomElement {
     #private;
 }
 import { CustomElement } from 'shared/components/element.js';
-import Modal from "bootstrap/js/src/modal.js";
+import Modal from 'bootstrap/js/src/modal.js';
 import { Model } from '@converse/skeletor';
 //# sourceMappingURL=modal.d.ts.map

+ 24 - 0
src/types/plugins/modal/toast.d.ts

@@ -0,0 +1,24 @@
+export default class Toast extends CustomElement {
+    static get properties(): {
+        name: {
+            type: StringConstructor;
+        };
+        title: {
+            type: StringConstructor;
+        };
+        body: {
+            type: StringConstructor;
+        };
+    };
+    name: string;
+    body: string;
+    header: string;
+    initialize(): void;
+    render(): import("lit-html").TemplateResult<1>;
+    /**
+     * @param {MouseEvent} [ev]
+     */
+    hide(ev?: MouseEvent): void;
+}
+import { CustomElement } from 'shared/components/element.js';
+//# sourceMappingURL=toast.d.ts.map

+ 7 - 0
src/types/plugins/modal/toasts.d.ts

@@ -0,0 +1,7 @@
+export class ToastsContainer extends CustomElement {
+    initialize(): void;
+    render(): import("lit-html").TemplateResult<1>;
+}
+export default ToastsContainer;
+import { CustomElement } from 'shared/components/element.js';
+//# sourceMappingURL=toasts.d.ts.map

+ 5 - 0
src/types/plugins/modal/types.d.ts

@@ -8,4 +8,9 @@ export type Field = {
     required?: boolean;
     value?: string;
 };
+export type ToastProperties = {
+    title?: string;
+    body?: string;
+    name: string;
+};
 //# sourceMappingURL=types.d.ts.map