Sfoglia il codice sorgente

Allow the user to choose one of multiple pubsub services

- Use bootstrap's collapse feature to show/hide entity features
JC Brand 4 settimane fa
parent
commit
9db2890f18

+ 1 - 1
src/headless/index.js

@@ -30,7 +30,7 @@ import './plugins/bosh/index.js'; // XEP-0206 BOSH
 import './plugins/caps/index.js'; // XEP-0115 Entity Capabilities
 export { ChatBox, Message, Messages } from './plugins/chat/index.js'; // RFC-6121 Instant messaging
 import './plugins/chatboxes/index.js';
-import './plugins/disco/index.js'; // XEP-0030 Service discovery
+export { DiscoEntity, DiscoEntities } from './plugins/disco/index.js'; // XEP-0030 Service discovery
 import './plugins/adhoc/index.js'; // XEP-0050 Ad Hoc Commands
 import './plugins/headlines/index.js'; // Support for headline messages
 export { Device, Devices, DeviceList, DeviceLists } from './plugins/omemo/index.js'; // Support for headline messages

+ 2 - 0
src/headless/plugins/disco/index.js

@@ -19,6 +19,8 @@ import {
 
 const { Strophe } = converse.env;
 
+export { DiscoEntity, DiscoEntities };
+
 /**
  * @typedef {Object} DiscoState
  * @property {Array} _identities

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

@@ -21,6 +21,7 @@ export { Collection, EventEmitter, Model } from "@converse/skeletor";
 export { Builder, Stanza } from "strophe.js";
 export { Bookmark, Bookmarks } from "./plugins/bookmarks/index.js";
 export { ChatBox, Message, Messages } from "./plugins/chat/index.js";
+export { DiscoEntity, DiscoEntities } from "./plugins/disco/index.js";
 export { Device, Devices, DeviceList, DeviceLists } from "./plugins/omemo/index.js";
 export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants } from "./plugins/muc/index.js";
 export { RosterContact, RosterContacts, RosterFilter, Presence, Presences } from "./plugins/roster/index.js";

+ 3 - 0
src/headless/types/plugins/disco/index.d.ts

@@ -2,4 +2,7 @@ export type DiscoState = {
     _identities: any[];
     _features: any[];
 };
+import DiscoEntity from './entity.js';
+import DiscoEntities from './entities.js';
+export { DiscoEntity, DiscoEntities };
 //# sourceMappingURL=index.d.ts.map

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

@@ -38,10 +38,10 @@ declare namespace _default {
          * Creates a PubSub node at a given service
          * @param {string} jid - The PubSub service JID
          * @param {string} node - The node to create
-         * @param {PubSubConfigOptions} config The configuration options
+         * @param {PubSubConfigOptions} [config] The configuration options
          * @returns {Promise<void>}
          */
-        function create(jid: string, node: string, config: import("./types").PubSubConfigOptions): Promise<void>;
+        function create(jid: string, node: string, config?: import("./types").PubSubConfigOptions): Promise<void>;
         /**
          * Subscribes the local user to a PubSub node.
          *

+ 1 - 1
src/headless/types/shared/_converse.d.ts

@@ -79,7 +79,7 @@ export class ConversePrivateGlobal extends ConversePrivateGlobal_base {
      */
     state: any;
     initSession(): void;
-    session: Model;
+    session: any;
     /**
      * Translate the given string based on the current locale.
      * @method __

+ 5 - 6
src/headless/types/shared/connection/index.d.ts

@@ -5,7 +5,11 @@ declare const Connection_base: typeof import("strophe.js/src/types/connection").
  * via BOSH or websocket inside a shared worker).
  */
 export class Connection extends Connection_base {
-    constructor(service: any, options: any);
+    /**
+     * @param {string} service - The BOSH or WebSocket service URL.
+     * @param {import('strophe.js/src/types/connection').ConnectionOptions} options
+     */
+    constructor(service: string, options: import("strophe.js/src/types/connection").ConnectionOptions);
     send_initial_presence: boolean;
     debouncedReconnect: import("lodash").DebouncedFunc<() => Promise<any>>;
     /** @param {Element} body */
@@ -111,11 +115,6 @@ export class Connection extends Connection_base {
  * The MockConnection class is used during testing, to mock an XMPP connection.
  */
 export class MockConnection extends Connection {
-    /**
-     * @param {string} service - The BOSH or WebSocket service URL.
-     * @param {import('strophe.js/src/types/connection').ConnectionOptions} options - The configuration options
-     */
-    constructor(service: string, options: import("strophe.js/src/types/connection").ConnectionOptions);
     sent_stanzas: any[];
     IQ_stanzas: any[];
     IQ_ids: any[];

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

@@ -1,6 +1,11 @@
 .conversejs {
-
     #chatrooms {
+        .collapsed {
+            height: 0 !important;
+            overflow: hidden !important;
+            padding: 0 !important;
+        }
+
         .muc-domain-group-toggle {
             margin: 0.75em 0 0.25em 0;
         }

+ 6 - 1
src/plugins/rosterview/styles/roster.scss

@@ -1,6 +1,11 @@
 .conversejs {
-
     #controlbox {
+        .collapsed {
+            height: 0 !important;
+            overflow: hidden !important;
+            padding: 0 !important;
+        }
+
         .open-contacts-toggle, .open-contacts-toggle .fa {
             color: var(--chat-color) !important;
             &:hover {

+ 37 - 0
src/plugins/todo/PLAN.md

@@ -0,0 +1,37 @@
+# TODO Plugin Implementation Plan
+
+This document outlines the step-by-step plan to add multi-list TODO functionality using XEP-0060 PubSub. Each step can be checked off as it’s implemented.
+
+- [x] 1. Add `api.disco.entities.find(feature)` in `src/headless/plugins/disco/api.js` to lookup JIDs advertising a feature.  
+- [x] 2. In `src/headless/plugins/pubsub/api.js`, add `pubsub.subscribe(jid, node)` and `pubsub.unsubscribe(jid, node)`
+- [ ] 3. Modify `src/plugins/todo/plugin.js` to:  
+  - [ ] Discovers the PubSub service JID via `api.disco.entities.find('http://jabber.org/protocol/pubsub')`.  
+  - [ ] Subscribes to the master index node `urn:conversejs:todolists:1`.  
+  - [ ] Listens for item-add and item-delete events to track available TODO lists.  
+- [ ] 4. For each index entry, subscribe/unsubscribe to its list node and:  
+  - [ ] Fetch existing items via `api.pubsub.items(serviceJid, node)`.  
+  - [ ] Emit high-level events for list item additions/removals.  
+- [ ] 5. Implement payload parsing/serialization in PEP Bookmarks style (XEP-0402) or JSON inside `<item>` payloads.  
+- [ ] 6. Expose CRUD methods on `api.apps.todo`:  
+  - [ ] `listLists(): Promise<Array<{ node, title }>>`  
+  - [ ] `createList(node, title): Promise<void>`  
+  - [ ] `deleteList(node): Promise<void>`  
+  - [ ] `listItems(node): Promise<Item[]>`  
+  - [ ] `addItem(node, data): Promise<void>`  
+  - [ ] `removeItem(node, id): Promise<void>`  
+- [ ] 7. Maintain internal state of lists/items and fire events via `api.emit('todo:lists', lists)` and `api.emit('todo:items', node, items)`.  
+- [ ] 8. Write tests for:  
+  - [x] `api.disco.entities.find`  
+  - [x] `api.pubsub.subscribe/unsubscribe`  
+  - [ ] Index module behavior  
+  - [ ] CRUD API methods  
+- [ ] 9. Update documentation in `src/plugins/todo/README.md` and project docs.
+
+Once all items are checked, the multi-list TODO plugin will support dynamic discovery, subscription, and management of multiple TODO lists via XMPP PubSub.  
+
+---
+
+When adding a new TODO list, let the user enter a name for the list.
+
+Then, upon submission, automatically look for a pubsub service on the user's domain.
+If none is found, return to the form with a new input to let the user specify a PubSub service.

+ 14 - 0
src/plugins/todo/README.md

@@ -0,0 +1,14 @@
+# A TODO app for Converse
+
+We would like to support having multiple todo lists.
+The planned approach is to have a "master" index node which contains a list of todo lists.
+An XMPP client should subscribe to this node to get the list of todo lists, and then it should subscribe to each of the todo lists themselves.
+
+ 1 “Master” index node                                                                                                                       
+   • Create a well-known PubSub node (e.g. “lists.index@server”) whose items each represent one TODO-list.                                   
+   • The payload of each item can simply contain the node-ID and title of the list.                              
+   • Your client subscribes to the index node and watches for adds/removals.                                                                 
+ 2 Individual list nodes                                                                                                                     
+   • Each TODO list is its own PubSub node (e.g. “lists/user@example.net/work”).                                                             
+   • When you see a new index-item, you subscribe to that node; when the index-item is retracted, you unsubscribe.                           
+   • List items (the actual TODO entries) are published/retracted on those per-list nodes.                                                   

+ 27 - 8
src/plugins/todo/modals/add-todo-modal.js

@@ -1,11 +1,20 @@
-import { _converse, api, converse, parsers } from '@converse/headless';
+import { default as Collapse } from 'bootstrap/js/src/collapse.js';
+import { _converse, api, converse, parsers, u } from '@converse/headless';
 import BaseModal from 'plugins/modal/modal.js';
-import tplAddTodo from './templates/add-todo-modal.js';
 import { __ } from 'i18n';
+import tplAddTodo from './templates/add-todo-modal.js';
 
 const { Strophe } = converse.env;
 
 export default class AddTodoModal extends BaseModal {
+    static get properties() {
+        return {
+            ...super.properties,
+            _manual_jid: { state: true, type: Boolean },
+            _entities: { state: true, type: Array },
+        };
+    }
+
     initialize() {
         super.initialize();
         this.requestUpdate();
@@ -18,6 +27,14 @@ export default class AddTodoModal extends BaseModal {
         );
     }
 
+    /**
+     * @param {import('lit').PropertyValues} changed
+     */
+    firstUpdated(changed) {
+        super.firstUpdated(changed);
+        this.collapse = new Collapse(/** @type {HTMLElement} */ (this));
+    }
+
     renderModal() {
         return tplAddTodo(this);
     }
@@ -36,19 +53,21 @@ export default class AddTodoModal extends BaseModal {
         const name = data.get('name');
         const jid = data.get('jid') ?? _converse.state.session.get('domain');
 
-        const service_jids = await api.disco.entities.find(Strophe.NS.PUBSUB, jid);
-        if (service_jids.length === 0) {
+        const entities = await api.disco.entities.find(Strophe.NS.PUBSUB, jid);
+        if (entities.length === 0) {
             this.alert(__('Could not find a PubSub service to host your todo list'), 'danger');
-            this.state.set({ manual_jid: true });
+            this._manual_jid = true;
             return;
-        } else if (service_jids.length > 1) {
+        } else if (entities.length > 1) {
             this.alert(__('Found multiple possible PubSub services to host your todo list, please choose one.'));
-            this.state.set({ services: service_jids, manual_jid: true });
+
+            this._entities = entities;
+            this._manual_jid = true;
             return;
         }
 
         try {
-            await api.pubsub.create(service_jids[0].get('jid'), name);
+            await api.pubsub.create(entities[0].get('jid'), `${Strophe.NS.TODO}/${u.getUniqueId()}`, { title: name });
         } catch (e) {
             const err = await parsers.parseErrorStanza(e);
             this.alert(__('Sorry, an error occurred: %s', err.message), 'danger');

+ 46 - 20
src/plugins/todo/modals/templates/add-todo-modal.js

@@ -10,32 +10,58 @@ export default (el) => {
     const label_name = __('Todo name');
     const label_service = __('PubSub service (XMPP Address) for your todo list');
 
-    const pubsub_services = el.state.get('services') ?? [];
-
     return html`<form class="converse-form" @submit=${(ev) => el.createTodo(ev)}>
         <div class="mb-3">
             <label for="todo-name" class="form-label">${label_name}:</label>
             <input type="text" id="todo-name" name="name" class="form-control" placeholder="${label_name}" required />
         </div>
 
-        ${el.state.get('manual_jid')
-            ? html`<div class="mb-3">
-                  ${pubsub_services.length > 1
-                      ? html`${__('Available PubSub services')}
-                            <ul class="list-group">
-                                ${(pubsub_services ?? []).map((jid) => html`<li class="list-group-item">${jid}</li>`)}
-                            </ul>`
-                      : ''}
-
-                  <label for="todo-jid" class="form-label">${label_service}:</label>
-                  <input
-                      type="text"
-                      id="todo-jid"
-                      name="jid"
-                      class="form-control"
-                      required
-                  />
-              </div>`
+        ${el._manual_jid
+            ? html` ${(el._entities?.length ?? 0) > 1
+                  ? html`<div class="mb-3">
+                        <label class="form-label">${__('Available PubSub services:')}</label>
+                        <div class="list-group">
+                            ${el._entities.map(
+                                /** @param {import('@converse/headless').DiscoEntity} entity */ (entity) => {
+                                    const jid = entity.get('jid');
+                                    const features = entity.features
+                                        .map((f) => f.get('var'))
+                                        .filter((f) => f.includes('pubsub'));
+                                    return html`<div class="form-check">
+                                        <input
+                                            class="form-check-input"
+                                            type="radio"
+                                            name="jid"
+                                            id="${jid}"
+                                            value="${jid}"
+                                        />
+                                        <label class="form-check-label fw-bold" for="${jid}">${jid}</label>
+                                        <button
+                                            class="btn btn-link p-0"
+                                            type="button"
+                                            data-bs-toggle="collapse"
+                                            data-bs-target="#collapse-${jid}"
+                                            aria-expanded="false"
+                                            aria-controls="collapse-${jid}"
+                                        >
+                                            ${__('Show Features')}
+                                        </button>
+                                        <div class="collapse" id="collapse-${jid}">
+                                            <div class="card card-body">
+                                                <ul class="list-unstyled mt-2 text-muted small">
+                                                    ${features.map((feature) => html`<li>${feature}</li>`)}
+                                                </ul>
+                                            </div>
+                                        </div>
+                                    </div>`;
+                                }
+                            )}
+                        </div>
+                    </div>`
+                  : html`<div class="mb-3">
+                        <label for="todo-jid" class="form-label">${label_service}:</label>
+                        <input type="text" id="todo-jid" name="jid" class="form-control" required />
+                    </div>`}`
             : ''}
 
         <input type="submit" class="btn btn-primary mt-3" value="${i18n_create || ''}" />

+ 7 - 0
src/plugins/todo/models/project.js

@@ -0,0 +1,7 @@
+import { Model } from '@converse/skeletor';
+
+export default class Project extends Model {
+    get idAttribute() {
+        return 'jid';
+    }
+}

+ 11 - 0
src/plugins/todo/models/projects.js

@@ -0,0 +1,11 @@
+import { Collection } from '@converse/skeletor';
+import Project from './project';
+
+export default class Projects extends Collection {
+
+    constructor() {
+        super();
+        this.model = Project;
+    }
+
+}

+ 3 - 0
src/shared/components/dropdownbase.js

@@ -3,6 +3,9 @@ import EventHandler from "bootstrap/js/src/dom/event-handler.js";
 import { CustomElement } from "./element.js";
 
 export default class DropdownBase extends CustomElement {
+    /**
+     * @param {import('lit').PropertyValues} [changed]
+     */
     firstUpdated(changed) {
         super.firstUpdated(changed);
         this.menu = this.querySelector(".dropdown-menu");

+ 0 - 6
src/shared/styles/_core.scss

@@ -383,12 +383,6 @@
         animation-timing-function: ease-in-out;
     }
 
-    .collapsed {
-        height: 0 !important;
-        overflow: hidden !important;
-        padding: 0 !important;
-    }
-
     .locked {
         padding-inline-end: 22px;
     }

+ 0 - 6
src/types/plugins/rootview/app-switcher.d.ts

@@ -1,10 +1,4 @@
 export default class AppSwitcher extends CustomElement {
-    static get properties(): {
-        _activeApp: {
-            type: StringConstructor;
-        };
-    };
-    _activeApp: string;
     initialize(): void;
     render(): import("lit-html").TemplateResult<1>;
     /**

+ 0 - 1
src/types/plugins/rootview/types.d.ts

@@ -3,6 +3,5 @@ export type App = {
     name: string;
     render: () => TemplateResult;
     renderControlbox?: () => TemplateResult;
-    active: boolean;
 };
 //# sourceMappingURL=types.d.ts.map

+ 10 - 0
src/types/plugins/todo/lists.d.ts

@@ -0,0 +1,10 @@
+export default class TodoLists extends CustomElement {
+    render(): import("lit-html").TemplateResult<1>;
+    model: Model;
+    /** @param {Event} [ev] */
+    toggleList(ev?: Event): void;
+    getProjects(): any[];
+}
+import { CustomElement } from 'shared/components/element.js';
+import { Model } from '@converse/skeletor';
+//# sourceMappingURL=lists.d.ts.map

+ 26 - 0
src/types/plugins/todo/modals/add-todo-modal.d.ts

@@ -0,0 +1,26 @@
+export default class AddTodoModal extends BaseModal {
+    static get properties(): {
+        _manual_jid: {
+            state: boolean;
+            type: BooleanConstructor;
+        };
+        _entities: {
+            state: boolean;
+            type: ArrayConstructor;
+        };
+        model: {
+            type: typeof import("@converse/headless").Model;
+        };
+    };
+    collapse: any;
+    renderModal(): import("lit-html").TemplateResult<1>;
+    getModalTitle(): any;
+    /**
+     * @param {Event} ev
+     */
+    createTodo(ev: Event): Promise<void>;
+    _manual_jid: boolean;
+    _entities: any;
+}
+import BaseModal from 'plugins/modal/modal.js';
+//# sourceMappingURL=add-todo-modal.d.ts.map

+ 3 - 0
src/types/plugins/todo/modals/templates/add-todo-modal.d.ts

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

+ 4 - 0
src/types/plugins/todo/models/project.d.ts

@@ -0,0 +1,4 @@
+export default class Project extends Model {
+}
+import { Model } from '@converse/skeletor';
+//# sourceMappingURL=project.d.ts.map

+ 5 - 0
src/types/plugins/todo/models/projects.d.ts

@@ -0,0 +1,5 @@
+export default class Projects extends Collection {
+    constructor();
+}
+import { Collection } from '@converse/skeletor';
+//# sourceMappingURL=projects.d.ts.map

+ 3 - 0
src/types/plugins/todo/templates/lists.d.ts

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

+ 4 - 1
src/types/shared/components/dropdownbase.d.ts

@@ -1,5 +1,8 @@
 export default class DropdownBase extends CustomElement {
-    firstUpdated(changed: any): void;
+    /**
+     * @param {import('lit').PropertyValues} [changed]
+     */
+    firstUpdated(changed?: import("lit").PropertyValues): void;
     menu: Element;
     button: HTMLButtonElement;
     dropdown: BootstrapDropdown;