diff --git a/conversejs/custom/plugins/tasks/constants.js b/conversejs/custom/plugins/tasks/constants.js index 3bf738ad..5128e44f 100644 --- a/conversejs/custom/plugins/tasks/constants.js +++ b/conversejs/custom/plugins/tasks/constants.js @@ -1 +1,2 @@ -export const XMLNS_TASKLIST = 'https://livingston.frama.io/peertube-plugin-livechat/protocol/tasklist' +export const XMLNS_TASKLIST = 'urn:peertube-plugin-livechat:tasklist' +export const XMLNS_TASK = 'urn:peertube-plugin-livechat:task' diff --git a/conversejs/custom/plugins/tasks/index.js b/conversejs/custom/plugins/tasks/index.js index e5c2c587..69991dc6 100644 --- a/conversejs/custom/plugins/tasks/index.js +++ b/conversejs/custom/plugins/tasks/index.js @@ -8,6 +8,8 @@ import './muc-task-list-view.js' // FIXME: here or in another file? import './muc-task-lists-view.js' // FIXME: here or in another file? import './modals/muc-task-lists.js' // FIXME: here or in another file? +// TODO: add a client disco feature (using api.listen.on('addClientFeatures' ...)). + converse.plugins.add('livechat-converse-tasks', { dependencies: ['converse-muc', 'converse-disco'], // TODO: add converse-pubsub diff --git a/conversejs/custom/plugins/tasks/task-lists.js b/conversejs/custom/plugins/tasks/task-lists.js index 26111fbb..c2942a75 100644 --- a/conversejs/custom/plugins/tasks/task-lists.js +++ b/conversejs/custom/plugins/tasks/task-lists.js @@ -2,7 +2,6 @@ import { Collection } from '@converse/skeletor/src/collection.js' import { ChatRoomTaskList } from './task-list' import { XMLNS_TASKLIST } from './constants' import { initStorage } from '@converse/headless/utils/storage.js' -import { getUniqueId } from '@converse/headless/utils/core.js' import { converse, api } from '@converse/headless/core' const { $build } = converse.env @@ -24,8 +23,6 @@ class ChatRoomTaskLists extends Collection { initStorage(this, id, 'session') this.on('change:name', () => this.sort()) - - this.fetchTasksLists().catch(console.error) } comparator (tasklist1, tasklist2) { @@ -35,50 +32,6 @@ class ChatRoomTaskLists extends Collection { return name1 < name2 ? -1 : name1 > name2 ? 1 : 0 } - create (attrs, options) { - if (attrs instanceof ChatRoomTaskList) { - return super.create(attrs, options) - } - attrs.id ??= getUniqueId() - return super.create(attrs, options) - } - - /** - * Requires Task lists from the server. - */ - async fetchTasksLists () { - // TODO: remove these test lines, and subscribe to pubsub. - const taskListsData = [ - { - id: 'task-list-1', - name: 'Task List 1' - }, - { - id: 'task-list-2', - name: 'Task List 2' - } - ] - - for (const item of taskListsData) { - let id = item.id - - const tasklist = id ? this.get(id) : undefined - if (tasklist) { - tasklist.save({ - name: item.name - }) - return - } - - id ??= getUniqueId() - - this.create({ - id, - name: item.name - }) - } - } - async createTaskList (data) { const name = data?.name if (!name) { throw new Error('Missing name') } diff --git a/conversejs/custom/plugins/tasks/tasks.js b/conversejs/custom/plugins/tasks/tasks.js index 94cd7284..68bd064e 100644 --- a/conversejs/custom/plugins/tasks/tasks.js +++ b/conversejs/custom/plugins/tasks/tasks.js @@ -1,7 +1,6 @@ import { Collection } from '@converse/skeletor/src/collection.js' import { ChatRoomTask } from './task' import { initStorage } from '@converse/headless/utils/storage.js' -import { getUniqueId } from '@converse/headless/utils/core.js' /** * A list of {@link _converse.ChatRoomTask} instances, representing all tasks associated to a MUC. @@ -22,71 +21,6 @@ class ChatRoomTasks extends Collection { initStorage(this, id, 'session') this.on('change:order', () => this.sort()) - - this.fetchTasks().catch(console.error) - } - - create (attrs, options) { - if (attrs instanceof ChatRoomTask) { - return super.create(attrs, options) - } - attrs.id ??= getUniqueId() - return super.create(attrs, options) - } - - /** - * Requires Task lists from the server. - */ - async fetchTasks () { - // TODO: remove these test lines, and subscribe to pubsub. - const tasksData = [ - { - id: 'task-1', - name: 'Task 1.1', - list: 'task-list-1', - order: 1, - done: false - }, - { - id: 'task-2', - name: 'Task 1.2', - list: 'task-list-1', - order: 2, - done: true - }, - { - id: 'task-3', - name: 'Task 2.1', - list: 'task-list-2', - order: 1, - done: false - } - ] - - for (const item of tasksData) { - let id = item.id - - const task = id ? this.get(id) : undefined - if (task) { - task.save({ - name: item.name, - list: item.list, - order: item.order, - done: item.done - }) - return - } - - id ??= getUniqueId() - - this.create({ - id, - name: item.name, - list: item.list, - order: item.order, - done: item.done - }) - } } } diff --git a/conversejs/custom/plugins/tasks/utils.js b/conversejs/custom/plugins/tasks/utils.js index cf2448a3..45974f81 100644 --- a/conversejs/custom/plugins/tasks/utils.js +++ b/conversejs/custom/plugins/tasks/utils.js @@ -1,6 +1,7 @@ +import { XMLNS_TASKLIST, XMLNS_TASK } from './constants.js' +import { PubSubManager } from '../../shared/lib/pubsub-manager.js' import { converse, _converse, api } from '../../../src/headless/core.js' import { __ } from 'i18n' -const { Strophe, $iq } = converse.env export function getHeadingButtons (view, buttons) { const muc = view.model @@ -13,6 +14,9 @@ export function getHeadingButtons (view, buttons) { return buttons } + // TODO: use disco to discover the feature. + // (if the chat is remote, the server could use a livechat version that does not support this feature) + // Adding a "Open task list" button. buttons.unshift({ // eslint-disable-next-line no-undef @@ -32,7 +36,7 @@ export function getHeadingButtons (view, buttons) { } function _initChatRoomTaskLists (mucModel) { - if (mucModel.tasklists) { + if (mucModel.taskManager) { // already initiliazed return } @@ -40,35 +44,41 @@ function _initChatRoomTaskLists (mucModel) { mucModel.tasklists = new _converse.ChatRoomTaskLists(undefined, { chatroom: mucModel }) mucModel.tasks = new _converse.ChatRoomTasks(undefined, { chatroom: mucModel }) - // Requesting all items. - const stanza = $iq({ - type: 'get', - from: _converse.bare_jid, - to: mucModel.get('jid') - }).c('pubsub', { xmlns: Strophe.NS.PUBSUB }) - .c('items', { node: 'livechat-tasks' }) - - api.sendIQ(stanza).then( - (iq) => { - console.debug('task lists: ', iq) - }, - (iq) => { - if (iq === null || !iq?.querySelector) { - console.error('Failed to retrieve tasks', iq) - return + mucModel.taskManager = new PubSubManager( + mucModel.get('jid'), + 'livechat-tasks', // the node name + { + tasklist: { + itemTag: 'tasklist', + xmlns: XMLNS_TASKLIST, + collection: mucModel.tasklists, + fields: { + name: String + } + }, + task: { + itemTag: 'task', + xmlns: XMLNS_TASK, + collection: mucModel.tasks, + fields: { + name: String + }, + attributes: { + done: Boolean, + list: String, + order: Number + } } - if (!iq.querySelector('error[type="cancel"] item-not-found')) { - console.error('Failed to retrieve tasks:', iq) - return - } - // This is totally normal when you open an empty task list. - console.log('Not livechat-tasks node for now') } ) + mucModel.taskManager.start().catch(err => console.log(err)) } function _destroyChatRoomTaskLists (mucModel) { - if (!mucModel.tasklists) { return } + if (!mucModel.taskManager) { return } + + mucModel.taskManager.stop().catch(err => console.log(err)) + mucModel.taskManager = undefined // mucModel.tasklists.unload() FIXME: add a method to unregister from the pubsub, and empty the tasklist. mucModel.tasklists = undefined diff --git a/conversejs/custom/shared/lib/pubsub-manager.js b/conversejs/custom/shared/lib/pubsub-manager.js new file mode 100644 index 00000000..0e318622 --- /dev/null +++ b/conversejs/custom/shared/lib/pubsub-manager.js @@ -0,0 +1,248 @@ +import { converse, _converse, api } from '../../../src/headless/core.js' +const { Strophe, $iq, sizzle } = converse.env + +/** + * This class helps to manage some objects that are stored on pubsub nodes. + * This if for example used for livechat-taskslists and livechat-tasks, + * but could be used for other object types that would be added one day. + */ +export class PubSubManager { + roomJID + node + types + stanzaHandler + + /** + * Created a new pubsub manager. + * @param {string} roomJID the room JID + * @param {string} node the node name to which subscribe + * @param {Array} types an array with object describing the object type, and how to handle them. Here is its format: + * { + * itemTag: 'task', + * xmlns: XMLNS_TASK, + * collection: mucModel.tasks, + * fields: { // these are item child nodes (tag names) + * name: String, + * }, + * attributes: { // these are attribute on the node + * order: Number + * done: Boolean, + * list: String, + * } + * } + */ + constructor (roomJID, node, types) { + this.roomJID = roomJID + this.node = node + this.types = types + + this.stanzaHandler = undefined + } + + /** + * Starts the manager. + * This will subscribe to the node, and retrieve all existing objects. + */ + async start () { + // FIXME: handle errors. Find a way to display to user that this failed. + + this.stanzaHandler = _converse.connection.addHandler( + (message) => { + try { + this._handleMessage(message) + } catch (err) { + console.log(err) + } + return true // if returning anything else, the handler will not be called again! + }, + null, // The namespace to match. + 'message', // The stanza name to match. + 'headline', // The stanza type attribute to match. + null, // The stanza id attribute to match. + this.roomJID, // The stanza from attribute to match. + { + matchBareFromJid: true + } // The handler options + ) + await this._subscribe() + await this._retrieveAllItems() + } + + /** + * Stops the manager + */ + async stop () { + // await this._unsubscribe() TODO + + if (this.stanzaHandler) { + _converse.connection.deleteHandler(this.stanzaHandler) + this.stanzaHandler = undefined + } + } + + /** + * Subscribed to the pubsub node. + */ + async _subscribe () { + const stanza = $iq({ + type: 'set', + from: _converse.jid, + to: this.roomJID + }).c('pubsub', { xmlns: Strophe.NS.PUBSUB }) + .c('subscribe', { node: this.node, jid: _converse.bare_jid }) + + try { + const iq = await api.sendIQ(stanza) + + console.debug('subscribtion ok: ', iq) + } catch (iq) { + console.error('Failed to subscribe to ' + this.node, iq) + throw iq + } + } + + /** + * Retrieves all items + * + * TODO: handle pagination if results are not all sent. + * See https://xmpp.org/extensions/xep-0060.html#subscriber-retrieve-returnsome + */ + async _retrieveAllItems () { + // Requesting all items. + const stanza = $iq({ + type: 'get', + from: _converse.jid, + to: this.roomJID + }).c('pubsub', { xmlns: Strophe.NS.PUBSUB }) + .c('items', { node: this.node }) + + try { + const iq = await api.sendIQ(stanza) + this._handleIQ(iq) + } catch (iq) { + if (iq === null || !iq?.querySelector) { + console.error('Failed to retrieve objects from ' + this.node, iq) + throw iq + } + if (!iq.querySelector('error[type="cancel"] item-not-found')) { + console.error('Failed to retrieve objects from ' + this.node + ':', iq) + throw iq + } + // This is totally normal when you open an empty node. + console.log('Not ' + this.node + ' node for now') + } + } + + /** + * Check if an incomming message contains data to dispatch. + * @param message The incoming stanza + */ + _handleMessage (message) { + const itemsNodes = sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"] items[node="${this.node}"]`, message) + if (!itemsNodes.length) { + return + } + for (const itemsNode of itemsNodes) { + this._dispatchStanza(itemsNode) + } + } + + /** + * As _handleMessage, but for IQ. + * @param iq stanza + */ + _handleIQ (iq) { + const itemsNodes = sizzle(`pubsub items[node="${this.node}"]`, iq) + if (!itemsNodes.length) { return } + for (const itemsNode of itemsNodes) { + this._dispatchStanza(itemsNode) + } + } + + /** + * Parse items in a stanza response, and dispatch them to collections. + * @param {stanza} itemsNode the 'items' part of the incomming stanza. + */ + _dispatchStanza (itemsNode) { + console.debug('Dispatching items for node ' + this.node + ' from stanza items: ', itemsNode) + + for (const key in this.types) { + const type = this.types[key] + + const selector = `item ${type.itemTag}[xmlns="${type.xmlns}"]` + const items = sizzle(selector, itemsNode) + if (!items.length) { continue } + + console.log('Found ' + items.length + ' ' + type.itemTag + ' in stanza, dispatching...') + for (const item of items) { + // Note: we consider that there is only one object in an item. + const id = item.parentNode?.getAttribute('id') + if (!id) { + console.error('Missing id for this item', item) + continue + } + + const data = this._parseItem(item, type) + if (data === null) { continue } + + const existing = type.collection.get(id) + if (existing) { + existing.save(data) + } else { + type.collection.create(Object.assign(data, { id })) + } + } + } + + this._handleRetractations(itemsNode) + } + + /** + * Parse retractations in IQ or Message stanza, and process them. + * @param stanza IQ response + */ + _handleRetractations (stanza) { + // Note: here we don't know the object type. We must try on each collection. + const ids = sizzle('', stanza).map(i => i.getAttribute('id')) + for (const id of ids) { + for (const key in this.types) { + const type = this.types[key] + const item = type.collection.get(id) + if (!item) { continue } + console.log('Removing Item ' + id + ' that was found in collection ' + key) + type.collection.remove(item) + } + } + } + + _parseItem (itemNode, type) { + const data = {} + if (type.fields) { + for (const child of itemNode.children ?? []) { + const fieldName = child.tagName + if (!(fieldName in type.fields)) { + continue + } + data[fieldName] = this._readValue(child.textContent, type.fields[fieldName]) + } + } + if (type.attributes) { + for (const attr in type.attributes) { + const value = itemNode.getAttribute(attr) + if (attr !== undefined) { + data[attr] = this._readValue(value, type.attributes[attr]) + } + } + } + return data + } + + _readValue (v, t) { + switch (t) { + case String: return (v ?? '').toString() + case Number: return parseInt(v) // only integers? + case Boolean: return !!v + default: return v // dont know what to do + } + } +} diff --git a/prosody-modules/mod_pubsub_peertubelivechat/mod_pubsub_peertubelivechat.lua b/prosody-modules/mod_pubsub_peertubelivechat/mod_pubsub_peertubelivechat.lua index a86d4308..f12a9214 100644 --- a/prosody-modules/mod_pubsub_peertubelivechat/mod_pubsub_peertubelivechat.lua +++ b/prosody-modules/mod_pubsub_peertubelivechat/mod_pubsub_peertubelivechat.lua @@ -13,6 +13,8 @@ -- Implemented nodes: -- * livechat-tasks: contains tasklist and task items, specific to livechat plugin. +-- TODO: add disco support. + local pubsub = require "util.pubsub"; local jid_bare = require "util.jid".bare; local jid_split = require "util.jid".split; @@ -125,7 +127,7 @@ local function simple_itemstore(room_jid) end end -local function get_broadcaster(room_jid) +local function get_broadcaster(room_jid, room_host) local function simple_broadcast(kind, node, jids, item, _, node_obj) if node_obj then if node_obj.config["notify_"..kind] == false then @@ -146,7 +148,9 @@ local function get_broadcaster(room_jid) end local id = new_id(); - local message = st.message({ from = room_jid, type = "headline", id = id }) + -- FIXME: should we add a type=headline to the message? (this is what mod_pep does, + -- and it seems that ConverseJS prefer to have it for server messages.) + local message = st.message({ from = jid_join(room_jid, room_host), type = "headline", id = id }) :tag("event", { xmlns = xmlns_pubsub_event }) :tag(kind, { node = node }); @@ -232,7 +236,7 @@ function get_mep_service(room_jid, room_host) nodestore = nodestore(room_jid); itemstore = simple_itemstore(room_jid); - broadcaster = get_broadcaster(room_jid); + broadcaster = get_broadcaster(room_jid, room_host); -- subscriber_filter = get_subscriber_filter(room_jid); itemcheck = is_item_stanza; get_affiliation = function (jid)