This commit is contained in:
2024-07-05 18:16:01 -04:00
73 changed files with 8494 additions and 426 deletions

View File

@ -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')

View File

@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// 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 }
}
}
_fieldTranslationMap = new Map()
async initialize () {
this.alert_message = undefined
if (!this.model) {
this.alert_message = __('Error')
return
}
try {
this._initFieldTranslations()
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')
}
// 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) {
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 })
)
}
_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 {
this.alert_message = undefined
const form = ev.target
const inputs = sizzle(':input:not([type=button]):not([type=submit])', form)
const iq = $iq({
type: 'set',
to: this.model.get('jid'),
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.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')
}
}
}
api.elements.define('livechat-converse-poll-form', MUCPollFormView)

View File

@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { tplPoll } from '../templates/poll.js'
import { CustomElement } from 'shared/components/element.js'
import { converse, _converse, api } from '@converse/headless/core'
import '../styles/poll.scss'
export default class MUCPollView extends CustomElement {
static get properties () {
return {
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.buttonDisabled = false
this.requestUpdate()
})
this.listenTo(this.model.occupants, 'change:role', occupant => {
if (occupant.get('jid') !== _converse.bare_jid) { // only for myself
return
}
// visitors cant vote. So we must refresh the polls results when current occupant role changes.
this.requestUpdate()
})
}
render () {
const currentPoll = this.model?.get('current_poll')
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) {
ev.preventDefault()
this.collapsed = !this.collapsed
}
async 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()
await this.model.sendMessage({
body: '!' + choice.choice
})
// Dispatching an event.
// When in Peertube interface, this will open a Peertube notifier with a message.
// FIXME: we should only trigger this on the message echo or bounce,
// but seems ConverseJs does not provide any promise for that.
const event = new Event('livechat-poll-vote', {
bubbles: true
})
this.dispatchEvent(event)
}
closePoll (ev) {
ev.preventDefault()
this.model.set('current_poll', undefined)
}
}
api.elements.define('livechat-converse-muc-poll', MUCPollView)

View File

@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// 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'

View File

@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { _converse, converse } from '../../../src/headless/core.js'
import { getHeadingButtons } from './utils.js'
import { POLL_MESSAGE_TAG, POLL_QUESTION_TAG, POLL_CHOICE_TAG } from './constants.js'
import { __ } from 'i18n'
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'],
initialize () {
// adding the poll actions in the MUC heading buttons:
_converse.api.listen.on('getHeadingButtons', getHeadingButtons)
_converse.api.listen.on('parseMUCMessage', (stanza, attrs) => {
// Localizing specific error messages
if (attrs.is_error) {
// eslint-disable-next-line no-undef, camelcase
if (attrs.error_text === LOC_poll_is_over) {
// eslint-disable-next-line no-undef
attrs.error_text = __(LOC_poll_is_over)
// eslint-disable-next-line no-undef, camelcase
} else if (attrs.error_text === LOC_poll_choice_invalid) {
// eslint-disable-next-line no-undef
attrs.error_text = __(LOC_poll_choice_invalid)
// eslint-disable-next-line no-undef, camelcase
} else if (attrs.error_text === LOC_poll_anonymous_vote_ok) {
// eslint-disable-next-line no-undef
attrs.error_text = __(LOC_poll_anonymous_vote_ok)
}
}
// 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,
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,
choice: c.getAttribute('choice'),
votes: parseInt(c.getAttribute('votes') ?? 0)
}
})
}
// We will also translate some strings here.
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,
{
current_poll: currentPoll,
body
}
)
})
},
overrides: {
ChatRoom: {
onMessage: function onMessage (attrs) {
if (!attrs.current_poll) {
return this.__super__.onMessage(attrs)
}
// We intercept poll messages, to show the banner.
// 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')
// 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)
return this.__super__.onMessage(attrs)
}
}
}
})

View File

@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// 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'
class PollFormModal extends BaseModal {
initialize () {
super.initialize()
}
onHide () {
super.onHide()
api.modal.remove('livechat-converse-poll-form-modal')
}
renderModal () {
return html`<livechat-converse-poll-form .model=${this.model} .modal=${this}></livechat-converse-poll-form>`
}
getModalTitle () {
// eslint-disable-next-line no-undef
return __(LOC_new_poll)
}
renderModalFooter () {
return html`
<div class="modal-footer">
${ModalCloseButton}
<button
type="submit"
class="btn btn-primary"
@click=${(ev) => {
ev.preventDefault()
this.querySelector('livechat-converse-poll-form form')?.requestSubmit()
}}
>
${__('Ok')}
</button>
</div>
`
}
}
api.elements.define('livechat-converse-poll-form-modal', PollFormModal)

View File

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
livechat-converse-poll-form-modal {
/* Special case: when the form is in a modal */
.converse-form {
max-height: 50vh;
overflow-y: scroll;
padding-top: 0;
}
}
}

View File

@ -0,0 +1,134 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* 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;
.livechat-poll-toggle {
background: unset;
border: 0;
padding-left: 0.25em;
padding-right: 0.25em;
}
.livechat-poll-close {
background: unset;
border: 0;
float: right;
}
p.livechat-poll-question {
text-align: center;
font-weight: bold;
span {
cursor: pointer; // because a click toggles
}
}
p.livechat-poll-instructions {
text-align: right;
}
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;
}
}
}
}
&[livechat-converse-root-height="small"],
&[livechat-converse-root-height="medium"] {
/* stylelint-disable-next-line no-descending-specificity */
livechat-converse-muc-poll > div {
max-height: 150px;
overflow-y: scroll;
}
}
}
body[livechat-viewer-mode="on"] {
livechat-converse-muc-poll {
/* Dont display the poll before user choose a nickname */
display: none !important;
}
}
.livechat-readonly {
.conversejs {
/* stylelint-disable-next-line no-descending-specificity */
livechat-converse-muc-poll > 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;
}
p.livechat-poll-instructions {
// No need for instruction in readonly mode
display: none !important;
}
}
}
}

View File

@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { converseLocalizedHelpUrl } from '../../../shared/lib/help'
import { html } from 'lit'
import { __ } from 'i18n'
export function tplPollForm (el) {
const i18nOk = __('Ok')
// eslint-disable-next-line no-undef
const i18nHelp = __(LOC_online_help)
const helpUrl = converseLocalizedHelpUrl({
page: 'documentation/user/streamers/polls'
})
return html`
${el.alert_message ? html`<div class="error">${el.alert_message}</div>` : ''}
${
el.form_fields
? html`
<form class="converse-form" @submit=${ev => el.formSubmit(ev)}>
<p class="title">
${el.title}
<a href="${helpUrl}" target="_blank"><converse-icon
class="fa fa-circle-question"
size="1em"
title="${i18nHelp}"
></converse-icon></a>
</p>
<p class="form-help instructions">${el.instructions}</p>
<div class="form-errors hidden"></div>
${el.form_fields}
<fieldset class="buttons form-group">
<input type="submit" class="btn btn-primary" value="${i18nOk}" />
</fieldset>
</form>`
: ''
}`
}

View File

@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { __ } from 'i18n'
function _tplPollInstructions (el, currentPoll, canVote) {
if (currentPoll.over || !canVote) {
return html``
}
// eslint-disable-next-line no-undef
const i18nPollInstructions = __(LOC_poll_vote_instructions)
return html`<p class="livechat-poll-instructions">
${i18nPollInstructions}
</p>`
}
function _tplPollEnd (el, currentPoll) {
if (!currentPoll.endDate) {
return html``
}
// eslint-disable-next-line no-undef
const i18nPollEnd = __(LOC_poll_end)
return html`<p class="livechat-poll-end">
${i18nPollEnd}
${currentPoll.endDate.toLocaleString()}
</p>`
}
function _tplChoice (el, currentPoll, choice, canVote) {
// eslint-disable-next-line no-undef
const i18nChoiceN = '' + choice.choice + ':'
const votes = choice.votes
const totalVotes = currentPoll.votes
const percent = totalVotes ? (100 * votes / totalVotes).toFixed(2) : '0.00'
return html`
<tr>
<td>
${
currentPoll.over || !canVote
? html`${i18nChoiceN}`
: html`
<button type="button" class="btn btn-primary btn-sm"
@click=${ev => {
ev.preventDefault()
el.voteFor(choice)
}}
?disabled=${el.buttonDisabled}
>
${i18nChoiceN}
</button>`
}
</td>
<td class="livechat-poll-choice-label">
${choice.label}
</td>
<td>
<div class="livechat-progress-bar">
<div
role="progressbar"
style="width: ${percent}%;"
aria-valuenow="${percent}" aria-valuemin="0" aria-valuemax="100"
></div>
<p>
${votes}/${totalVotes}
(${percent}%)
</p>
</div>
</td>
</tr>`
}
export function tplPoll (el, currentPoll, canVote) {
if (!currentPoll) {
return html``
}
return html`<div class="${currentPoll.over ? 'livechat-poll-over' : ''}">
<p class="livechat-poll-question">
${currentPoll.over
? html`<button class="livechat-poll-close" @click=${el.closePoll} title="${__('Close')}">
<converse-icon class="fa fa-times" size="1em"></converse-icon>
</button>`
: ''
}
${el.collapsed
? html`
<button @click=${el.toggle} class="livechat-poll-toggle">
<converse-icon
color="var(--muc-toolbar-btn-color)"
class="fa fa-angle-right"
size="1em"></converse-icon>
</button>`
: html`
<button @click=${el.toggle} class="livechat-poll-toggle">
<converse-icon
color="var(--muc-toolbar-btn-color)"
class="fa fa-angle-down"
size="1em"></converse-icon>
</button>`
}
<span @click=${el.toggle}>
${currentPoll.question}
</span>
</p>
${
el.collapsed
? ''
: html`
<table><tbody>
${repeat(currentPoll.choices ?? [], (c) => c.choice, (c) => _tplChoice(el, currentPoll, c, canVote))}
</tbody></table>
${_tplPollInstructions(el, currentPoll, canVote)}
${_tplPollEnd(el, currentPoll)}
`
}
</div>`
}

View File

@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { XMLNS_POLL } from './constants.js'
import { _converse, api } from '../../../src/headless/core.js'
import { __ } from 'i18n'
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()
api.modal.show('livechat-converse-poll-form-modal', { model: muc })
},
a_class: '',
icon_class: 'fa-list-check', // FIXME
name: 'muc-create-poll'
})
return buttons
}

View File

@ -43,17 +43,25 @@ function start () {
function stop () {
rootResizeObserver.disconnect()
document.querySelector('converse-root')?.removeAttribute('livechat-converse-root-width')
const root = document.querySelector('converse-root')
if (root) {
root.removeAttribute('livechat-converse-root-width')
root.removeAttribute('livechat-converse-root-height')
}
}
function handle (el) {
const rect = el.getBoundingClientRect()
const height = rect.height > 576 ? 'high' : (rect.height > 250 ? 'medium' : 'small')
const width = rect.width > 576 ? 'large' : (rect.width > 250 ? 'medium' : 'small')
const previous = el.getAttribute('livechat-converse-root-width')
if (width === previous) { return }
const previousHeight = el.getAttribute('livechat-converse-root-height')
const previousWidth = el.getAttribute('livechat-converse-root-width')
if (width === previousWidth && height === previousHeight) { return }
el.setAttribute('livechat-converse-root-width', width)
el.setAttribute('livechat-converse-root-height', height)
api.trigger('livechatSizeChanged', {
height: height,
width: width
})
}

View File

@ -36,6 +36,11 @@ converse.plugins.add('livechat-converse-terms', {
console.error('Invalid x-livechat-terms type: ', type)
return
}
if (attrs.is_archived) {
// This should not happen, as we add some no-store hints. But, just in case.
console.info('Dropping an archived x-livechat-terms message')
return
}
// console.info('Received a x-livechat-terms message', attrs)
const options = {}
options['x_livechat_terms_' + type] = attrs

View File

@ -21,6 +21,7 @@ export default (o) => {
</converse-muc-heading>
<livechat-converse-muc-terms .model=${o.model} termstype="global"></livechat-converse-muc-terms>
<livechat-converse-muc-terms .model=${o.model} termstype="muc"></livechat-converse-muc-terms>
<livechat-converse-muc-poll .model=${o.model}></livechat-converse-muc-poll>
<div class="chat-body chatroom-body row no-gutters">${getChatRoomBodyTemplate(o)}</div>`
: ''}
</div>`

View File

@ -35,7 +35,21 @@ const locKeys = [
'task_list_pick_title',
'task_list_pick_empty',
'task_list_pick_message',
'muted_anonymous_message'
'muted_anonymous_message',
'new_poll',
'poll_question',
'poll_duration',
'poll_anonymous_results',
'poll_choice_n',
'poll_title',
'poll_instructions',
'poll_end',
'poll',
'poll_vote_instructions',
'poll_vote_instructions_xmpp',
'poll_is_over',
'poll_choice_invalid',
'poll_anonymous_vote_ok'
]
module.exports = locKeys