From 7fef055c14cdf1c7fc0d5f1751b882e76596c377 Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Mon, 1 Jul 2024 16:46:02 +0000 Subject: [PATCH 01/34] Translated using Weblate (Croatian) Currently translated at 12.1% (92 of 760 strings) Translation: PeerTube LiveChat/Peertube Plugin Livechat Documentation Translate-URL: https://weblate.framasoft.org/projects/peertube-livechat/peertube-plugin-livechat-documentation/hr/ --- support/documentation/po/livechat.hr.po | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/support/documentation/po/livechat.hr.po b/support/documentation/po/livechat.hr.po index cffb1f48..9dd8e930 100644 --- a/support/documentation/po/livechat.hr.po +++ b/support/documentation/po/livechat.hr.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: peertube-plugin-livechat-documentation VERSION\n" "POT-Creation-Date: 2024-06-25 17:22+0200\n" -"PO-Revision-Date: 2024-06-25 19:17+0000\n" +"PO-Revision-Date: 2024-07-01 20:41+0000\n" "Last-Translator: Milo Ivir \n" "Language-Team: Croatian \n" @@ -3010,20 +3010,25 @@ msgstr "" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md -#, fuzzy -#| msgid "[Custom emojis](/peertube-plugin-livechat/documentation/user/streamers/emojis)" msgid "[{{% livechat_label livechat_configuration_channel_terms_label %}}](/peertube-plugin-livechat/documentation/user/streamers/terms)" -msgstr "[Prilagođeni emojiji](/peertube-plugin-livechat/documentation/user/streamers/emojis)" +msgstr "" +"[{{% livechat_label livechat_configuration_channel_terms_label " +"%}}](/peertube-plugin-livechat/documentation/user/streamers/terms)" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md msgid "[{{% livechat_label livechat_configuration_channel_mute_anonymous_label %}}](/peertube-plugin-livechat/documentation/user/streamers/moderation) default value" msgstr "" +"[{{% livechat_label livechat_configuration_channel_mute_anonymous_label " +"%}}](/peertube-plugin-livechat/documentation/user/streamers/moderation) " +"standardna vrijednost" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md msgid "[The slow mode](/peertube-plugin-livechat/documentation/user/streamers/slow_mode)" msgstr "" +"[Spori modus](/peertube-plugin-livechat/documentation/user/streamers/" +"slow_mode)" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md From 241065e6831179eed3674fd5a050edd1b62cbedb Mon Sep 17 00:00:00 2001 From: John Livingston Date: Fri, 5 Jul 2024 11:10:38 +0200 Subject: [PATCH 02/34] Fix doc urls for hr lang. --- support/documentation/po/livechat.hr.po | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/support/documentation/po/livechat.hr.po b/support/documentation/po/livechat.hr.po index 9dd8e930..6857cf51 100644 --- a/support/documentation/po/livechat.hr.po +++ b/support/documentation/po/livechat.hr.po @@ -2360,9 +2360,7 @@ msgstr "" #. type: Plain text #: support/documentation/content/en/documentation/installation/troubleshooting.md msgid "Check the help for [this setting](/peertube-plugin-livechat/documentation/admin/settings/) for more information." -msgstr "" -"Za više informacija pogledaj stranicu pomoći za [ovu postavku](/peertube-" -"plugin-livechat/documentation/admin/settings/)." +msgstr "Za više informacija pogledaj stranicu pomoći za [ovu postavku](/peertube-plugin-livechat/hr/documentation/admin/settings/)." #. type: Title ### #: support/documentation/content/en/documentation/installation/troubleshooting.md @@ -3013,21 +3011,21 @@ msgstr "" msgid "[{{% livechat_label livechat_configuration_channel_terms_label %}}](/peertube-plugin-livechat/documentation/user/streamers/terms)" msgstr "" "[{{% livechat_label livechat_configuration_channel_terms_label " -"%}}](/peertube-plugin-livechat/documentation/user/streamers/terms)" +"%}}](/peertube-plugin-livechat/hr/documentation/user/streamers/terms)" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md msgid "[{{% livechat_label livechat_configuration_channel_mute_anonymous_label %}}](/peertube-plugin-livechat/documentation/user/streamers/moderation) default value" msgstr "" "[{{% livechat_label livechat_configuration_channel_mute_anonymous_label " -"%}}](/peertube-plugin-livechat/documentation/user/streamers/moderation) " +"%}}](/peertube-plugin-livechat/hr/documentation/user/streamers/moderation) " "standardna vrijednost" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md msgid "[The slow mode](/peertube-plugin-livechat/documentation/user/streamers/slow_mode)" msgstr "" -"[Spori modus](/peertube-plugin-livechat/documentation/user/streamers/" +"[Spori modus](/peertube-plugin-livechat/hr/documentation/user/streamers/" "slow_mode)" #. type: Bullet: '* ' @@ -3038,7 +3036,7 @@ msgstr "" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md msgid "[Custom emojis](/peertube-plugin-livechat/documentation/user/streamers/emojis)" -msgstr "[Prilagođeni emojiji](/peertube-plugin-livechat/documentation/user/streamers/emojis)" +msgstr "[Prilagođeni emojiji](/peertube-plugin-livechat/hr/documentation/user/streamers/emojis)" #. type: Bullet: '* ' #: support/documentation/content/en/documentation/user/streamers/channel.md @@ -3657,7 +3655,7 @@ msgstr "" msgid "To configure the terms & conditions, go to the [channel configuration page](/peertube-plugin-livechat/documentation/user/streamers/channel):" msgstr "" "Za konfiguriranje uvjeta i odredbi idi na [stranicu za konfiguraciju kanala" -"](/peertube-plugin-livechat/documentation/user/streamers/channel):" +"](/peertube-plugin-livechat/hr/documentation/user/streamers/channel):" #. type: Plain text #: support/documentation/content/en/documentation/user/streamers/terms.md From b79258836481b4c56636a211dac9967a93d7ba02 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Thu, 27 Jun 2024 19:56:12 +0200 Subject: [PATCH 03/34] Poll WIP (#231): * mod_muc_poll * feature detection and create poll button in Converse --- conversejs/custom/index.js | 2 + conversejs/custom/plugins/poll/constants.js | 5 ++ conversejs/custom/plugins/poll/index.js | 21 ++++++ conversejs/custom/plugins/poll/utils.js | 52 ++++++++++++++ conversejs/loc.keys.js | 3 +- languages/en.yml | 2 + prosody-modules/mod_muc_poll/README.md | 15 ++++ prosody-modules/mod_muc_poll/mod_muc_poll.lua | 70 +++++++++++++++++++ server/lib/prosody/config/content.ts | 2 + 9 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 conversejs/custom/plugins/poll/constants.js create mode 100644 conversejs/custom/plugins/poll/index.js create mode 100644 conversejs/custom/plugins/poll/utils.js create mode 100644 prosody-modules/mod_muc_poll/README.md create mode 100644 prosody-modules/mod_muc_poll/mod_muc_poll.lua diff --git a/conversejs/custom/index.js b/conversejs/custom/index.js index d5fda0a1..51d352c5 100644 --- a/conversejs/custom/index.js +++ b/conversejs/custom/index.js @@ -47,6 +47,7 @@ import './plugins/fullscreen/index.js' import '../custom/plugins/size/index.js' import '../custom/plugins/tasks/index.js' import '../custom/plugins/terms/index.js' +import '../custom/plugins/poll/index.js' /* END: Removable components */ import { CORE_PLUGINS } from './headless/shared/constants.js' @@ -55,6 +56,7 @@ import { ROOM_FEATURES } from './headless/plugins/muc/constants.js' CORE_PLUGINS.push('livechat-converse-size') CORE_PLUGINS.push('livechat-converse-tasks') CORE_PLUGINS.push('livechat-converse-terms') +CORE_PLUGINS.push('livechat-converse-poll') // We must also add our custom ROOM_FEATURES, so that they correctly resets // (see headless/plugins/muc, getDiscoInfoFeatures, which loops on this const) ROOM_FEATURES.push('x_peertubelivechat_mute_anonymous') diff --git a/conversejs/custom/plugins/poll/constants.js b/conversejs/custom/plugins/poll/constants.js new file mode 100644 index 00000000..a7eceaf1 --- /dev/null +++ b/conversejs/custom/plugins/poll/constants.js @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +export const XMLNS_POLL = 'http://jabber.org/protocol/muc#x-poll' diff --git a/conversejs/custom/plugins/poll/index.js b/conversejs/custom/plugins/poll/index.js new file mode 100644 index 00000000..798b1037 --- /dev/null +++ b/conversejs/custom/plugins/poll/index.js @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { _converse, converse } from '../../../src/headless/core.js' +import { getHeadingButtons } from './utils.js' +// import { XMLNS_POLL } from './constants.js' + +converse.plugins.add('livechat-converse-poll', { + dependencies: ['converse-muc', 'converse-disco'], + + initialize () { + // _converse.api.listen.on('chatRoomInitialized', muc => { + // muc.features.on('change:' + XMLNS_POLL, () => { + // // TODO: refresh headingbuttons? + // }) + // }) + // adding the poll actions in the MUC heading buttons: + _converse.api.listen.on('getHeadingButtons', getHeadingButtons) + } +}) diff --git a/conversejs/custom/plugins/poll/utils.js b/conversejs/custom/plugins/poll/utils.js new file mode 100644 index 00000000..acddfcab --- /dev/null +++ b/conversejs/custom/plugins/poll/utils.js @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { XMLNS_POLL } from './constants.js' +import { converse, _converse, api } from '../../../src/headless/core.js' +import { __ } from 'i18n' +const $iq = converse.env.$iq + +function _fetchPollForm (mucModel) { + return api.sendIQ( + $iq({ + to: mucModel.get('jid'), + type: 'get' + }).c('query', { xmlns: XMLNS_POLL }) + ) +} + +export function getHeadingButtons (view, buttons) { + const muc = view.model + if (muc.get('type') !== _converse.CHATROOMS_TYPE) { + // only on MUC. + return buttons + } + + if (!muc.features?.get?.(XMLNS_POLL)) { + // Poll feature not available (can happen if the chat is remote, and the plugin not up to date) + return buttons + } + + const myself = muc.getOwnOccupant() + if (!myself || !['admin', 'owner'].includes(myself.get('affiliation'))) { + return buttons + } + + // Adding a "New poll" button. + buttons.unshift({ + // eslint-disable-next-line no-undef + i18n_text: __(LOC_new_poll), + handler: async (ev) => { + ev.preventDefault() + const r = await _fetchPollForm(muc) + // FIXME + console.info('Received poll form', r) + }, + a_class: '', + icon_class: 'fa-list-check', // FIXME + name: 'muc-create-poll' + }) + + return buttons +} diff --git a/conversejs/loc.keys.js b/conversejs/loc.keys.js index 5baacb04..ba601544 100644 --- a/conversejs/loc.keys.js +++ b/conversejs/loc.keys.js @@ -35,7 +35,8 @@ const locKeys = [ 'task_list_pick_title', 'task_list_pick_empty', 'task_list_pick_message', - 'muted_anonymous_message' + 'muted_anonymous_message', + 'new_poll' ] module.exports = locKeys diff --git a/languages/en.yml b/languages/en.yml index a05e26a2..b29d1491 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -563,3 +563,5 @@ livechat_configuration_channel_mute_anonymous_desc: | livechat_configuration_channel_terms_label: "Channel's chat terms & conditions" livechat_configuration_channel_terms_desc: | You can configure a "terms & conditions" message that will be shown to users joining your chatrooms. + +new_poll: Create a new poll diff --git a/prosody-modules/mod_muc_poll/README.md b/prosody-modules/mod_muc_poll/README.md new file mode 100644 index 00000000..f29d7621 --- /dev/null +++ b/prosody-modules/mod_muc_poll/README.md @@ -0,0 +1,15 @@ + +# mod_muc_slow_pool + +This module provide a way to create polls in MUC rooms. + +This module is part of peertube-plugin-livechat, and is under the same LICENSE. + +There will probably be a XEP proposal for this module behaviour. When done, this module will be published in the prosody-modules repository. + +## Configuration + +Just enable the module on your MUC component. diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua new file mode 100644 index 00000000..6386d493 --- /dev/null +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -0,0 +1,70 @@ +local st = require "util.stanza"; +local dataform = require "util.dataforms"; +local jid_bare = require "util.jid".bare; + +local mod_muc = module:depends"muc"; +local get_room_from_jid = mod_muc.get_room_from_jid; + +-- FIXME: create a XEP to standardize this, and remove the "x-". +local xmlns_poll = "http://jabber.org/protocol/muc#x-poll"; + +local function get_form_layout(room, stanza) + local form = dataform.new({ + title = "New poll", + instructions = "Complete and submit this form to create a new poll. This will end and replace any existing poll.", + { + name = "FORM_TYPE"; + type = "hidden"; + value = xmlns_poll; + } + }); + + table.insert(form, { + name = "muc#roompoll_question"; + label = "Question"; + value = ""; + }); + return form; +end + +local function send_form(room, origin, stanza) + module:log("debug", "Sending the poll form"); + origin.send(st.reply(stanza):query(xmlns_poll) + :add_child(get_form_layout(room, stanza.attr.from):form()) +) ; +end + +-- module:hook("iq/bare", function (event) +-- local stanza = event.stanza; +-- local type = stanza.attr.type; +-- local child = stanza.tags[1]; +-- local xmlns = child.attr.xmlns or "jabber:client"; +-- module:log("info", "coucou %s %s %s", type, xmlns, child.name); +-- end, 1000); + +-- new poll creation, get form +module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) + local origin, stanza = event.origin, event.stanza; + local room_jid = stanza.attr.to; + module:log("debug", "Received a request for the poll form"); + local room = get_room_from_jid(room_jid); + if not room then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + end + local from = jid_bare(stanza.attr.from); + + local from_affiliation = room:get_affiliation(from); + if (from_affiliation ~= "owner" and from_affiliation ~= "admin") then + origin.send(st.error_reply(stanza, "auth", "forbidden")) + return true; + end + + send_form(room, origin, stanza); + return true; +end); + +-- Discovering support +module:hook("muc-disco#info", function (event) + event.reply:tag("feature", { var = xmlns_poll }):up(); +end); diff --git a/server/lib/prosody/config/content.ts b/server/lib/prosody/config/content.ts index 9853c302..6aa8d99c 100644 --- a/server/lib/prosody/config/content.ts +++ b/server/lib/prosody/config/content.ts @@ -248,6 +248,8 @@ class ProsodyConfigContent { if (chatTerms) { this.muc.set('muc_terms_global', new ConfigEntryValueMultiLineString(chatTerms)) } + + this.muc.add('modules_enabled', 'muc_poll') } useAnonymous (autoBanIP: boolean): void { From 14e05763295725ca53d6f7923e60448b36b868b8 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Fri, 28 Jun 2024 18:38:59 +0200 Subject: [PATCH 04/34] Poll WIP (#231): * backend form declaration * frontend form dialog --- .../plugins/poll/components/poll-form-view.js | 95 +++++++++++++++++++ conversejs/custom/plugins/poll/index.js | 2 + .../custom/plugins/poll/modals/poll-form.js | 45 +++++++++ .../custom/plugins/poll/styles/poll-form.scss | 14 +++ .../plugins/poll/templates/poll-form.js | 33 +++++++ conversejs/custom/plugins/poll/utils.js | 16 +--- prosody-modules/mod_muc_poll/mod_muc_poll.lua | 54 +++++++++++ 7 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 conversejs/custom/plugins/poll/components/poll-form-view.js create mode 100644 conversejs/custom/plugins/poll/modals/poll-form.js create mode 100644 conversejs/custom/plugins/poll/styles/poll-form.scss create mode 100644 conversejs/custom/plugins/poll/templates/poll-form.js diff --git a/conversejs/custom/plugins/poll/components/poll-form-view.js b/conversejs/custom/plugins/poll/components/poll-form-view.js new file mode 100644 index 00000000..df171595 --- /dev/null +++ b/conversejs/custom/plugins/poll/components/poll-form-view.js @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only +import { XMLNS_POLL } from '../constants.js' +import { tplPollForm } from '../templates/poll-form.js' +import { CustomElement } from 'shared/components/element.js' +import { converse, api } from '@converse/headless/core' +import { webForm2xForm } from '@converse/headless/utils/form' +import { __ } from 'i18n' +import '../styles/poll-form.scss' +const $iq = converse.env.$iq +const u = converse.env.utils +const sizzle = converse.env.sizzle +const Strophe = converse.env.Strophe + +export default class MUCPollFormView extends CustomElement { + static get properties () { + return { + model: { type: Object, attribute: true }, + modal: { type: Object, attribute: true }, + form_fields: { type: Object, attribute: false }, + alert_message: { type: Object, attribute: false }, + title: { type: String, attribute: false }, + instructions: { type: String, attribute: false } + } + } + + async initialize () { + this.alert_message = undefined + if (!this.model) { + this.alert_message = __('Error') + return + } + try { + const stanza = await this._fetchPollForm() + const query = stanza.querySelector('query') + const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query)[0] + if (!xform) { + throw Error('Missing xform in stanza') + } + + this.title = xform.querySelector('title')?.textContent ?? '' + this.instructions = xform.querySelector('instructions')?.textContent ?? '' + this.form_fields = Array.from(xform.querySelectorAll('field')).map(field => { + return u.xForm2TemplateResult(field, stanza) + }) + } catch (err) { + console.error(err) + this.alert_message = __('Error') + } + } + + render () { + return tplPollForm(this) + } + + _fetchPollForm () { + return api.sendIQ( + $iq({ + to: this.model.get('jid'), + type: 'get' + }).c('query', { xmlns: XMLNS_POLL }) + ) + } + + async formSubmit (ev) { + ev.preventDefault() + try { + this.alert_message = undefined + const form = ev.target + const inputs = sizzle(':input:not([type=button]):not([type=submit])', form) + + const iq = $iq({ + type: 'set', + id: u.getUniqueId() + }).c('query', { xmlns: XMLNS_POLL }) + + iq.c('x', { xmlns: Strophe.NS.XFORM, type: 'submit' }) + + const xmlNodes = inputs.map(i => webForm2xForm(i)).filter(n => n) + xmlNodes.forEach(n => iq.cnode(n).up()) + + await api.sendIQ(iq) + + if (this.modal) { + this.modal.hide() + } + } catch (err) { + console.error(err) + this.alert_message = __('Error') + } + } +} + +api.elements.define('livechat-converse-poll-form', MUCPollFormView) diff --git a/conversejs/custom/plugins/poll/index.js b/conversejs/custom/plugins/poll/index.js index 798b1037..bd345204 100644 --- a/conversejs/custom/plugins/poll/index.js +++ b/conversejs/custom/plugins/poll/index.js @@ -5,6 +5,8 @@ import { _converse, converse } from '../../../src/headless/core.js' import { getHeadingButtons } from './utils.js' // import { XMLNS_POLL } from './constants.js' +import './modals/poll-form.js' +import './components/poll-form-view.js' converse.plugins.add('livechat-converse-poll', { dependencies: ['converse-muc', 'converse-disco'], diff --git a/conversejs/custom/plugins/poll/modals/poll-form.js b/conversejs/custom/plugins/poll/modals/poll-form.js new file mode 100644 index 00000000..137e65d0 --- /dev/null +++ b/conversejs/custom/plugins/poll/modals/poll-form.js @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { __ } from 'i18n' +import BaseModal from 'plugins/modal/modal.js' +import { api } from '@converse/headless/core' +import { modal_close_button as ModalCloseButton } from 'plugins/modal/templates/buttons.js' +import { html } from 'lit' +import 'livechat-external-login-content.js' + +class PollFormModal extends BaseModal { + initialize () { + super.initialize() + } + + renderModal () { + return html`` + } + + getModalTitle () { + // eslint-disable-next-line no-undef + return __(LOC_new_poll) + } + + renderModalFooter () { + return html` + + ` + } +} + +api.elements.define('livechat-converse-poll-form-modal', PollFormModal) diff --git a/conversejs/custom/plugins/poll/styles/poll-form.scss b/conversejs/custom/plugins/poll/styles/poll-form.scss new file mode 100644 index 00000000..6476d183 --- /dev/null +++ b/conversejs/custom/plugins/poll/styles/poll-form.scss @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2024 John Livingston + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +livechat-converse-poll-form-modal { + /* Special case: when the form is in a modal */ + + .converse-form { + max-height: 50vh; + overflow-y: scroll; + } +} diff --git a/conversejs/custom/plugins/poll/templates/poll-form.js b/conversejs/custom/plugins/poll/templates/poll-form.js new file mode 100644 index 00000000..5316f099 --- /dev/null +++ b/conversejs/custom/plugins/poll/templates/poll-form.js @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { html } from 'lit' +import { __ } from 'i18n' + +export function tplPollForm (el) { + const i18nOk = __('Ok') + + return html` + ${el.alert_message ? html`
${el.alert_message}
` : ''} + ${ + el.form_fields + ? html` +
el.formSubmit(ev)}> +

${el.title}

+

${el.instructions}

+ + + ${el.form_fields} + + ${ + el.modal + ? html`` // no need for submit button, the modal will have one in the footer + : html`
+ +
` + } +
` + : '' + }` +} diff --git a/conversejs/custom/plugins/poll/utils.js b/conversejs/custom/plugins/poll/utils.js index acddfcab..9a4a3812 100644 --- a/conversejs/custom/plugins/poll/utils.js +++ b/conversejs/custom/plugins/poll/utils.js @@ -3,18 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-only import { XMLNS_POLL } from './constants.js' -import { converse, _converse, api } from '../../../src/headless/core.js' +import { _converse, api } from '../../../src/headless/core.js' import { __ } from 'i18n' -const $iq = converse.env.$iq - -function _fetchPollForm (mucModel) { - return api.sendIQ( - $iq({ - to: mucModel.get('jid'), - type: 'get' - }).c('query', { xmlns: XMLNS_POLL }) - ) -} export function getHeadingButtons (view, buttons) { const muc = view.model @@ -39,9 +29,7 @@ export function getHeadingButtons (view, buttons) { i18n_text: __(LOC_new_poll), handler: async (ev) => { ev.preventDefault() - const r = await _fetchPollForm(muc) - // FIXME - console.info('Received poll form', r) + api.modal.show('livechat-converse-poll-form-modal', { model: muc }) }, a_class: '', icon_class: 'fa-list-check', // FIXME diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 6386d493..1e12d025 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -21,9 +21,63 @@ local function get_form_layout(room, stanza) table.insert(form, { name = "muc#roompoll_question"; + type = "text-single"; label = "Question"; + desc = "The poll question."; value = ""; }); + table.insert(form, { + name = "muc#roompoll_duration"; + type = "text-single"; + datatype = "xs:integer"; + range_min = 1; + label = "Poll duration (in minutes)"; + desc = "The number of minutes to run the poll."; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_anonymous"; + type = "text-single"; + label = "Anonymous results"; + desc = "By enabling this, user's votes won't be publicly shown in the room."; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_choice1"; + type = "text-single"; + label = "Choice 1"; + desc = ""; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_choice2"; + type = "text-single"; + label = "Choice 2"; + desc = ""; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_choice3"; + type = "text-single"; + label = "Choice 3"; + desc = ""; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_choice4"; + type = "text-single"; + label = "Choice 4"; + desc = ""; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_choice5"; + type = "text-single"; + label = "Choice 5"; + desc = ""; + value = ""; + }); + return form; end From a7250efd0672e9574c387610149f8ad751d0d7a5 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Sat, 29 Jun 2024 18:15:04 +0200 Subject: [PATCH 05/34] Poll WIP (#231): * backend save WIP * various fixes --- .../plugins/poll/components/poll-form-view.js | 13 ++- .../plugins/poll/templates/poll-form.js | 10 +- prosody-modules/mod_muc_poll/mod_muc_poll.lua | 105 ++++++++++++++++-- 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/conversejs/custom/plugins/poll/components/poll-form-view.js b/conversejs/custom/plugins/poll/components/poll-form-view.js index df171595..32110ab7 100644 --- a/conversejs/custom/plugins/poll/components/poll-form-view.js +++ b/conversejs/custom/plugins/poll/components/poll-form-view.js @@ -72,6 +72,7 @@ export default class MUCPollFormView extends CustomElement { const iq = $iq({ type: 'set', + to: this.model.get('jid'), id: u.getUniqueId() }).c('query', { xmlns: XMLNS_POLL }) @@ -83,9 +84,19 @@ export default class MUCPollFormView extends CustomElement { await api.sendIQ(iq) if (this.modal) { - this.modal.hide() + this.modal.onHide() } } catch (err) { + if (u.isErrorStanza(err)) { + // Checking if there is a text error that we can show to the user. + if (sizzle('error bad-request', err).length) { + const text = sizzle('error text', err) + if (text.length) { + this.alert_message = __('Error') + ': ' + text[0].textContent + return + } + } + } console.error(err) this.alert_message = __('Error') } diff --git a/conversejs/custom/plugins/poll/templates/poll-form.js b/conversejs/custom/plugins/poll/templates/poll-form.js index 5316f099..085ea040 100644 --- a/conversejs/custom/plugins/poll/templates/poll-form.js +++ b/conversejs/custom/plugins/poll/templates/poll-form.js @@ -20,13 +20,9 @@ export function tplPollForm (el) { ${el.form_fields} - ${ - el.modal - ? html`` // no need for submit button, the modal will have one in the footer - : html`
- -
` - } +
+ +
` : '' }` diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 1e12d025..43e809ba 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -1,6 +1,8 @@ local st = require "util.stanza"; local dataform = require "util.dataforms"; +local get_form_type = require "util.dataforms".get_type; local jid_bare = require "util.jid".bare; +local get_time = require "util.time".now; local mod_muc = module:depends"muc"; local get_room_from_jid = mod_muc.get_room_from_jid; @@ -25,6 +27,7 @@ local function get_form_layout(room, stanza) label = "Question"; desc = "The poll question."; value = ""; + required = true; }); table.insert(form, { name = "muc#roompoll_duration"; @@ -34,13 +37,13 @@ local function get_form_layout(room, stanza) label = "Poll duration (in minutes)"; desc = "The number of minutes to run the poll."; value = ""; + required = true; }); table.insert(form, { name = "muc#roompoll_anonymous"; - type = "text-single"; + type = "boolean"; label = "Anonymous results"; desc = "By enabling this, user's votes won't be publicly shown in the room."; - value = ""; }); table.insert(form, { name = "muc#roompoll_choice1"; @@ -48,6 +51,7 @@ local function get_form_layout(room, stanza) label = "Choice 1"; desc = ""; value = ""; + required = true; }); table.insert(form, { name = "muc#roompoll_choice2"; @@ -55,6 +59,7 @@ local function get_form_layout(room, stanza) label = "Choice 2"; desc = ""; value = ""; + required = true; }); table.insert(form, { name = "muc#roompoll_choice3"; @@ -88,13 +93,73 @@ local function send_form(room, origin, stanza) ) ; end --- module:hook("iq/bare", function (event) --- local stanza = event.stanza; --- local type = stanza.attr.type; --- local child = stanza.tags[1]; --- local xmlns = child.attr.xmlns or "jabber:client"; --- module:log("info", "coucou %s %s %s", type, xmlns, child.name); --- end, 1000); +local function dataform_error_message(err) + local out = {}; + for field, errmsg in pairs(err) do + table.insert(out, ("%s: %s"):format(field, errmsg)) + end + return table.concat(out, "; "); +end + +local function process_form(room, origin, stanza) + if not stanza.tags[1] then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + end + local form = stanza.tags[1]:get_child("x", "jabber:x:data"); + if not form then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + end + + local form_type, err = get_form_type(form); + if not form_type then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err)); + return true; + elseif form_type ~= xmlns_poll then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_poll.."'")); + return true; + end + + if form.attr.type == "cancel" then + origin.send(st.reply(stanza)); + return true; + elseif form.attr.type ~= "submit" then + origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); + return true; + end + + -- form submitted + local fields, errors, present = get_form_layout(room, stanza.attr.from):data(form); + if errors then + origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(errors))); + return true; + end + + -- stop any poll that is already here + end_current_poll(room); + + -- create the new poll + create_poll(room, fields); + + origin.send(st.reply(stanza)); + return true; +end + +local function end_current_poll (room) + if not room._data.current_poll then + return; + end + -- TODO: compute and send last result. + room._data.current_poll = nil; +end + +local function create_poll(room, fields) + room._data.current_poll = fields; + room._data.current_poll.end_timestamp = now() + (60 * fields["muc#roompoll_duration"]); + room._data.current_poll.votes = {}; + -- TODO: create and send poll message. +end -- new poll creation, get form module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) @@ -118,6 +183,28 @@ module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) return true; end); + +-- new poll creation, form submission +module:hook("iq-set/bare/" .. xmlns_poll .. ":query", function (event) + local origin, stanza = event.origin, event.stanza; + local room_jid = stanza.attr.to; + module:log("debug", "Received a form submission for the poll form"); + local room = get_room_from_jid(room_jid); + if not room then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + end + local from = jid_bare(stanza.attr.from); + + local from_affiliation = room:get_affiliation(from); + if (from_affiliation ~= "owner" and from_affiliation ~= "admin") then + origin.send(st.error_reply(stanza, "auth", "forbidden")) + return true; + end + + return process_form(room, origin, stanza); +end); + -- Discovering support module:hook("muc-disco#info", function (event) event.reply:tag("feature", { var = xmlns_poll }):up(); From 300eb14ca4a3906f1c984e9af0bf639fd43778ec Mon Sep 17 00:00:00 2001 From: John Livingston Date: Sat, 29 Jun 2024 18:39:22 +0200 Subject: [PATCH 06/34] Poll WIP (#231): * mod_muc_poll refactoring --- .../mod_muc_poll/constants.lib.lua | 9 + prosody-modules/mod_muc_poll/form.lib.lua | 152 ++++++++++++++++ prosody-modules/mod_muc_poll/mod_muc_poll.lua | 171 ++---------------- prosody-modules/mod_muc_poll/poll.lib.lua | 24 +++ 4 files changed, 199 insertions(+), 157 deletions(-) create mode 100644 prosody-modules/mod_muc_poll/constants.lib.lua create mode 100644 prosody-modules/mod_muc_poll/form.lib.lua create mode 100644 prosody-modules/mod_muc_poll/poll.lib.lua diff --git a/prosody-modules/mod_muc_poll/constants.lib.lua b/prosody-modules/mod_muc_poll/constants.lib.lua new file mode 100644 index 00000000..759e274c --- /dev/null +++ b/prosody-modules/mod_muc_poll/constants.lib.lua @@ -0,0 +1,9 @@ +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only + +-- FIXME: create a XEP to standardize this, and remove the "x-". +local xmlns_poll = "http://jabber.org/protocol/muc#x-poll"; + +return { + xmlns_poll = xmlns_poll; +}; diff --git a/prosody-modules/mod_muc_poll/form.lib.lua b/prosody-modules/mod_muc_poll/form.lib.lua new file mode 100644 index 00000000..169f17eb --- /dev/null +++ b/prosody-modules/mod_muc_poll/form.lib.lua @@ -0,0 +1,152 @@ +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only + +local st = require "util.stanza"; +local dataform = require "util.dataforms"; +local get_form_type = require "util.dataforms".get_type; +local xmlns_poll = module:require("constants").xmlns_poll; + +local end_current_poll = module:require("poll").end_current_poll; +local create_poll = module:require("poll").create_poll; + +local function get_form_layout(room, stanza) + local form = dataform.new({ + title = "New poll", + instructions = "Complete and submit this form to create a new poll. This will end and replace any existing poll.", + { + name = "FORM_TYPE"; + type = "hidden"; + value = xmlns_poll; + } + }); + + table.insert(form, { + name = "muc#roompoll_question"; + type = "text-single"; + label = "Question"; + desc = "The poll question."; + value = ""; + required = true; + }); + table.insert(form, { + name = "muc#roompoll_duration"; + type = "text-single"; + datatype = "xs:integer"; + range_min = 1; + label = "Poll duration (in minutes)"; + desc = "The number of minutes to run the poll."; + value = ""; + required = true; + }); + table.insert(form, { + name = "muc#roompoll_anonymous"; + type = "boolean"; + label = "Anonymous results"; + desc = "By enabling this, user's votes won't be publicly shown in the room."; + }); + table.insert(form, { + name = "muc#roompoll_choice1"; + type = "text-single"; + label = "Choice 1"; + desc = ""; + value = ""; + required = true; + }); + table.insert(form, { + name = "muc#roompoll_choice2"; + type = "text-single"; + label = "Choice 2"; + desc = ""; + value = ""; + required = true; + }); + table.insert(form, { + name = "muc#roompoll_choice3"; + type = "text-single"; + label = "Choice 3"; + desc = ""; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_choice4"; + type = "text-single"; + label = "Choice 4"; + desc = ""; + value = ""; + }); + table.insert(form, { + name = "muc#roompoll_choice5"; + type = "text-single"; + label = "Choice 5"; + desc = ""; + value = ""; + }); + + return form; +end + +local function send_form(room, origin, stanza) + module:log("debug", "Sending the poll form"); + origin.send(st.reply(stanza):query(xmlns_poll) + :add_child(get_form_layout(room, stanza.attr.from):form()) +) ; +end + +local function dataform_error_message(err) + local out = {}; + for field, errmsg in pairs(err) do + table.insert(out, ("%s: %s"):format(field, errmsg)) + end + return table.concat(out, "; "); +end + + +local function process_form(room, origin, stanza) + if not stanza.tags[1] then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + end + local form = stanza.tags[1]:get_child("x", "jabber:x:data"); + if not form then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + end + + local form_type, err = get_form_type(form); + if not form_type then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err)); + return true; + elseif form_type ~= xmlns_poll then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_poll.."'")); + return true; + end + + if form.attr.type == "cancel" then + origin.send(st.reply(stanza)); + return true; + elseif form.attr.type ~= "submit" then + origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); + return true; + end + + -- form submitted + local fields, errors, present = get_form_layout(room, stanza.attr.from):data(form); + if errors then + origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(errors))); + return true; + end + + -- stop any poll that is already here + end_current_poll(room); + + -- create the new poll + create_poll(room, fields); + + origin.send(st.reply(stanza)); + return true; +end + +return { + send_form = send_form; + process_form = process_form; +}; diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 43e809ba..2af76e34 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -1,165 +1,23 @@ +-- mod_muc_poll +-- +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only +-- +-- This file is AGPL-v3 licensed. +-- Please see the Peertube livechat plugin copyright information. +-- https://livingston.frama.io/peertube-plugin-livechat/credits/ +-- +-- Implements: XEP-????: MUC Poll (XEP to come). + local st = require "util.stanza"; -local dataform = require "util.dataforms"; -local get_form_type = require "util.dataforms".get_type; local jid_bare = require "util.jid".bare; -local get_time = require "util.time".now; local mod_muc = module:depends"muc"; local get_room_from_jid = mod_muc.get_room_from_jid; --- FIXME: create a XEP to standardize this, and remove the "x-". -local xmlns_poll = "http://jabber.org/protocol/muc#x-poll"; - -local function get_form_layout(room, stanza) - local form = dataform.new({ - title = "New poll", - instructions = "Complete and submit this form to create a new poll. This will end and replace any existing poll.", - { - name = "FORM_TYPE"; - type = "hidden"; - value = xmlns_poll; - } - }); - - table.insert(form, { - name = "muc#roompoll_question"; - type = "text-single"; - label = "Question"; - desc = "The poll question."; - value = ""; - required = true; - }); - table.insert(form, { - name = "muc#roompoll_duration"; - type = "text-single"; - datatype = "xs:integer"; - range_min = 1; - label = "Poll duration (in minutes)"; - desc = "The number of minutes to run the poll."; - value = ""; - required = true; - }); - table.insert(form, { - name = "muc#roompoll_anonymous"; - type = "boolean"; - label = "Anonymous results"; - desc = "By enabling this, user's votes won't be publicly shown in the room."; - }); - table.insert(form, { - name = "muc#roompoll_choice1"; - type = "text-single"; - label = "Choice 1"; - desc = ""; - value = ""; - required = true; - }); - table.insert(form, { - name = "muc#roompoll_choice2"; - type = "text-single"; - label = "Choice 2"; - desc = ""; - value = ""; - required = true; - }); - table.insert(form, { - name = "muc#roompoll_choice3"; - type = "text-single"; - label = "Choice 3"; - desc = ""; - value = ""; - }); - table.insert(form, { - name = "muc#roompoll_choice4"; - type = "text-single"; - label = "Choice 4"; - desc = ""; - value = ""; - }); - table.insert(form, { - name = "muc#roompoll_choice5"; - type = "text-single"; - label = "Choice 5"; - desc = ""; - value = ""; - }); - - return form; -end - -local function send_form(room, origin, stanza) - module:log("debug", "Sending the poll form"); - origin.send(st.reply(stanza):query(xmlns_poll) - :add_child(get_form_layout(room, stanza.attr.from):form()) -) ; -end - -local function dataform_error_message(err) - local out = {}; - for field, errmsg in pairs(err) do - table.insert(out, ("%s: %s"):format(field, errmsg)) - end - return table.concat(out, "; "); -end - -local function process_form(room, origin, stanza) - if not stanza.tags[1] then - origin.send(st.error_reply(stanza, "modify", "bad-request")); - return true; - end - local form = stanza.tags[1]:get_child("x", "jabber:x:data"); - if not form then - origin.send(st.error_reply(stanza, "modify", "bad-request")); - return true; - end - - local form_type, err = get_form_type(form); - if not form_type then - origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err)); - return true; - elseif form_type ~= xmlns_poll then - origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_poll.."'")); - return true; - end - - if form.attr.type == "cancel" then - origin.send(st.reply(stanza)); - return true; - elseif form.attr.type ~= "submit" then - origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); - return true; - end - - -- form submitted - local fields, errors, present = get_form_layout(room, stanza.attr.from):data(form); - if errors then - origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(errors))); - return true; - end - - -- stop any poll that is already here - end_current_poll(room); - - -- create the new poll - create_poll(room, fields); - - origin.send(st.reply(stanza)); - return true; -end - -local function end_current_poll (room) - if not room._data.current_poll then - return; - end - -- TODO: compute and send last result. - room._data.current_poll = nil; -end - -local function create_poll(room, fields) - room._data.current_poll = fields; - room._data.current_poll.end_timestamp = now() + (60 * fields["muc#roompoll_duration"]); - room._data.current_poll.votes = {}; - -- TODO: create and send poll message. -end +local xmlns_poll = module:require("constants").xmlns_poll; +local send_form = module:require("form").send_form; +local process_form = module:require("form").process_form; -- new poll creation, get form module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) @@ -183,7 +41,6 @@ module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) return true; end); - -- new poll creation, form submission module:hook("iq-set/bare/" .. xmlns_poll .. ":query", function (event) local origin, stanza = event.origin, event.stanza; diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua new file mode 100644 index 00000000..8b1607e8 --- /dev/null +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -0,0 +1,24 @@ +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only + +local get_time = require "util.time".now; + +local function end_current_poll (room) + if not room._data.current_poll then + return; + end + -- TODO: compute and send last result. + room._data.current_poll = nil; +end + +local function create_poll(room, fields) + room._data.current_poll = fields; + room._data.current_poll.end_timestamp = now() + (60 * fields["muc#roompoll_duration"]); + room._data.current_poll.votes = {}; + -- TODO: create and send poll message. +end + +return { + end_current_poll = end_current_poll; + create_poll = create_poll; +}; From e779a669c8ef95386ac846ebc829332dfbfae642 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Sat, 29 Jun 2024 19:33:37 +0200 Subject: [PATCH 07/34] Poll WIP (#231): * poll backend WIP --- prosody-modules/mod_muc_poll/mod_muc_poll.lua | 4 + prosody-modules/mod_muc_poll/poll.lib.lua | 88 ++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 2af76e34..5b1ac707 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -18,6 +18,7 @@ local get_room_from_jid = mod_muc.get_room_from_jid; local xmlns_poll = module:require("constants").xmlns_poll; local send_form = module:require("form").send_form; local process_form = module:require("form").process_form; +local handle_groupchat = module:require("poll").handle_groupchat; -- new poll creation, get form module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) @@ -66,3 +67,6 @@ end); module:hook("muc-disco#info", function (event) event.reply:tag("feature", { var = xmlns_poll }):up(); end); + +-- on groupchat messages, we check if this is a vote for the current poll +module:hook("muc-occupant-groupchat", handle_groupchat); diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index 8b1607e8..4ff76658 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -1,6 +1,7 @@ -- SPDX-FileCopyrightText: 2024 John Livingston -- SPDX-License-Identifier: AGPL-3.0-only +local st = require "util.stanza"; local get_time = require "util.time".now; local function end_current_poll (room) @@ -8,17 +9,100 @@ local function end_current_poll (room) return; end -- TODO: compute and send last result. + module:log("debug", "Ending the current poll for room %s", room.jid); room._data.current_poll = nil; end local function create_poll(room, fields) + module:log("debug", "Creating a new poll for room %s", room.jid); room._data.current_poll = fields; - room._data.current_poll.end_timestamp = now() + (60 * fields["muc#roompoll_duration"]); - room._data.current_poll.votes = {}; + room._data.current_poll.end_timestamp = get_time() + (60 * fields["muc#roompoll_duration"]); + room._data.current_poll.votes_by_occupant = {}; + room._data.current_poll.votes_by_choices = {}; + for field, _ in pairs(fields) do + local c = field:match("^muc#roompoll_choice(%d+)$"); + if c then + if fields["muc#roompoll_choice" .. c]:find("%S") then + room._data.current_poll.votes_by_choices[c] = 0; + end + end + end -- TODO: create and send poll message. end +local function handle_groupchat(event) + local origin, stanza = event.origin, event.stanza; + local room = event.room; + if not room._data.current_poll then + return; + end + + -- There is a current poll. Is this a vote? + local body = stanza:get_child_text("body") + if not body or #body < 1 then + return; + end + local choice = body:match("^%s*!(%d+)%s*$"); + if not choice then + return; + end + + -- Ok, seems it is a vote. + + if get_time() > room._data.current_poll.end_timestamp then + module:log("debug", "Got a vote for a finished poll, not counting it."); + -- Note: we keep bouncing messages a few seconds/minutes after the poll end + -- to be sure any user that send the vote too late won't expose his choice. + origin.send(st.error_reply( + stanza, + -- error_type = 'cancel' (see descriptions in RFC 6120 https://xmpp.org/rfcs/rfc6120.html#stanzas-error-syntax) + "cancel", + -- error_condition = 'not-allowed' (see RFC 6120 Defined Error Conditions https://xmpp.org/rfcs/rfc6120.html#stanzas-error-conditions) + "not-allowed", + "This poll is over." + )); + return true; -- stop! + end + + -- We must check that the choice is valid: + if room._data.current_poll.votes_by_choices[choice] == nil then + module:log("debug", "Invalid vote, bouncing it."); + origin.send(st.error_reply( + stanza, + -- error_type = 'cancel' (see descriptions in RFC 6120 https://xmpp.org/rfcs/rfc6120.html#stanzas-error-syntax) + "cancel", + -- error_condition = 'not-allowed' (see RFC 6120 Defined Error Conditions https://xmpp.org/rfcs/rfc6120.html#stanzas-error-conditions) + "bad-request", + "This choice is not valid." + )); + return true; -- stop! + end + + -- Ok, we can count the vote. + local occupant = event.occupant; + local id = occupant.jid; -- FIXME: is this the correct value? or bare_jid? + module:log("debug", "Counting a new vote for room %s: choice %i, voter %s", room.jid, choice, id); + + -- TODO: count the vote. + + -- When the poll is anonymous, we bounce the messages (but count the votes). + local must_bounce = room._data.current_poll["muc#roompoll_anonymous"] == true; + if must_bounce then + module:log("debug", "Invalid vote, bouncing it."); + origin.send(st.error_reply( + stanza, + -- error_type + "continue", + -- error_condition + "undefined-condition", + "You vote is taken into account. Votes are anonymous, it will not be shown to other participants." + )); + return true; -- stop! + end +end + return { end_current_poll = end_current_poll; create_poll = create_poll; + handle_groupchat = handle_groupchat; }; From 22076e992937ef86b3594b835feaa661763ffeb0 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Sun, 30 Jun 2024 15:30:33 +0200 Subject: [PATCH 08/34] Poll WIP (#231): * poll backend WIP --- prosody-modules/mod_muc_poll/form.lib.lua | 1 + prosody-modules/mod_muc_poll/message.lib.lua | 20 +++ prosody-modules/mod_muc_poll/mod_muc_poll.lua | 10 +- prosody-modules/mod_muc_poll/poll.lib.lua | 130 +++++++++++++++++- 4 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 prosody-modules/mod_muc_poll/message.lib.lua diff --git a/prosody-modules/mod_muc_poll/form.lib.lua b/prosody-modules/mod_muc_poll/form.lib.lua index 169f17eb..47466e85 100644 --- a/prosody-modules/mod_muc_poll/form.lib.lua +++ b/prosody-modules/mod_muc_poll/form.lib.lua @@ -43,6 +43,7 @@ local function get_form_layout(room, stanza) type = "boolean"; label = "Anonymous results"; desc = "By enabling this, user's votes won't be publicly shown in the room."; + value = true; }); table.insert(form, { name = "muc#roompoll_choice1"; diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua new file mode 100644 index 00000000..884e6258 --- /dev/null +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -0,0 +1,20 @@ +-- SPDX-FileCopyrightText: 2024 John Livingston +-- SPDX-License-Identifier: AGPL-3.0-only + +local function poll_start_message(room) + -- TODO +end + +local function schedule_poll_update_message(room) + -- TODO +end + +local function poll_end_message(room) + -- TODO +end + +return { + poll_start_message = poll_start_message; + poll_end_message = poll_end_message; + schedule_poll_update_message = schedule_poll_update_message; +}; diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 5b1ac707..2d8fef3b 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -19,6 +19,7 @@ local xmlns_poll = module:require("constants").xmlns_poll; local send_form = module:require("form").send_form; local process_form = module:require("form").process_form; local handle_groupchat = module:require("poll").handle_groupchat; +local room_restored = module:require("poll").room_restored; -- new poll creation, get form module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) @@ -68,5 +69,10 @@ module:hook("muc-disco#info", function (event) event.reply:tag("feature", { var = xmlns_poll }):up(); end); --- on groupchat messages, we check if this is a vote for the current poll -module:hook("muc-occupant-groupchat", handle_groupchat); +-- On groupchat messages, we check if this is a vote for the current poll. +-- Note: we use a high priority, so it will be handled before the slow mode. +module:hook("muc-occupant-groupchat", handle_groupchat, 1000); + +-- when a room is restored (after a server restart for example), +-- we must resume any current poll +module:hook("muc-room-restored", room_restored); diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index 4ff76658..c6ff9757 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -3,14 +3,91 @@ local st = require "util.stanza"; local get_time = require "util.time".now; +local timer = require "util.timer"; +local mod_muc = module:depends"muc"; +local get_room_from_jid = mod_muc.get_room_from_jid; +local poll_start_message = module:require("message").poll_start_message; +local poll_end_message = module:require("message").poll_end_message; +local schedule_poll_update_message = module:require("message").schedule_poll_update_message; + +local scheduled_end = {}; + +local function schedule_poll_purge(room_jid) + module:log("debug", "Scheduling a purge for poll %s", room_jid); + timer.add_task(30, function () + module:log("info", "Must purge poll for room %s", room_jid); + -- We dont pass room, because here it could have been removed from memory. + -- So we must relad the room from the JID in any case. + local room = get_room_from_jid(room_jid); + if not room then + module:log("debug", "Room %s not found, was maybe destroyed", room_jid); + return; + end + -- we must check if the poll is ended (could be a new poll!) + if not room._data.current_poll then + module:log("debug", "Room %s has no current poll to purge", room_jid); + return; + end + if not room._data.current_poll.already_ended then + module:log("debug", "Room %s has has a poll that is not ended, must be a new one", room_jid); + return; + end + module:log("info", "Purging poll for room %s", room_jid); + room._data.current_poll = nil; + end); +end local function end_current_poll (room) if not room._data.current_poll then return; end - -- TODO: compute and send last result. + + if room._data.current_poll.already_ended then + -- this can happen if the server was restarted before the purge + schedule_poll_purge(room.jid); + return; + end + module:log("debug", "Ending the current poll for room %s", room.jid); - room._data.current_poll = nil; + room._data.current_poll.already_ended = true; + + if scheduled_end[room.jid] then + module:log("debug", "Stopping the end timer for the poll"); + timer.stop(scheduled_end[room_jid]); + scheduled_end[room_jid] = nil; + end + poll_end_message(room); + -- TODO: store the result somewhere, to keep track? + + -- We don't remove the poll immediatly. Indeed, if the vote is anonymous, + -- we don't want to expose votes from late users. + schedule_poll_purge(room.jid); +end + +local function schedule_poll_end (room_jid, timestamp) + local delay = timestamp - get_time(); + if delay <= 0 then + delay = 1; + end + module:log("debug", "Must schedule a poll end in %i for room %s", delay, room_jid); + + if scheduled_end[room_jid] then + module:log("debug", "There is already a timer for the %s poll end, rescheduling", room_jid); + timer.reschedule(scheduled_end[room_jid], delay); + return; + end + scheduled_end[room_jid] = timer.add_task(delay, function () + module:log("debug", "Its time to end the poll for room %s", room_jid); + scheduled_end[room_jid] = nil; + -- We dont pass room, because here it could have been removed from memory. + -- So we must relad the room from the JID in any case. + local room = get_room_from_jid(room_jid); + if not room then + module:log("debug", "Room %s not found, was probably destroyed", room_jid); + return; -- room was probably destroyed + end + end_current_poll(room); + end); end local function create_poll(room, fields) @@ -19,6 +96,7 @@ local function create_poll(room, fields) room._data.current_poll.end_timestamp = get_time() + (60 * fields["muc#roompoll_duration"]); room._data.current_poll.votes_by_occupant = {}; room._data.current_poll.votes_by_choices = {}; + room._data.current_poll.already_ended = false; for field, _ in pairs(fields) do local c = field:match("^muc#roompoll_choice(%d+)$"); if c then @@ -27,7 +105,8 @@ local function create_poll(room, fields) end end end - -- TODO: create and send poll message. + poll_start_message(room); + schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); end local function handle_groupchat(event) @@ -49,7 +128,7 @@ local function handle_groupchat(event) -- Ok, seems it is a vote. - if get_time() > room._data.current_poll.end_timestamp then + if get_time() >= room._data.current_poll.end_timestamp then module:log("debug", "Got a vote for a finished poll, not counting it."); -- Note: we keep bouncing messages a few seconds/minutes after the poll end -- to be sure any user that send the vote too late won't expose his choice. @@ -80,10 +159,22 @@ local function handle_groupchat(event) -- Ok, we can count the vote. local occupant = event.occupant; - local id = occupant.jid; -- FIXME: is this the correct value? or bare_jid? - module:log("debug", "Counting a new vote for room %s: choice %i, voter %s", room.jid, choice, id); + if not occupant then + module:log("warn", "No occupant in the event, dont know how to count the vote"); + return + end - -- TODO: count the vote. + local occupant_bare_id = occupant.bare_jid; + module:log("debug", "Counting a new vote for room %s: choice %i, voter %s", room.jid, choice, occupant_bare_id); + -- counting the vote: + if room._data.current_poll.votes_by_occupant[occupant_bare_id] ~= nil then + module:log("debug", "Occupant %s has already voted for current room %s vote, reassigning his vote.", occupant_bare_id); + room._data.current_poll.votes_by_choices[room._data.current_poll.votes_by_occupant[occupant_bare_id]] = room._data.current_poll.votes_by_choices[room._data.current_poll.votes_by_occupant[occupant_bare_id]] - 1; + end + room._data.current_poll.votes_by_choices[choice] = room._data.current_poll.votes_by_choices[choice] + 1; + room._data.current_poll.votes_by_occupant[occupant_bare_id] = choice; + + schedule_poll_update_message(room); -- When the poll is anonymous, we bounce the messages (but count the votes). local must_bounce = room._data.current_poll["muc#roompoll_anonymous"] == true; @@ -101,8 +192,33 @@ local function handle_groupchat(event) end end +local function room_restored(event) + local room = event.room; + if not room._data.current_poll then + return; + end + + module:log("info", "Restoring room %s with current ongoing poll.", room.jid); + local now = get_time(); + if now >= room._data.current_poll.end_timestamp then + module:log("info", "Current poll is over for room %s, ending it", room.jid); + end_current_poll(room); + return; + end + + if scheduled_end[room.jid] then + module:log("info", "Poll for room %s is not finished yet, the end is still scheduled", room.jid); + else + module:log("info", "Poll for room %s is not finished yet, rescheduling the end", room.jid); + schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); + end + -- just in case, we can also reschedule an update message + schedule_poll_update_message(room); +end + return { end_current_poll = end_current_poll; create_poll = create_poll; handle_groupchat = handle_groupchat; + room_restored = room_restored; }; From 212076c3a35aba1ca01d2f24e7ff46ff0b6bee79 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Sun, 30 Jun 2024 17:19:14 +0200 Subject: [PATCH 09/34] Poll WIP (#231): * poll backend WIP --- prosody-modules/mod_muc_poll/form.lib.lua | 4 +- prosody-modules/mod_muc_poll/message.lib.lua | 61 ++++++++++++++++++- prosody-modules/mod_muc_poll/mod_muc_poll.lua | 16 +++-- prosody-modules/mod_muc_poll/poll.lib.lua | 11 +++- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/prosody-modules/mod_muc_poll/form.lib.lua b/prosody-modules/mod_muc_poll/form.lib.lua index 47466e85..c86b7c3b 100644 --- a/prosody-modules/mod_muc_poll/form.lib.lua +++ b/prosody-modules/mod_muc_poll/form.lib.lua @@ -102,7 +102,7 @@ local function dataform_error_message(err) end -local function process_form(room, origin, stanza) +local function process_form(room, origin, stanza, occupant) if not stanza.tags[1] then origin.send(st.error_reply(stanza, "modify", "bad-request")); return true; @@ -141,7 +141,7 @@ local function process_form(room, origin, stanza) end_current_poll(room); -- create the new poll - create_poll(room, fields); + create_poll(room, fields, occupant); origin.send(st.reply(stanza)); return true; diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index 884e6258..feb94b42 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -1,12 +1,71 @@ -- SPDX-FileCopyrightText: 2024 John Livingston -- SPDX-License-Identifier: AGPL-3.0-only +local id = require "util.id"; +local st = require "util.stanza"; +local format = require"util.format".format; +local xmlns_occupant_id = "urn:xmpp:occupant-id:0"; + +local function build_poll_message(room, message_id) + local current_poll = room._data.current_poll; + if not current_poll then + return nil; + end + local from = room.jid .. '/' .. current_poll.occupant_nick; + + local content = current_poll["muc#roompoll_question"] .. "\n"; + + local total = 0; + for choice, nb in pairs(current_poll.votes_by_choices) do + total = total + nb; + end + for choice, label in pairs(current_poll.choices) do + content = content .. choice .. ': ' .. label; + if total > 0 then + local nb = current_poll.votes_by_choices[choice] or 0; + local percent = format("%d.%d%d", nb * 100 / total); + content = content .. " (" .. nb .. "/" .. total .. " = " .. percent .. "%)"; + end + content = content .. "\n"; + end + content = content .. "Send a message with an exclamation mark followed by your choice number to vote. Example: !1\n"; + + local msg = st.message({ + type = "groupchat", + from = from, + id = message_id + }, content); + + msg:tag("occupant-id", { + xmlns = xmlns_occupant_id, + id = current_poll.occupant_id + }):up(); + + return msg; +end + local function poll_start_message(room) - -- TODO + if not room._data.current_poll then + return nil; + end + module:log("debug", "Sending the start message for room %s poll", room.jid); + local message_id = id.medium(); + local msg = build_poll_message(room, message_id); + room:broadcast_message(msg); + return message_id; end local function schedule_poll_update_message(room) -- TODO + + -- if not room._data.current_poll then + -- return nil; + -- end + -- module:log("debug", "Sending an update message for room %s poll", room.jid); + -- local message_id = id.medium(); + -- local msg = build_poll_message(room, message_id); + -- room:broadcast_message(msg); + -- return message_id; end local function poll_end_message(room) diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 2d8fef3b..b8afaf61 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -47,21 +47,29 @@ end); module:hook("iq-set/bare/" .. xmlns_poll .. ":query", function (event) local origin, stanza = event.origin, event.stanza; local room_jid = stanza.attr.to; - module:log("debug", "Received a form submission for the poll form"); + local from = stanza.attr.from; + module:log("debug", "Received a form submission for the poll form on %s from %s", room_jid, from); local room = get_room_from_jid(room_jid); if not room then origin.send(st.error_reply(stanza, "cancel", "item-not-found")); return true; end - local from = jid_bare(stanza.attr.from); - local from_affiliation = room:get_affiliation(from); + local occupant = room:get_occupant_by_real_jid(from); + if not occupant then + module:log("debug", "No occupant, ignoring..."); + origin.send(st.error_reply(stanza, "auth", "forbidden")) + return true; + end + + local from_bare = jid_bare(stanza.attr.from); + local from_affiliation = room:get_affiliation(from_bare); if (from_affiliation ~= "owner" and from_affiliation ~= "admin") then origin.send(st.error_reply(stanza, "auth", "forbidden")) return true; end - return process_form(room, origin, stanza); + return process_form(room, origin, stanza, occupant); end); -- Discovering support diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index c6ff9757..6d15a8bd 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -90,22 +90,27 @@ local function schedule_poll_end (room_jid, timestamp) end); end -local function create_poll(room, fields) - module:log("debug", "Creating a new poll for room %s", room.jid); +local function create_poll(room, fields, occupant) + module:log("debug", "Creating a new poll for room %s, by %s", room.jid, occupant.bare_jid); room._data.current_poll = fields; room._data.current_poll.end_timestamp = get_time() + (60 * fields["muc#roompoll_duration"]); room._data.current_poll.votes_by_occupant = {}; room._data.current_poll.votes_by_choices = {}; + room._data.current_poll.choices = {}; room._data.current_poll.already_ended = false; + room._data.current_poll.occupant_bare_jid = occupant.bare_jid; + room._data.current_poll.occupant_nick = occupant.nick; + room._data.current_poll.occupant_id = room:get_occupant_id(occupant); for field, _ in pairs(fields) do local c = field:match("^muc#roompoll_choice(%d+)$"); if c then if fields["muc#roompoll_choice" .. c]:find("%S") then room._data.current_poll.votes_by_choices[c] = 0; + room._data.current_poll.choices[c] = fields["muc#roompoll_choice" .. c]; end end end - poll_start_message(room); + room._data.current_poll.start_message_id = poll_start_message(room); schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); end From b741959312564c0aa2331635806406e0cd1c07a5 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Sun, 30 Jun 2024 17:57:33 +0200 Subject: [PATCH 10/34] Poll WIP (#231): * poll backend WIP --- prosody-modules/mod_muc_poll/message.lib.lua | 82 ++++++++++++++++---- prosody-modules/mod_muc_poll/poll.lib.lua | 10 ++- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index feb94b42..0a1b8850 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -3,10 +3,18 @@ local id = require "util.id"; local st = require "util.stanza"; -local format = require"util.format".format; +local timer = require "util.timer"; local xmlns_occupant_id = "urn:xmpp:occupant-id:0"; +local xmlns_replace = "urn:xmpp:message-correct:0"; -local function build_poll_message(room, message_id) +local mod_muc = module:depends"muc"; +local get_room_from_jid = mod_muc.get_room_from_jid; + +local debounce_delay = 10; -- number of seconds during which we must group votes to avoid flood. +local scheduled_updates = {}; + +-- construct the poll message stanza +local function build_poll_message(room, message_id, is_end_message) local current_poll = room._data.current_poll; if not current_poll then return nil; @@ -15,6 +23,10 @@ local function build_poll_message(room, message_id) local content = current_poll["muc#roompoll_question"] .. "\n"; + if is_end_message then + content = content .. "This poll is now over.\n"; + end + local total = 0; for choice, nb in pairs(current_poll.votes_by_choices) do total = total + nb; @@ -23,12 +35,15 @@ local function build_poll_message(room, message_id) content = content .. choice .. ': ' .. label; if total > 0 then local nb = current_poll.votes_by_choices[choice] or 0; - local percent = format("%d.%d%d", nb * 100 / total); + local percent = string.format("%.2f", nb * 100 / total); content = content .. " (" .. nb .. "/" .. total .. " = " .. percent .. "%)"; end content = content .. "\n"; end - content = content .. "Send a message with an exclamation mark followed by your choice number to vote. Example: !1\n"; + + if not is_end_message then + content = content .. "Send a message with an exclamation mark followed by your choice number to vote. Example: !1\n"; + end local msg = st.message({ type = "groupchat", @@ -44,32 +59,67 @@ local function build_poll_message(room, message_id) return msg; end +-- sends a message when the poll starts. local function poll_start_message(room) if not room._data.current_poll then return nil; end module:log("debug", "Sending the start message for room %s poll", room.jid); local message_id = id.medium(); - local msg = build_poll_message(room, message_id); + local msg = build_poll_message(room, message_id, false); room:broadcast_message(msg); return message_id; end -local function schedule_poll_update_message(room) - -- TODO +-- Send the poll update message +local function send_poll_update_message(room) + if not room._data.current_poll then + return nil; + end + module:log("debug", "Sending an update message for room %s poll", room.jid); + local message_id = id.medium(); -- generate a new id + local msg = build_poll_message(room, message_id, false); - -- if not room._data.current_poll then - -- return nil; - -- end - -- module:log("debug", "Sending an update message for room %s poll", room.jid); - -- local message_id = id.medium(); - -- local msg = build_poll_message(room, message_id); - -- room:broadcast_message(msg); - -- return message_id; + -- the update message is a message (see XEP-0308). + msg:tag('replace', { + xmlns = xmlns_replace; + id = room._data.current_poll.start_message_id; + }):up(); + + room:broadcast_message(msg); + return message_id; end +-- Schedule an update of the start message. +-- We do not send this update each time someone vote, +-- to avoid flooding. +local function schedule_poll_update_message(room_jid) + if scheduled_updates[room_jid] then + -- already a running timer, we can ignore to debounce. + return; + end + scheduled_updates[room_jid] = timer.add_task(debounce_delay, function() + scheduled_updates[room_jid] = nil; + -- We dont pass room, because here it could have been removed from memory. + -- So we must relad the room from the JID in any case. + local room = get_room_from_jid(room_jid); + if not room then + return; + end + send_poll_update_message(room); + end); +end + +-- Send a new message when the poll is over, with the result. local function poll_end_message(room) - -- TODO + if not room._data.current_poll then + return nil; + end + module:log("debug", "Sending the end message for room %s poll", room.jid); + local message_id = id.medium(); -- generate a new id + local msg = build_poll_message(room, message_id, true); + room:broadcast_message(msg); + return message_id; end return { diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index 6d15a8bd..3af17acd 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -56,7 +56,9 @@ local function end_current_poll (room) timer.stop(scheduled_end[room_jid]); scheduled_end[room_jid] = nil; end + poll_end_message(room); + -- TODO: store the result somewhere, to keep track? -- We don't remove the poll immediatly. Indeed, if the vote is anonymous, @@ -173,18 +175,18 @@ local function handle_groupchat(event) module:log("debug", "Counting a new vote for room %s: choice %i, voter %s", room.jid, choice, occupant_bare_id); -- counting the vote: if room._data.current_poll.votes_by_occupant[occupant_bare_id] ~= nil then - module:log("debug", "Occupant %s has already voted for current room %s vote, reassigning his vote.", occupant_bare_id); + module:log("debug", "Occupant %s has already voted for current room %s vote, reassigning his vote.", occupant_bare_id, room.jid); room._data.current_poll.votes_by_choices[room._data.current_poll.votes_by_occupant[occupant_bare_id]] = room._data.current_poll.votes_by_choices[room._data.current_poll.votes_by_occupant[occupant_bare_id]] - 1; end room._data.current_poll.votes_by_choices[choice] = room._data.current_poll.votes_by_choices[choice] + 1; room._data.current_poll.votes_by_occupant[occupant_bare_id] = choice; - schedule_poll_update_message(room); + schedule_poll_update_message(room.jid); -- When the poll is anonymous, we bounce the messages (but count the votes). local must_bounce = room._data.current_poll["muc#roompoll_anonymous"] == true; if must_bounce then - module:log("debug", "Invalid vote, bouncing it."); + module:log("debug", "Anonymous votes, bouncing it."); origin.send(st.error_reply( stanza, -- error_type @@ -218,7 +220,7 @@ local function room_restored(event) schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); end -- just in case, we can also reschedule an update message - schedule_poll_update_message(room); + schedule_poll_update_message(room.jid); end return { From 5e6fd50c498098d2757e372af975a429678b6642 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Jul 2024 12:04:03 +0200 Subject: [PATCH 11/34] Poll WIP (#231): * fix backend. --- prosody-modules/mod_muc_poll/message.lib.lua | 12 +++++++++--- prosody-modules/mod_muc_poll/poll.lib.lua | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index 0a1b8850..73c07bee 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -10,7 +10,7 @@ local xmlns_replace = "urn:xmpp:message-correct:0"; local mod_muc = module:depends"muc"; local get_room_from_jid = mod_muc.get_room_from_jid; -local debounce_delay = 10; -- number of seconds during which we must group votes to avoid flood. +local debounce_delay = 5; -- number of seconds during which we must group votes to avoid flood. local scheduled_updates = {}; -- construct the poll message stanza @@ -31,10 +31,11 @@ local function build_poll_message(room, message_id, is_end_message) for choice, nb in pairs(current_poll.votes_by_choices) do total = total + nb; end - for choice, label in pairs(current_poll.choices) do + for _, choice_desc in ipairs(current_poll.choices_ordered) do + local choice, label = choice_desc.number, choice_desc.label; content = content .. choice .. ': ' .. label; if total > 0 then - local nb = current_poll.votes_by_choices[choice] or 0; + local nb = current_poll.votes_by_choices["" .. choice] or 0; local percent = string.format("%.2f", nb * 100 / total); content = content .. " (" .. nb .. "/" .. total .. " = " .. percent .. "%)"; end @@ -76,6 +77,11 @@ local function send_poll_update_message(room) if not room._data.current_poll then return nil; end + if room._data.current_poll.already_ended then + module:log("debug", "Cancelling the update message for room %s poll, because already_ended==true.", room.jid); + return nil; + end + module:log("debug", "Sending an update message for room %s poll", room.jid); local message_id = id.medium(); -- generate a new id local msg = build_poll_message(room, message_id, false); diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index 3af17acd..ff3f09ac 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -98,7 +98,7 @@ local function create_poll(room, fields, occupant) room._data.current_poll.end_timestamp = get_time() + (60 * fields["muc#roompoll_duration"]); room._data.current_poll.votes_by_occupant = {}; room._data.current_poll.votes_by_choices = {}; - room._data.current_poll.choices = {}; + room._data.current_poll.choices_ordered = {}; -- choices labels with numerical index, so we can have correct order room._data.current_poll.already_ended = false; room._data.current_poll.occupant_bare_jid = occupant.bare_jid; room._data.current_poll.occupant_nick = occupant.nick; @@ -108,10 +108,20 @@ local function create_poll(room, fields, occupant) if c then if fields["muc#roompoll_choice" .. c]:find("%S") then room._data.current_poll.votes_by_choices[c] = 0; - room._data.current_poll.choices[c] = fields["muc#roompoll_choice" .. c]; + table.insert(room._data.current_poll.choices_ordered, { + number = c; + label = fields["muc#roompoll_choice" .. c]; + }); end end end + table.sort(room._data.current_poll.choices_ordered, function(a, b) + if a.number == b.number then + return 0; + end + return tonumber(a.number) < tonumber(b.number); + end); + room._data.current_poll.start_message_id = poll_start_message(room); schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); end From c5b0176e950053369dc178668001c8cf994cbd83 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Jul 2024 12:36:32 +0200 Subject: [PATCH 12/34] Poll WIP (#231): * backend: custom xml --- .../mod_muc_poll/constants.lib.lua | 8 +++++ prosody-modules/mod_muc_poll/message.lib.lua | 33 ++++++++++++++++++- prosody-modules/mod_muc_poll/poll.lib.lua | 2 ++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/prosody-modules/mod_muc_poll/constants.lib.lua b/prosody-modules/mod_muc_poll/constants.lib.lua index 759e274c..eca16ce0 100644 --- a/prosody-modules/mod_muc_poll/constants.lib.lua +++ b/prosody-modules/mod_muc_poll/constants.lib.lua @@ -3,7 +3,15 @@ -- FIXME: create a XEP to standardize this, and remove the "x-". local xmlns_poll = "http://jabber.org/protocol/muc#x-poll"; +local xmlns_poll_message = "http://jabber.org/protocol/muc#x-poll-message"; +local poll_message_tag = "x-poll"; +local poll_question_tag = "x-poll-question"; +local poll_choice_tag = "x-poll-choice"; return { xmlns_poll = xmlns_poll; + xmlns_poll_message = xmlns_poll_message; + poll_message_tag = poll_message_tag; + poll_question_tag = poll_question_tag; + poll_choice_tag = poll_choice_tag; }; diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index 73c07bee..30135a00 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -6,6 +6,10 @@ local st = require "util.stanza"; local timer = require "util.timer"; local xmlns_occupant_id = "urn:xmpp:occupant-id:0"; local xmlns_replace = "urn:xmpp:message-correct:0"; +local xmlns_poll_message = module:require("constants").xmlns_poll_message; +local poll_message_tag = module:require("constants").poll_message_tag; +local poll_question_tag = module:require("constants").poll_question_tag; +local poll_choice_tag = module:require("constants").poll_choice_tag; local mod_muc = module:depends"muc"; local get_room_from_jid = mod_muc.get_room_from_jid; @@ -35,7 +39,7 @@ local function build_poll_message(room, message_id, is_end_message) local choice, label = choice_desc.number, choice_desc.label; content = content .. choice .. ': ' .. label; if total > 0 then - local nb = current_poll.votes_by_choices["" .. choice] or 0; + local nb = current_poll.votes_by_choices[choice] or 0; local percent = string.format("%.2f", nb * 100 / total); content = content .. " (" .. nb .. "/" .. total .. " = " .. percent .. "%)"; end @@ -57,6 +61,33 @@ local function build_poll_message(room, message_id, is_end_message) id = current_poll.occupant_id }):up(); + -- now we must add some custom XML data, so that compatible clients can display the poll as they want: + -- + -- Poll question + -- Choice 1 label + -- Choice 2 label + -- Choice 3 label + -- Choice 4 label + -- + local message_attrs = { + xmlns = xmlns_poll_message, + id = current_poll.poll_id, + votes = "" .. total + }; + if current_poll.already_ended then + message_attrs["over"] = ""; + end + msg:tag(poll_message_tag, message_attrs):text_tag(poll_question_tag, current_poll["muc#roompoll_question"], {}); + for _, choice_desc in ipairs(current_poll.choices_ordered) do + local choice, label = choice_desc.number, choice_desc.label; + local nb = current_poll.votes_by_choices[choice] or 0; + msg:text_tag(poll_choice_tag, label, { + votes = "" .. nb, + choice = choice + }); + end + msg:up(); + return msg; end diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index ff3f09ac..f6e110e7 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -1,6 +1,7 @@ -- SPDX-FileCopyrightText: 2024 John Livingston -- SPDX-License-Identifier: AGPL-3.0-only +local id = require "util.id"; local st = require "util.stanza"; local get_time = require "util.time".now; local timer = require "util.timer"; @@ -95,6 +96,7 @@ end local function create_poll(room, fields, occupant) module:log("debug", "Creating a new poll for room %s, by %s", room.jid, occupant.bare_jid); room._data.current_poll = fields; + room._data.current_poll.poll_id = id.short(); room._data.current_poll.end_timestamp = get_time() + (60 * fields["muc#roompoll_duration"]); room._data.current_poll.votes_by_occupant = {}; room._data.current_poll.votes_by_choices = {}; From 52391c922e5c6aaf9ec7ef49b6fa2189b37bd07b Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Jul 2024 14:36:59 +0200 Subject: [PATCH 13/34] Poll WIP (#231): * front-end: translate the form fields labels --- .../plugins/poll/components/poll-form-view.js | 34 +++++++++++++++++-- .../custom/plugins/poll/modals/poll-form.js | 1 - conversejs/loc.keys.js | 8 ++++- languages/en.yml | 6 ++++ languages/fr.yml | 9 +++++ 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/conversejs/custom/plugins/poll/components/poll-form-view.js b/conversejs/custom/plugins/poll/components/poll-form-view.js index 32110ab7..a0fba934 100644 --- a/conversejs/custom/plugins/poll/components/poll-form-view.js +++ b/conversejs/custom/plugins/poll/components/poll-form-view.js @@ -25,6 +25,8 @@ export default class MUCPollFormView extends CustomElement { } } + _fieldTranslationMap = new Map() + async initialize () { this.alert_message = undefined if (!this.model) { @@ -32,6 +34,7 @@ export default class MUCPollFormView extends CustomElement { return } try { + this._initFieldTranslations() const stanza = await this._fetchPollForm() const query = stanza.querySelector('query') const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query)[0] @@ -39,9 +42,12 @@ export default class MUCPollFormView extends CustomElement { throw Error('Missing xform in stanza') } - this.title = xform.querySelector('title')?.textContent ?? '' - this.instructions = xform.querySelector('instructions')?.textContent ?? '' + // eslint-disable-next-line no-undef + this.title = __(LOC_poll_title) // xform.querySelector('title')?.textContent ?? '' + // eslint-disable-next-line no-undef + this.instructions = __(LOC_poll_instructions) // xform.querySelector('instructions')?.textContent ?? '' this.form_fields = Array.from(xform.querySelectorAll('field')).map(field => { + this._translateField(field) return u.xForm2TemplateResult(field, stanza) }) } catch (err) { @@ -63,6 +69,30 @@ export default class MUCPollFormView extends CustomElement { ) } + _initFieldTranslations () { + // eslint-disable-next-line no-undef + this._fieldTranslationMap.set('muc#roompoll_question', __(LOC_poll_question)) + // eslint-disable-next-line no-undef + this._fieldTranslationMap.set('muc#roompoll_duration', __(LOC_poll_duration)) + // eslint-disable-next-line no-undef + this._fieldTranslationMap.set('muc#roompoll_anonymous_results', __(LOC_poll_anonymous_results)) + for (let i = 1; i <= 10; i++) { + this._fieldTranslationMap.set( + 'muc#roompoll_choice' + i.toString(), + // eslint-disable-next-line no-undef + __(LOC_poll_choice_n).replace('{{N}}', i.toString()) + ) + } + } + + _translateField (field) { + const v = field.getAttribute('var') + const label = this._fieldTranslationMap.get(v) + if (label) { + field.setAttribute('label', label) + } + } + async formSubmit (ev) { ev.preventDefault() try { diff --git a/conversejs/custom/plugins/poll/modals/poll-form.js b/conversejs/custom/plugins/poll/modals/poll-form.js index 137e65d0..038e5c44 100644 --- a/conversejs/custom/plugins/poll/modals/poll-form.js +++ b/conversejs/custom/plugins/poll/modals/poll-form.js @@ -7,7 +7,6 @@ import BaseModal from 'plugins/modal/modal.js' import { api } from '@converse/headless/core' import { modal_close_button as ModalCloseButton } from 'plugins/modal/templates/buttons.js' import { html } from 'lit' -import 'livechat-external-login-content.js' class PollFormModal extends BaseModal { initialize () { diff --git a/conversejs/loc.keys.js b/conversejs/loc.keys.js index ba601544..6d167438 100644 --- a/conversejs/loc.keys.js +++ b/conversejs/loc.keys.js @@ -36,7 +36,13 @@ const locKeys = [ 'task_list_pick_empty', 'task_list_pick_message', 'muted_anonymous_message', - 'new_poll' + 'new_poll', + 'poll_question', + 'poll_duration', + 'poll_anonymous_results', + 'poll_choice_n', + 'poll_title', + 'poll_instructions' ] module.exports = locKeys diff --git a/languages/en.yml b/languages/en.yml index b29d1491..99f53d50 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -565,3 +565,9 @@ livechat_configuration_channel_terms_desc: | You can configure a "terms & conditions" message that will be shown to users joining your chatrooms. new_poll: Create a new poll +poll_title: New poll +poll_instructions: Complete and submit this form to create a new poll. This will end and replace any existing poll. +poll_question: Question +poll_duration: Poll duration (in minutes) +poll_anonymous_results: Anonymous results +poll_choice_n: Choice {{N}} diff --git a/languages/fr.yml b/languages/fr.yml index 4d15375e..bfe4a7ea 100644 --- a/languages/fr.yml +++ b/languages/fr.yml @@ -545,3 +545,12 @@ livechat_configuration_channel_terms_label: Conditions d'utilisation tchat de la livechat_configuration_channel_terms_desc: "Vous pouvez configurer un message de \"\ conditions d'utilisation\" qui sera affiché aux utilisateur⋅rices qui rejoignent vos salons de discussion.\n" + + +new_poll: Créer un nouveau sondage +poll_title: Nouveau sondage +poll_instructions: Complétez et soumettez ce formulaire pour créer un nouveau sondage. Ceci mettra fin au sondage précédent le cas échéant. +poll_question: Question +poll_duration: Durée du sondage (en minutes) +poll_anonymous_results: Résultats anonymes +poll_choice_n: Choix {{N}} From 8e2a3335aba042fa805206f5c95ada7cd1d548cb Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Jul 2024 14:53:52 +0200 Subject: [PATCH 14/34] Poll WIP (#231): * fix frontend: modal must be destroyed on hide --- conversejs/custom/plugins/poll/modals/poll-form.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/conversejs/custom/plugins/poll/modals/poll-form.js b/conversejs/custom/plugins/poll/modals/poll-form.js index 038e5c44..f56bb9c3 100644 --- a/conversejs/custom/plugins/poll/modals/poll-form.js +++ b/conversejs/custom/plugins/poll/modals/poll-form.js @@ -13,6 +13,11 @@ class PollFormModal extends BaseModal { super.initialize() } + onHide () { + super.onHide() + api.modal.remove('livechat-converse-poll-form-modal') + } + renderModal () { return html`` } From 3ef05418867de0ffc715a15ecb488c1ffe5c5f1b Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Jul 2024 15:01:30 +0200 Subject: [PATCH 15/34] Poll WIP (#231): * backend security: avoid spoofing polls --- prosody-modules/mod_muc_poll/message.lib.lua | 17 +++++++++++++++++ prosody-modules/mod_muc_poll/mod_muc_poll.lua | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index 30135a00..fa1a8d45 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -159,8 +159,25 @@ local function poll_end_message(room) return message_id; end +-- security check: we must remove all specific tags, to be sure nobody tries to spoof polls! +local function remove_specific_tags_from_groupchat(event) + event.stanza:maptags(function (child) + if child.name == poll_message_tag then + return nil; + end + if child.name == poll_question_tag then + return nil; + end + if child.name == poll_choice_tag then + return nil; + end + return child; + end); +end + return { poll_start_message = poll_start_message; poll_end_message = poll_end_message; schedule_poll_update_message = schedule_poll_update_message; + remove_specific_tags_from_groupchat = remove_specific_tags_from_groupchat; }; diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index b8afaf61..90bbb320 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -19,6 +19,7 @@ local xmlns_poll = module:require("constants").xmlns_poll; local send_form = module:require("form").send_form; local process_form = module:require("form").process_form; local handle_groupchat = module:require("poll").handle_groupchat; +local remove_specific_tags_from_groupchat = module:require("message").remove_specific_tags_from_groupchat local room_restored = module:require("poll").room_restored; -- new poll creation, get form @@ -81,6 +82,10 @@ end); -- Note: we use a high priority, so it will be handled before the slow mode. module:hook("muc-occupant-groupchat", handle_groupchat, 1000); +-- security check: we must remove all specific tags, to be sure nobody tries to spoof polls! +module:hook("muc-occupant-groupchat", remove_specific_tags_from_groupchat, 1000); + + -- when a room is restored (after a server restart for example), -- we must resume any current poll module:hook("muc-room-restored", room_restored); From 4591633400128051298546d3f7e13e35f73a0a50 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Jul 2024 17:45:11 +0200 Subject: [PATCH 16/34] Poll WIP (#231): * front end poll WIP * backend fix --- .../plugins/poll/components/poll-view.js | 30 +++++++ conversejs/custom/plugins/poll/constants.js | 4 + conversejs/custom/plugins/poll/index.js | 64 ++++++++++++++- .../custom/plugins/poll/styles/poll.scss | 76 ++++++++++++++++++ .../custom/plugins/poll/templates/poll.js | 79 +++++++++++++++++++ conversejs/custom/templates/muc.js | 1 + conversejs/loc.keys.js | 4 +- languages/en.yml | 2 + prosody-modules/mod_muc_poll/message.lib.lua | 17 +++- prosody-modules/mod_muc_poll/mod_muc_poll.lua | 9 ++- 10 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 conversejs/custom/plugins/poll/components/poll-view.js create mode 100644 conversejs/custom/plugins/poll/styles/poll.scss create mode 100644 conversejs/custom/plugins/poll/templates/poll.js diff --git a/conversejs/custom/plugins/poll/components/poll-view.js b/conversejs/custom/plugins/poll/components/poll-view.js new file mode 100644 index 00000000..be8aa7ad --- /dev/null +++ b/conversejs/custom/plugins/poll/components/poll-view.js @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { tplPoll } from '../templates/poll.js' +import { CustomElement } from 'shared/components/element.js' +import { api } from '@converse/headless/core' +import '../styles/poll.scss' + +export default class MUCPollView extends CustomElement { + static get properties () { + return { + model: { type: Object, attribute: true } + } + } + + async initialize () { + if (!this.model) { + return + } + this.listenTo(this.model, 'change:current_poll', () => this.requestUpdate()) + } + + render () { + const currentPoll = this.model?.get('current_poll') + return tplPoll(this.model, currentPoll) + } +} + +api.elements.define('livechat-converse-muc-poll', MUCPollView) diff --git a/conversejs/custom/plugins/poll/constants.js b/conversejs/custom/plugins/poll/constants.js index a7eceaf1..3b998795 100644 --- a/conversejs/custom/plugins/poll/constants.js +++ b/conversejs/custom/plugins/poll/constants.js @@ -3,3 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only export const XMLNS_POLL = 'http://jabber.org/protocol/muc#x-poll' +export const XMLNS_POLL_MESSAGE = 'http://jabber.org/protocol/muc#x-poll-message' +export const POLL_MESSAGE_TAG = 'x-poll' +export const POLL_QUESTION_TAG = 'x-poll-question' +export const POLL_CHOICE_TAG = 'x-poll-choice' diff --git a/conversejs/custom/plugins/poll/index.js b/conversejs/custom/plugins/poll/index.js index bd345204..d5daebcc 100644 --- a/conversejs/custom/plugins/poll/index.js +++ b/conversejs/custom/plugins/poll/index.js @@ -4,10 +4,13 @@ import { _converse, converse } from '../../../src/headless/core.js' import { getHeadingButtons } from './utils.js' -// import { XMLNS_POLL } from './constants.js' +import { POLL_MESSAGE_TAG, POLL_QUESTION_TAG, POLL_CHOICE_TAG } from './constants.js' import './modals/poll-form.js' +import './components/poll-view.js' import './components/poll-form-view.js' +const { sizzle } = converse.env + converse.plugins.add('livechat-converse-poll', { dependencies: ['converse-muc', 'converse-disco'], @@ -19,5 +22,64 @@ converse.plugins.add('livechat-converse-poll', { // }) // adding the poll actions in the MUC heading buttons: _converse.api.listen.on('getHeadingButtons', getHeadingButtons) + + _converse.api.listen.on('parseMUCMessage', (stanza, attrs) => { + // Checking if there is any poll data in the message. + const poll = sizzle(POLL_MESSAGE_TAG, stanza)?.[0] + if (!poll) { + return attrs + } + const question = sizzle(POLL_QUESTION_TAG, poll)?.[0] + const choices = sizzle(POLL_CHOICE_TAG, poll) + if (!question || !choices.length) { + return attrs + } + + const endDate = poll.hasAttribute('end') + ? new Date(1000 * parseInt(poll.getAttribute('end'))) + : null + + const currentPoll = { + question: question.textContent, + id: poll.getAttribute('id'), + votes: parseInt(poll.getAttribute('votes') ?? 0), + over: poll.hasAttribute('over'), + endDate: endDate, + choices: choices.map(c => { + return { + label: c.textContent, + choice: c.getAttribute('choice'), + votes: parseInt(c.getAttribute('votes') ?? 0) + } + }) + } + + return Object.assign( + attrs, + { + current_poll: currentPoll + } + ) + }) + }, + + overrides: { + ChatRoom: { + onMessage: function onMessage (attrs) { + if (!attrs.current_poll) { + return this.__super__.onMessage(attrs) + } + // We intercept poll messages, so they won't show up in the chat as classic messages. + if (attrs.is_delayed) { + console.info('Got a delayed poll message, just dropping') + return + } + + console.info('Got a poll message, setting it as the current_poll') + this.set('current_poll', attrs.current_poll) + // this will be displayed by the livechat-converse-muc-poll custom element, + // which is inserted in the DOM by the muc.js template overload. + } + } } }) diff --git a/conversejs/custom/plugins/poll/styles/poll.scss b/conversejs/custom/plugins/poll/styles/poll.scss new file mode 100644 index 00000000..d42b138f --- /dev/null +++ b/conversejs/custom/plugins/poll/styles/poll.scss @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2024 John Livingston + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +.conversejs { + livechat-converse-muc-poll { + background-color: var(--peertube-main-background); + color: var(--peertube-main-foreground); + + & > div { + border: 1px solid var(--peertube-menu-background); + margin: 5px; + padding: 5px; + + p.livechat-poll-question { + text-align: center; + font-weight: bold; + } + + p.livechat-poll-end { + text-align: right; + } + + table { + text-align: left; + vertical-align: middle; + width: 100%; + + td:first-child { + padding-right: 0.5rem; + white-space: nowrap; + } + + td.livechat-poll-choice-label { + width: 100%; + } + + td:last-child { + white-space: nowrap; + width: 120px; + } + } + + .livechat-progress-bar { + background-color: var(--peertube-menu-background); + border: 1px solid var(--peertube-menu-background); + color: var(--peertube-menu-foreground); + height: 1.25rem; + font-size: 0.75rem; + margin: 0; + position: relative; + width: 100px; + + div { + background-color: var(--peertube-button-background); + float: left; + height: 100%; + position: absolute; + z-index: 1; + } + + p { + display: inline; + height: 100%; + text-align: center; + position: absolute; + white-space: nowrap; + width: 100%; + z-index: 2; + } + } + } + } +} diff --git a/conversejs/custom/plugins/poll/templates/poll.js b/conversejs/custom/plugins/poll/templates/poll.js new file mode 100644 index 00000000..8ff1d863 --- /dev/null +++ b/conversejs/custom/plugins/poll/templates/poll.js @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { html } from 'lit' +import { repeat } from 'lit/directives/repeat.js' +import { __ } from 'i18n' + +function _tplPollEnd (el, currentPoll) { + if (!currentPoll.endDate) { + return html`` + } + + // eslint-disable-next-line no-undef + const i18nPollEnd = __(LOC_poll_end) + return html`

+ ${i18nPollEnd} + ${currentPoll.endDate.toLocaleString()} +

` +} + +function _tplChoice (el, currentPoll, choice) { + // eslint-disable-next-line no-undef + const i18nChoiceN = __(LOC_poll_choice_n).replace('{{N}}', choice.choice) + + const votes = choice.votes + const totalVotes = currentPoll.votes + const percent = totalVotes ? (100 * votes / totalVotes).toFixed(2) : '0.00' + return html` + + + ${ + currentPoll.over + ? html`${i18nChoiceN}` + : html` + ` + } + + + ${choice.label} + + +
+
+

+ ${votes}/${totalVotes} + (${percent}%) +

+
+ + ` +} + +export function tplPoll (el, currentPoll) { + if (!currentPoll) { + return html`` + } + + return html`
+

${currentPoll.question}

+ + ${repeat(currentPoll.choices ?? [], (c) => c.choice, (c) => _tplChoice(el, currentPoll, c))} +
+ ${_tplPollEnd(el, currentPoll)} +
` +} diff --git a/conversejs/custom/templates/muc.js b/conversejs/custom/templates/muc.js index bb9820c6..f03ce6f1 100644 --- a/conversejs/custom/templates/muc.js +++ b/conversejs/custom/templates/muc.js @@ -21,6 +21,7 @@ export default (o) => { +
${getChatRoomBodyTemplate(o)}
` : ''} ` diff --git a/conversejs/loc.keys.js b/conversejs/loc.keys.js index 6d167438..ee0cadc1 100644 --- a/conversejs/loc.keys.js +++ b/conversejs/loc.keys.js @@ -42,7 +42,9 @@ const locKeys = [ 'poll_anonymous_results', 'poll_choice_n', 'poll_title', - 'poll_instructions' + 'poll_instructions', + 'poll_end', + 'poll' ] module.exports = locKeys diff --git a/languages/en.yml b/languages/en.yml index 99f53d50..96e824ba 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -565,9 +565,11 @@ livechat_configuration_channel_terms_desc: | You can configure a "terms & conditions" message that will be shown to users joining your chatrooms. new_poll: Create a new poll +poll: Poll poll_title: New poll poll_instructions: Complete and submit this form to create a new poll. This will end and replace any existing poll. poll_question: Question poll_duration: Poll duration (in minutes) poll_anonymous_results: Anonymous results poll_choice_n: Choice {{N}} +poll_end: 'Poll ends at:' diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index fa1a8d45..601ce217 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -62,7 +62,7 @@ local function build_poll_message(room, message_id, is_end_message) }):up(); -- now we must add some custom XML data, so that compatible clients can display the poll as they want: - -- + -- -- Poll question -- Choice 1 label -- Choice 2 label @@ -74,6 +74,7 @@ local function build_poll_message(room, message_id, is_end_message) id = current_poll.poll_id, votes = "" .. total }; + message_attrs["end"] = string.format("%i", current_poll.end_timestamp); if current_poll.already_ended then message_attrs["over"] = ""; end @@ -175,9 +176,23 @@ local function remove_specific_tags_from_groupchat(event) end); end +-- when a new session is opened, we must send the current poll to the client +local function handle_new_occupant_session(event) + local room = event.room; + if not room._data.current_poll then + return; + end + if room._data.current_poll.already_ended then + return; + end + schedule_poll_update_message(room.jid); + -- FIXME: for now we just schedule a new poll update. But we should only send a message to the new occupant. +end + return { poll_start_message = poll_start_message; poll_end_message = poll_end_message; schedule_poll_update_message = schedule_poll_update_message; remove_specific_tags_from_groupchat = remove_specific_tags_from_groupchat; + handle_new_occupant_session = handle_new_occupant_session; }; diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 90bbb320..a8c8600c 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -19,7 +19,8 @@ local xmlns_poll = module:require("constants").xmlns_poll; local send_form = module:require("form").send_form; local process_form = module:require("form").process_form; local handle_groupchat = module:require("poll").handle_groupchat; -local remove_specific_tags_from_groupchat = module:require("message").remove_specific_tags_from_groupchat +local remove_specific_tags_from_groupchat = module:require("message").remove_specific_tags_from_groupchat; +local handle_new_occupant_session = module:require("message").handle_new_occupant_session; local room_restored = module:require("poll").room_restored; -- new poll creation, get form @@ -85,7 +86,11 @@ module:hook("muc-occupant-groupchat", handle_groupchat, 1000); -- security check: we must remove all specific tags, to be sure nobody tries to spoof polls! module:hook("muc-occupant-groupchat", remove_specific_tags_from_groupchat, 1000); - -- when a room is restored (after a server restart for example), -- we must resume any current poll module:hook("muc-room-restored", room_restored); + +-- when a new session is opened, we must send the current poll to the client +-- Note: it should be in the MAM. But it is easier for clients to ignore delayed messages +-- when displaying polls (to ignore old polls). +module:hook("muc-occupant-session-new", handle_new_occupant_session); From 1c6434630e7a9d8393d1eb08f3bb4e1fa3a4a398 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 1 Jul 2024 18:06:26 +0200 Subject: [PATCH 17/34] Poll WIP (#231): * poll frontend WIP --- conversejs/custom/plugins/poll/components/poll-view.js | 2 +- conversejs/custom/plugins/poll/templates/poll.js | 5 ++++- languages/en.yml | 2 +- languages/fr.yml | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/conversejs/custom/plugins/poll/components/poll-view.js b/conversejs/custom/plugins/poll/components/poll-view.js index be8aa7ad..fd06d552 100644 --- a/conversejs/custom/plugins/poll/components/poll-view.js +++ b/conversejs/custom/plugins/poll/components/poll-view.js @@ -23,7 +23,7 @@ export default class MUCPollView extends CustomElement { render () { const currentPoll = this.model?.get('current_poll') - return tplPoll(this.model, currentPoll) + return tplPoll(this, currentPoll) } } diff --git a/conversejs/custom/plugins/poll/templates/poll.js b/conversejs/custom/plugins/poll/templates/poll.js index 8ff1d863..becc5876 100644 --- a/conversejs/custom/plugins/poll/templates/poll.js +++ b/conversejs/custom/plugins/poll/templates/poll.js @@ -37,8 +37,11 @@ function _tplChoice (el, currentPoll, choice) { @click=${ev => { ev.preventDefault() if (currentPoll.over) { return } - // TODO + console.info('User has voted for choice: ', choice) + el.model.sendMessage({ + body: '!' + choice.choice + }) }} > ${i18nChoiceN} diff --git a/languages/en.yml b/languages/en.yml index 96e824ba..7b84ca6e 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -571,5 +571,5 @@ poll_instructions: Complete and submit this form to create a new poll. This will poll_question: Question poll_duration: Poll duration (in minutes) poll_anonymous_results: Anonymous results -poll_choice_n: Choice {{N}} +poll_choice_n: 'Choice {{N}}:' poll_end: 'Poll ends at:' diff --git a/languages/fr.yml b/languages/fr.yml index bfe4a7ea..1c163a62 100644 --- a/languages/fr.yml +++ b/languages/fr.yml @@ -553,4 +553,4 @@ poll_instructions: Complétez et soumettez ce formulaire pour créer un nouveau poll_question: Question poll_duration: Durée du sondage (en minutes) poll_anonymous_results: Résultats anonymes -poll_choice_n: Choix {{N}} +poll_choice_n: 'Choix {{N}} :' From 4168b2ce417bc2050727c5623ca9021a2770fa6a Mon Sep 17 00:00:00 2001 From: John Livingston Date: Thu, 4 Jul 2024 14:04:33 +0200 Subject: [PATCH 18/34] Poll WIP (#231): * various fix and improvements --- conversejs/custom/plugins/poll/index.js | 16 +++++++++++++--- prosody-modules/mod_muc_poll/README.md | 16 ++++++++++++++++ prosody-modules/mod_muc_poll/message.lib.lua | 9 ++++++--- prosody-modules/mod_muc_poll/mod_muc_poll.lua | 5 ++++- server/lib/prosody/config.ts | 2 ++ server/lib/prosody/config/content.ts | 10 ++++++++-- 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/conversejs/custom/plugins/poll/index.js b/conversejs/custom/plugins/poll/index.js index d5daebcc..fae612a3 100644 --- a/conversejs/custom/plugins/poll/index.js +++ b/conversejs/custom/plugins/poll/index.js @@ -69,9 +69,14 @@ converse.plugins.add('livechat-converse-poll', { if (!attrs.current_poll) { return this.__super__.onMessage(attrs) } - // We intercept poll messages, so they won't show up in the chat as classic messages. - if (attrs.is_delayed) { - console.info('Got a delayed poll message, just dropping') + // We intercept poll messages, to show the banner. + // Note: we also show the message in the chat. + if (attrs.is_delayed || attrs.is_archived) { + if (attrs.current_poll.over) { + console.info('Got a delayed/archived poll message for an poll that is over, just displaying in the chat') + return this.__super__.onMessage(attrs) + } + console.info('Got a delayed/archived poll message, just dropping') return } @@ -79,6 +84,11 @@ converse.plugins.add('livechat-converse-poll', { this.set('current_poll', attrs.current_poll) // this will be displayed by the livechat-converse-muc-poll custom element, // which is inserted in the DOM by the muc.js template overload. + if (attrs.current_poll.over) { + console.info('The poll is over, displaying the message in the chat') + return this.__super__.onMessage(attrs) + } + // Dropping the message. } } } diff --git a/prosody-modules/mod_muc_poll/README.md b/prosody-modules/mod_muc_poll/README.md index f29d7621..49f6f025 100644 --- a/prosody-modules/mod_muc_poll/README.md +++ b/prosody-modules/mod_muc_poll/README.md @@ -13,3 +13,19 @@ There will probably be a XEP proposal for this module behaviour. When done, this ## Configuration Just enable the module on your MUC component. +All above configurations are optional. + +## poll_groupchat_votes_priority + +The priority for the hook that will take into account votes. +You can change this, if you have some specific hook that should be done after/before counting votes (slow mode, firewall, ...). + +Default: 500 + +## Strings + +You can change some defaults strings, if you want for example to localize the poll messages. +Here are the existing strings and default values: + +* poll_string_over: This poll is now over. +* poll_string_vote_instructions: Send a message with an exclamation mark followed by your choice number to vote. Example: !1 diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index 601ce217..ce282328 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -17,18 +17,21 @@ local get_room_from_jid = mod_muc.get_room_from_jid; local debounce_delay = 5; -- number of seconds during which we must group votes to avoid flood. local scheduled_updates = {}; +local string_poll_over = module:get_option_string("poll_string_over") or "This poll is now over."; +local string_poll_vote_instructions = module:get_option_string("poll_string_vote_instructions") or "Send a message with an exclamation mark followed by your choice number to vote. Example: !1"; + -- construct the poll message stanza local function build_poll_message(room, message_id, is_end_message) local current_poll = room._data.current_poll; if not current_poll then return nil; end - local from = room.jid .. '/' .. current_poll.occupant_nick; + local from = current_poll.occupant_nick; -- this is in fact room.jid/nickname local content = current_poll["muc#roompoll_question"] .. "\n"; if is_end_message then - content = content .. "This poll is now over.\n"; + content = content .. string_poll_over .. "\n"; end local total = 0; @@ -47,7 +50,7 @@ local function build_poll_message(room, message_id, is_end_message) end if not is_end_message then - content = content .. "Send a message with an exclamation mark followed by your choice number to vote. Example: !1\n"; + content = content .. string_poll_vote_instructions .. "\n"; end local msg = st.message({ diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index a8c8600c..44e376fd 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -23,6 +23,9 @@ local remove_specific_tags_from_groupchat = module:require("message").remove_spe local handle_new_occupant_session = module:require("message").handle_new_occupant_session; local room_restored = module:require("poll").room_restored; +local poll_groupchat_votes_priority = module:get_option_number("poll_groupchat_votes_priority") or 500; + + -- new poll creation, get form module:hook("iq-get/bare/" .. xmlns_poll .. ":query", function (event) local origin, stanza = event.origin, event.stanza; @@ -81,7 +84,7 @@ end); -- On groupchat messages, we check if this is a vote for the current poll. -- Note: we use a high priority, so it will be handled before the slow mode. -module:hook("muc-occupant-groupchat", handle_groupchat, 1000); +module:hook("muc-occupant-groupchat", handle_groupchat, poll_groupchat_votes_priority); -- security check: we must remove all specific tags, to be sure nobody tries to spoof polls! module:hook("muc-occupant-groupchat", remove_specific_tags_from_groupchat, 1000); diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts index d8f8bd54..8b2d49b9 100644 --- a/server/lib/prosody/config.ts +++ b/server/lib/prosody/config.ts @@ -366,6 +366,8 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise Date: Thu, 4 Jul 2024 15:15:28 +0200 Subject: [PATCH 19/34] Poll WIP (#231): * frontend WIP * backend fix --- .../plugins/poll/components/poll-view.js | 32 ++++++++++- conversejs/custom/plugins/poll/index.js | 4 +- .../custom/plugins/poll/styles/poll.scss | 38 +++++++++++++ .../custom/plugins/poll/templates/poll.js | 57 +++++++++++++++---- conversejs/loc.keys.js | 3 +- languages/en.yml | 2 + prosody-modules/mod_muc_poll/README.md | 2 + prosody-modules/mod_muc_poll/poll.lib.lua | 7 ++- 8 files changed, 127 insertions(+), 18 deletions(-) diff --git a/conversejs/custom/plugins/poll/components/poll-view.js b/conversejs/custom/plugins/poll/components/poll-view.js index fd06d552..53d7e939 100644 --- a/conversejs/custom/plugins/poll/components/poll-view.js +++ b/conversejs/custom/plugins/poll/components/poll-view.js @@ -10,21 +10,49 @@ import '../styles/poll.scss' export default class MUCPollView extends CustomElement { static get properties () { return { - model: { type: Object, attribute: true } + model: { type: Object, attribute: true }, + collapsed: { type: Boolean, attribute: false }, + buttonDisabled: { type: Boolean, attribute: false } } } async initialize () { + this.collapsed = false + this.buttonDisabled = false if (!this.model) { return } - this.listenTo(this.model, 'change:current_poll', () => this.requestUpdate()) + this.listenTo(this.model, 'change:current_poll', () => { + this.buttonDisabled = false + this.requestUpdate() + }) } render () { const currentPoll = this.model?.get('current_poll') return tplPoll(this, currentPoll) } + + toggle () { + this.collapsed = !this.collapsed + } + + voteFor (choice) { + if (this.buttonDisabled) { return } + + const currentPoll = this.model?.get('current_poll') + if (!currentPoll) { return } + if (currentPoll.over) { return } + + console.info('User has voted for choice: ', choice) + // We disable vote buttons until next refresh: + this.buttonDisabled = true + this.requestUpdate() + + this.model.sendMessage({ + body: '!' + choice.choice + }) + } } api.elements.define('livechat-converse-muc-poll', MUCPollView) diff --git a/conversejs/custom/plugins/poll/index.js b/conversejs/custom/plugins/poll/index.js index fae612a3..adf72ca7 100644 --- a/conversejs/custom/plugins/poll/index.js +++ b/conversejs/custom/plugins/poll/index.js @@ -45,6 +45,7 @@ converse.plugins.add('livechat-converse-poll', { votes: parseInt(poll.getAttribute('votes') ?? 0), over: poll.hasAttribute('over'), endDate: endDate, + time: attrs.time, // this is to be sure that we update the custom element (needed to re-enable buttons) choices: choices.map(c => { return { label: c.textContent, @@ -81,9 +82,10 @@ converse.plugins.add('livechat-converse-poll', { } console.info('Got a poll message, setting it as the current_poll') - this.set('current_poll', attrs.current_poll) // this will be displayed by the livechat-converse-muc-poll custom element, // which is inserted in the DOM by the muc.js template overload. + this.set('current_poll', attrs.current_poll) + if (attrs.current_poll.over) { console.info('The poll is over, displaying the message in the chat') return this.__super__.onMessage(attrs) diff --git a/conversejs/custom/plugins/poll/styles/poll.scss b/conversejs/custom/plugins/poll/styles/poll.scss index d42b138f..ca5476ec 100644 --- a/conversejs/custom/plugins/poll/styles/poll.scss +++ b/conversejs/custom/plugins/poll/styles/poll.scss @@ -13,12 +13,25 @@ border: 1px solid var(--peertube-menu-background); margin: 5px; padding: 5px; + max-height: 150px; + overflow-y: scroll; + + .livechat-poll-toggle { + background: unset; + border: 0; + padding-left: 0.25em; + padding-right: 0.25em; + } p.livechat-poll-question { text-align: center; font-weight: bold; } + p.livechat-poll-instructions { + text-align: right; + } + p.livechat-poll-end { text-align: right; } @@ -74,3 +87,28 @@ } } } + +body[livechat-viewer-mode="on"] { + livechat-converse-muc-poll { + /* Dont display the poll before user choose a nickname */ + display: none !important; + } +} + +.livechat-readonly { + .conversejs { + livechat-converse-muc-poll { + /* stylelint-disable-next-line no-descending-specificity */ + & > div { + // In readonly mode, dont impose max-height + max-height: initial !important; + overflow-y: visible !important; + + &.livechat-poll-over { + // stop showing poll when over in readonly mode + display: none !important; + } + } + } + } +} diff --git a/conversejs/custom/plugins/poll/templates/poll.js b/conversejs/custom/plugins/poll/templates/poll.js index becc5876..703cb114 100644 --- a/conversejs/custom/plugins/poll/templates/poll.js +++ b/conversejs/custom/plugins/poll/templates/poll.js @@ -6,6 +6,18 @@ import { html } from 'lit' import { repeat } from 'lit/directives/repeat.js' import { __ } from 'i18n' +function _tplPollInstructions (el, currentPoll) { + if (currentPoll.over) { + return html`` + } + + // eslint-disable-next-line no-undef + const i18nPollInstructions = __(LOC_poll_vote_instructions) + return html`

+ ${i18nPollInstructions} +

` +} + function _tplPollEnd (el, currentPoll) { if (!currentPoll.endDate) { return html`` @@ -36,13 +48,9 @@ function _tplChoice (el, currentPoll, choice) { ` @@ -72,11 +80,36 @@ export function tplPoll (el, currentPoll) { return html`` } - return html`
-

${currentPoll.question}

- - ${repeat(currentPoll.choices ?? [], (c) => c.choice, (c) => _tplChoice(el, currentPoll, c))} -
- ${_tplPollEnd(el, currentPoll)} + return html`
+

+ ${el.collapsed + ? html` + ` + : html` + ` + } + ${currentPoll.question} +

+ ${ + el.collapsed + ? '' + : html` + + ${repeat(currentPoll.choices ?? [], (c) => c.choice, (c) => _tplChoice(el, currentPoll, c))} +
+ ${_tplPollInstructions(el, currentPoll)} + ${_tplPollEnd(el, currentPoll)} + ` + }
` } diff --git a/conversejs/loc.keys.js b/conversejs/loc.keys.js index ee0cadc1..a29e0088 100644 --- a/conversejs/loc.keys.js +++ b/conversejs/loc.keys.js @@ -44,7 +44,8 @@ const locKeys = [ 'poll_title', 'poll_instructions', 'poll_end', - 'poll' + 'poll', + 'poll_vote_instructions' ] module.exports = locKeys diff --git a/languages/en.yml b/languages/en.yml index 7b84ca6e..4807bdb5 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -573,3 +573,5 @@ poll_duration: Poll duration (in minutes) poll_anonymous_results: Anonymous results poll_choice_n: 'Choice {{N}}:' poll_end: 'Poll ends at:' +poll_vote_instructions: | + To vote, click on your choice or send a message with an exclamation mark followed by your choice number (Example: !1) diff --git a/prosody-modules/mod_muc_poll/README.md b/prosody-modules/mod_muc_poll/README.md index 49f6f025..40ec5a4f 100644 --- a/prosody-modules/mod_muc_poll/README.md +++ b/prosody-modules/mod_muc_poll/README.md @@ -29,3 +29,5 @@ Here are the existing strings and default values: * poll_string_over: This poll is now over. * poll_string_vote_instructions: Send a message with an exclamation mark followed by your choice number to vote. Example: !1 +* poll_string_invalid_choice: This choice is not valid. +* poll_string_anonymous_vote_ok: You vote is taken into account. Votes are anonymous, it will not be shown to other participants. diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index f6e110e7..73c16b00 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -11,6 +11,9 @@ local poll_start_message = module:require("message").poll_start_message; local poll_end_message = module:require("message").poll_end_message; local schedule_poll_update_message = module:require("message").schedule_poll_update_message; +local string_poll_invalid_choice = module:get_option_string("poll_string_invalid_choice") or "This choice is not valid."; +local string_poll_anonymous_vote_ok = module:get_option_string("poll_string_anonymous_vote_ok") or "You vote is taken into account. Votes are anonymous, it will not be shown to other participants."; + local scheduled_end = {}; local function schedule_poll_purge(room_jid) @@ -171,7 +174,7 @@ local function handle_groupchat(event) "cancel", -- error_condition = 'not-allowed' (see RFC 6120 Defined Error Conditions https://xmpp.org/rfcs/rfc6120.html#stanzas-error-conditions) "bad-request", - "This choice is not valid." + string_poll_invalid_choice )); return true; -- stop! end @@ -205,7 +208,7 @@ local function handle_groupchat(event) "continue", -- error_condition "undefined-condition", - "You vote is taken into account. Votes are anonymous, it will not be shown to other participants." + string_poll_anonymous_vote_ok )); return true; -- stop! end From 1249f0895d2bfc32b791b52241c576bc45f40a33 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Thu, 4 Jul 2024 15:34:32 +0200 Subject: [PATCH 20/34] Poll WIP (#231): * close button * backend fix --- conversejs/custom/plugins/poll/components/poll-view.js | 5 +++++ conversejs/custom/plugins/poll/index.js | 2 +- conversejs/custom/plugins/poll/styles/poll.scss | 6 ++++++ conversejs/custom/plugins/poll/templates/poll.js | 6 ++++++ prosody-modules/mod_muc_poll/message.lib.lua | 5 +++++ prosody-modules/mod_muc_poll/poll.lib.lua | 3 ++- 6 files changed, 25 insertions(+), 2 deletions(-) diff --git a/conversejs/custom/plugins/poll/components/poll-view.js b/conversejs/custom/plugins/poll/components/poll-view.js index 53d7e939..8742f044 100644 --- a/conversejs/custom/plugins/poll/components/poll-view.js +++ b/conversejs/custom/plugins/poll/components/poll-view.js @@ -53,6 +53,11 @@ export default class MUCPollView extends CustomElement { body: '!' + choice.choice }) } + + closePoll (ev) { + ev.preventDefault() + this.model.set('current_poll', undefined) + } } api.elements.define('livechat-converse-muc-poll', MUCPollView) diff --git a/conversejs/custom/plugins/poll/index.js b/conversejs/custom/plugins/poll/index.js index adf72ca7..b87f804c 100644 --- a/conversejs/custom/plugins/poll/index.js +++ b/conversejs/custom/plugins/poll/index.js @@ -71,7 +71,7 @@ converse.plugins.add('livechat-converse-poll', { return this.__super__.onMessage(attrs) } // We intercept poll messages, to show the banner. - // Note: we also show the message in the chat. + // Note: we also show poll end messages in the chat, so that the user don't loose the result. if (attrs.is_delayed || attrs.is_archived) { if (attrs.current_poll.over) { console.info('Got a delayed/archived poll message for an poll that is over, just displaying in the chat') diff --git a/conversejs/custom/plugins/poll/styles/poll.scss b/conversejs/custom/plugins/poll/styles/poll.scss index ca5476ec..81ac2dd8 100644 --- a/conversejs/custom/plugins/poll/styles/poll.scss +++ b/conversejs/custom/plugins/poll/styles/poll.scss @@ -23,6 +23,12 @@ padding-right: 0.25em; } + .livechat-poll-close { + background: unset; + border: 0; + float: right; + } + p.livechat-poll-question { text-align: center; font-weight: bold; diff --git a/conversejs/custom/plugins/poll/templates/poll.js b/conversejs/custom/plugins/poll/templates/poll.js index 703cb114..e4367126 100644 --- a/conversejs/custom/plugins/poll/templates/poll.js +++ b/conversejs/custom/plugins/poll/templates/poll.js @@ -82,6 +82,12 @@ export function tplPoll (el, currentPoll) { return html`

+ ${currentPoll.over + ? html`` + : '' + } ${el.collapsed ? html` ` } - ${currentPoll.question} + + ${currentPoll.question} +

${ el.collapsed From 6dda0cc44f5b6b20d8492953b4dc8074c5c0680a Mon Sep 17 00:00:00 2001 From: John Livingston Date: Fri, 5 Jul 2024 10:39:38 +0200 Subject: [PATCH 25/34] Poll WIP (#231): * refactoring update messages, for a more efficient and proper way to handle it --- conversejs/custom/plugins/poll/index.js | 25 +++---- conversejs/loc.keys.js | 1 + languages/en.yml | 2 + prosody-modules/mod_muc_poll/message.lib.lua | 70 +++++++++++++------ prosody-modules/mod_muc_poll/mod_muc_poll.lua | 2 +- prosody-modules/mod_muc_poll/poll.lib.lua | 2 +- server/lib/prosody/config/content.ts | 1 + 7 files changed, 64 insertions(+), 39 deletions(-) diff --git a/conversejs/custom/plugins/poll/index.js b/conversejs/custom/plugins/poll/index.js index 1f4953b7..2f32be8d 100644 --- a/conversejs/custom/plugins/poll/index.js +++ b/conversejs/custom/plugins/poll/index.js @@ -74,8 +74,11 @@ converse.plugins.add('livechat-converse-poll', { } // We will also translate some strings here. - // eslint-disable-next-line no-undef - const body = (attrs.body ?? '').replace(LOC_poll_is_over, __(LOC_poll_is_over)) + const body = (attrs.body ?? '') + // eslint-disable-next-line no-undef + .replace(LOC_poll_is_over, __(LOC_poll_is_over)) + // eslint-disable-next-line no-undef + .replace(LOC_poll_vote_instructions_xmpp, __(LOC_poll_vote_instructions)) // changing instructions on the fly return Object.assign( attrs, @@ -93,15 +96,11 @@ converse.plugins.add('livechat-converse-poll', { if (!attrs.current_poll) { return this.__super__.onMessage(attrs) } + // We intercept poll messages, to show the banner. - // Note: we also show poll end messages in the chat, so that the user don't loose the result. - if (attrs.is_delayed || attrs.is_archived) { - if (attrs.current_poll.over) { - console.info('Got a delayed/archived poll message for an poll that is over, just displaying in the chat') - return this.__super__.onMessage(attrs) - } - console.info('Got a delayed/archived poll message, just dropping') - return + // We just drop archived messages, to not show the banner for finished polls. + if (attrs.is_archived) { + return this.__super__.onMessage(attrs) } console.info('Got a poll message, setting it as the current_poll') @@ -109,11 +108,7 @@ converse.plugins.add('livechat-converse-poll', { // which is inserted in the DOM by the muc.js template overload. this.set('current_poll', attrs.current_poll) - if (attrs.current_poll.over) { - console.info('The poll is over, displaying the message in the chat') - return this.__super__.onMessage(attrs) - } - // Dropping the message. + return this.__super__.onMessage(attrs) } } } diff --git a/conversejs/loc.keys.js b/conversejs/loc.keys.js index c209e67d..7a6f9283 100644 --- a/conversejs/loc.keys.js +++ b/conversejs/loc.keys.js @@ -46,6 +46,7 @@ const locKeys = [ 'poll_end', 'poll', 'poll_vote_instructions', + 'poll_vote_instructions_xmpp', 'poll_is_over', 'poll_choice_invalid', 'poll_anonymous_vote_ok' diff --git a/languages/en.yml b/languages/en.yml index 9dde54d3..34211eb4 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -575,6 +575,8 @@ poll_choice_n: 'Choice {{N}}:' poll_end: 'Poll ends at:' poll_vote_instructions: | To vote, click on your choice or send a message with an exclamation mark followed by your choice number (Example: !1). +poll_vote_instructions_xmpp: | + Send a message with an exclamation mark followed by your choice number to vote. Example: !1 poll_is_over: This poll is now over. poll_choice_invalid: This choice is not valid. poll_anonymous_vote_ok: Your vote is taken into account. Votes are anonymous, they will not be shown to other participants. diff --git a/prosody-modules/mod_muc_poll/message.lib.lua b/prosody-modules/mod_muc_poll/message.lib.lua index ea17b88a..93612a0b 100644 --- a/prosody-modules/mod_muc_poll/message.lib.lua +++ b/prosody-modules/mod_muc_poll/message.lib.lua @@ -20,13 +20,12 @@ local scheduled_updates = {}; local string_poll_over = module:get_option_string("poll_string_over") or "This poll is now over."; local string_poll_vote_instructions = module:get_option_string("poll_string_vote_instructions") or "Send a message with an exclamation mark followed by your choice number to vote. Example: !1"; --- construct the poll message stanza -local function build_poll_message(room, message_id, is_end_message) +-- Build the content for poll start and end messages (that will go to the message ) +local function build_poll_message_content(room, is_end_message) local current_poll = room._data.current_poll; if not current_poll then return nil; end - local from = current_poll.occupant_nick; -- this is in fact room.jid/nickname local content = current_poll["muc#roompoll_question"] .. "\n"; @@ -41,7 +40,8 @@ local function build_poll_message(room, message_id, is_end_message) for _, choice_desc in ipairs(current_poll.choices_ordered) do local choice, label = choice_desc.number, choice_desc.label; content = content .. choice .. ': ' .. label; - if total > 0 then + -- if vote over, and at least 1 vote, we add the results. + if is_end_message and total > 0 then local nb = current_poll.votes_by_choices[choice] or 0; local percent = string.format("%.2f", nb * 100 / total); content = content .. " (" .. nb .. "/" .. total .. " = " .. percent .. "%)"; @@ -53,10 +53,23 @@ local function build_poll_message(room, message_id, is_end_message) content = content .. string_poll_vote_instructions .. "\n"; end + return content; +end + +-- construct the poll message stanza. +-- Note: content can be nil, for updates messages. +local function build_poll_message(room, content) + local current_poll = room._data.current_poll; + if not current_poll then + return nil; + end + + local from = current_poll.occupant_nick; -- this is in fact room.jid/nickname + local msg = st.message({ type = "groupchat", from = from, - id = message_id + id = id.long() }, content); msg:tag("occupant-id", { @@ -64,6 +77,14 @@ local function build_poll_message(room, message_id, is_end_message) id = current_poll.occupant_id }):up(); + if content == nil then + -- No content, this is an update message. + -- Adding some hints (XEP-0334): + msg:tag("no-copy", { xmlns = "urn:xmpp:hints" }):up(); + msg:tag("no-store", { xmlns = "urn:xmpp:hints" }):up(); + msg:tag("no-permanent-store", { xmlns = "urn:xmpp:hints" }):up(); + end + -- now we must add some custom XML data, so that compatible clients can display the poll as they want: -- -- Poll question @@ -72,6 +93,11 @@ local function build_poll_message(room, message_id, is_end_message) -- Choice 3 label -- Choice 4 label -- + local total = 0; + for choice, nb in pairs(current_poll.votes_by_choices) do + total = total + nb; + end + local message_attrs = { xmlns = xmlns_poll_message, id = current_poll.poll_id, @@ -85,6 +111,7 @@ local function build_poll_message(room, message_id, is_end_message) for _, choice_desc in ipairs(current_poll.choices_ordered) do local choice, label = choice_desc.number, choice_desc.label; local nb = current_poll.votes_by_choices[choice] or 0; + total = total + nb; msg:text_tag(poll_choice_tag, label, { votes = "" .. nb, choice = choice @@ -101,10 +128,9 @@ local function poll_start_message(room) return nil; end module:log("debug", "Sending the start message for room %s poll", room.jid); - local message_id = id.medium(); - local msg = build_poll_message(room, message_id, false); + local content = build_poll_message_content(room, false); + local msg = build_poll_message(room, content); room:broadcast_message(msg); - return message_id; end -- Send the poll update message @@ -118,17 +144,9 @@ local function send_poll_update_message(room) end module:log("debug", "Sending an update message for room %s poll", room.jid); - local message_id = id.medium(); -- generate a new id - local msg = build_poll_message(room, message_id, false); - - -- the update message is a message (see XEP-0308). - msg:tag('replace', { - xmlns = xmlns_replace; - id = room._data.current_poll.start_message_id; - }):up(); + local msg = build_poll_message(room, nil); room:broadcast_message(msg); - return message_id; end -- Schedule an update of the start message. @@ -162,10 +180,9 @@ local function poll_end_message(room) timer.stop(scheduled_updates[room.jid]); scheduled_updates[room.jid] = nil; end - local message_id = id.medium(); -- generate a new id - local msg = build_poll_message(room, message_id, true); + local content = build_poll_message_content(room, true); + local msg = build_poll_message(room, content); room:broadcast_message(msg); - return message_id; end -- security check: we must remove all specific tags, to be sure nobody tries to spoof polls! @@ -187,14 +204,23 @@ end -- when a new session is opened, we must send the current poll to the client local function handle_new_occupant_session(event) local room = event.room; + local occupant = event.occupant; + local origin = event.origin; + if not occupant then + return; + end if not room._data.current_poll then return; end if room._data.current_poll.already_ended then return; end - schedule_poll_update_message(room.jid); - -- FIXME: for now we just schedule a new poll update. But we should only send a message to the new occupant. + + -- Sending an update message to the new occupant. + module:log("debug", "Sending a poll update message to new occupant %s", occupant.jid); + local msg = build_poll_message(room, nil); + msg.attr.to = occupant.jid; + origin.send(msg); end return { diff --git a/prosody-modules/mod_muc_poll/mod_muc_poll.lua b/prosody-modules/mod_muc_poll/mod_muc_poll.lua index 44e376fd..1ef491ca 100644 --- a/prosody-modules/mod_muc_poll/mod_muc_poll.lua +++ b/prosody-modules/mod_muc_poll/mod_muc_poll.lua @@ -96,4 +96,4 @@ module:hook("muc-room-restored", room_restored); -- when a new session is opened, we must send the current poll to the client -- Note: it should be in the MAM. But it is easier for clients to ignore delayed messages -- when displaying polls (to ignore old polls). -module:hook("muc-occupant-session-new", handle_new_occupant_session); +module:hook("muc-occupant-session-new", handle_new_occupant_session, 10); -- must be after subject (20, see Prosody code) diff --git a/prosody-modules/mod_muc_poll/poll.lib.lua b/prosody-modules/mod_muc_poll/poll.lib.lua index 471d17cc..03ab69ad 100644 --- a/prosody-modules/mod_muc_poll/poll.lib.lua +++ b/prosody-modules/mod_muc_poll/poll.lib.lua @@ -128,7 +128,7 @@ local function create_poll(room, fields, occupant) return tonumber(a.number) < tonumber(b.number); end); - room._data.current_poll.start_message_id = poll_start_message(room); + poll_start_message(room); schedule_poll_end(room.jid, room._data.current_poll.end_timestamp); end diff --git a/server/lib/prosody/config/content.ts b/server/lib/prosody/config/content.ts index a95b3b38..88a3d9c7 100644 --- a/server/lib/prosody/config/content.ts +++ b/server/lib/prosody/config/content.ts @@ -540,6 +540,7 @@ class ProsodyConfigContent { this.muc.set('poll_string_over', loc('poll_is_over')) this.muc.set('poll_string_invalid_choice', loc('poll_choice_invalid')) this.muc.set('poll_string_anonymous_vote_ok', loc('poll_anonymous_vote_ok')) + this.muc.set('poll_string_vote_instructions', loc('poll_vote_instructions_xmpp')) } addMucAdmins (jids: string[]): void { From 14ffa90208a1fc5e48f7d07475179c10cee68497 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Fri, 5 Jul 2024 11:00:37 +0200 Subject: [PATCH 26/34] Poll WIP (#231): * muted participants can't vote * removed "Choice N" from button labels. --- conversejs/custom/plugins/poll/components/poll-view.js | 6 ++++-- conversejs/custom/plugins/poll/templates/poll.js | 10 +++++----- prosody-modules/mod_muc_poll/README.md | 2 +- prosody-modules/mod_muc_poll/mod_muc_poll.lua | 2 +- server/lib/prosody/config/content.ts | 1 - 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/conversejs/custom/plugins/poll/components/poll-view.js b/conversejs/custom/plugins/poll/components/poll-view.js index 422aba72..e96b5f3b 100644 --- a/conversejs/custom/plugins/poll/components/poll-view.js +++ b/conversejs/custom/plugins/poll/components/poll-view.js @@ -4,7 +4,7 @@ import { tplPoll } from '../templates/poll.js' import { CustomElement } from 'shared/components/element.js' -import { api } from '@converse/headless/core' +import { converse, api } from '@converse/headless/core' import '../styles/poll.scss' export default class MUCPollView extends CustomElement { @@ -30,7 +30,9 @@ export default class MUCPollView extends CustomElement { render () { const currentPoll = this.model?.get('current_poll') - return tplPoll(this, currentPoll) + const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED + const canVote = entered && this.model.getOwnRole() !== 'visitor' + return tplPoll(this, currentPoll, canVote) } toggle (ev) { diff --git a/conversejs/custom/plugins/poll/templates/poll.js b/conversejs/custom/plugins/poll/templates/poll.js index bd976b6d..03ad095f 100644 --- a/conversejs/custom/plugins/poll/templates/poll.js +++ b/conversejs/custom/plugins/poll/templates/poll.js @@ -31,9 +31,9 @@ function _tplPollEnd (el, currentPoll) {

` } -function _tplChoice (el, currentPoll, choice) { +function _tplChoice (el, currentPoll, choice, canVote) { // eslint-disable-next-line no-undef - const i18nChoiceN = __(LOC_poll_choice_n).replace('{{N}}', choice.choice) + const i18nChoiceN = '' + choice.choice + ':' const votes = choice.votes const totalVotes = currentPoll.votes @@ -42,7 +42,7 @@ function _tplChoice (el, currentPoll, choice) { ${ - currentPoll.over + currentPoll.over || !canVote ? html`${i18nChoiceN}` : html`