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);