diff --git a/conversejs/custom/index.js b/conversejs/custom/index.js
index df11239c..d5fda0a1 100644
--- a/conversejs/custom/index.js
+++ b/conversejs/custom/index.js
@@ -46,6 +46,7 @@ import './plugins/fullscreen/index.js'
import '../custom/plugins/size/index.js'
import '../custom/plugins/tasks/index.js'
+import '../custom/plugins/terms/index.js'
/* END: Removable components */
import { CORE_PLUGINS } from './headless/shared/constants.js'
@@ -53,6 +54,7 @@ import { ROOM_FEATURES } from './headless/plugins/muc/constants.js'
// We must add our custom plugins to CORE_PLUGINS (so it is white listed):
CORE_PLUGINS.push('livechat-converse-size')
CORE_PLUGINS.push('livechat-converse-tasks')
+CORE_PLUGINS.push('livechat-converse-terms')
// 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/terms/components/muc-terms.js b/conversejs/custom/plugins/terms/components/muc-terms.js
new file mode 100644
index 00000000..ac57d74a
--- /dev/null
+++ b/conversejs/custom/plugins/terms/components/muc-terms.js
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: 2024 John Livingston
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { CustomElement } from 'shared/components/element.js'
+import { api } from '@converse/headless/core'
+import { html } from 'lit'
+import { __ } from 'i18n'
+
+import '../styles/muc-terms.scss'
+
+export default class MUCTermsView extends CustomElement {
+ static get properties () {
+ return {
+ model: { type: Object, attribute: true },
+ termstype: { type: String, attribute: true }
+ }
+ }
+
+ async initialize () {
+ if (!this.model) {
+ return
+ }
+ this.listenTo(this.model, 'change:x_livechat_terms_' + this.termstype, () => this.requestUpdate())
+ }
+
+ render () {
+ const terms = this.model?.get('x_livechat_terms_' + this.termstype)
+ return html`
+ ${terms && terms.body && !this._hideInfoBox(terms.body)
+ ? html`
+
+
+
+
+
+
`
+ : ''
+ }`
+ }
+
+ closeInfoBox (ev) {
+ ev.preventDefault()
+ const terms = this.model?.get('x_livechat_terms_' + this.termstype)
+ if (terms) {
+ localStorage?.setItem('x_livechat_terms_' + this.termstype + '_hidden', terms.body)
+ }
+ this.requestUpdate()
+ }
+
+ _hideInfoBox (body) {
+ // When hiding the infobox, we store in localStorage the current body, so we will show it again if message change.
+ // Note: for termstype=global we don't store the MUC server, so if user join chat from different instances,
+ // it will show terms again
+ // Note: same for termstype=muc, we don't store the MUC JID, so if user changes channel,
+ // it will probably show terms again
+ const lsHideInfoBox = localStorage?.getItem('x_livechat_terms_' + this.termstype + '_hidden')
+ return lsHideInfoBox === body
+ }
+}
+
+api.elements.define('livechat-converse-muc-terms', MUCTermsView)
diff --git a/conversejs/custom/plugins/terms/index.js b/conversejs/custom/plugins/terms/index.js
new file mode 100644
index 00000000..838820d8
--- /dev/null
+++ b/conversejs/custom/plugins/terms/index.js
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2024 John Livingston
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { converse, api } from '../../../src/headless/core.js'
+import './components/muc-terms.js'
+
+const { sizzle } = converse.env
+
+converse.plugins.add('livechat-converse-terms', {
+ dependencies: ['converse-muc'],
+ initialize () {
+ api.listen.on('parseMUCMessage', (stanza, attrs) => {
+ const livechatTerms = sizzle('x-livechat-terms', stanza)
+ if (!livechatTerms.length) {
+ return attrs
+ }
+ return Object.assign(
+ attrs,
+ {
+ x_livechat_terms: livechatTerms[0].getAttribute('type')
+ }
+ )
+ })
+ },
+ overrides: {
+ ChatRoom: {
+ onMessage: function onMessage (attrs) {
+ if (!attrs.x_livechat_terms) {
+ return this.__super__.onMessage(attrs)
+ }
+ // We received a x-livechat-terms message, we don't forward it to standard onMessage,
+ // but we just update the room attribute.
+ const type = attrs.x_livechat_terms
+ if (type !== 'global' && type !== 'muc') {
+ console.error('Invalid x-livechat-terms type: ', type)
+ return
+ }
+ // console.info('Received a x-livechat-terms message', attrs)
+ const options = {}
+ options['x_livechat_terms_' + type] = attrs
+ this.set(options)
+ // this will be displayed by the livechat-converse-muc-terms custom element,
+ // which is inserted in the DOM by the muc.js template overload.
+ }
+ }
+ }
+})
diff --git a/conversejs/custom/plugins/terms/styles/muc-terms.scss b/conversejs/custom/plugins/terms/styles/muc-terms.scss
new file mode 100644
index 00000000..27f7ccef
--- /dev/null
+++ b/conversejs/custom/plugins/terms/styles/muc-terms.scss
@@ -0,0 +1,41 @@
+/*
+ * SPDX-FileCopyrightText: 2024 John Livingston
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+.conversejs {
+ livechat-converse-muc-terms {
+ background-color: var(--peertube-main-background);
+ color: var(--peertube-main-foreground);
+
+ div {
+ align-items: center;
+ border: 1px solid var(--peertube-menu-background);
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ margin: 5px;
+ padding: 5px;
+
+ converse-rich-text {
+ flex-grow: 2;
+ max-height: 5em;
+ overflow-y: scroll;
+ white-space: pre-wrap;
+ }
+
+ .livechat-hide-terms-info-box {
+ cursor: pointer;
+ font-size: var(--font-size-small);
+ flex-shrink: 2;
+ }
+ }
+ }
+}
+
+.livechat-readonly .conversejs {
+ livechat-converse-muc-terms {
+ display: none !important;
+ }
+}
diff --git a/conversejs/custom/templates/muc-bottom-panel.js b/conversejs/custom/templates/muc-bottom-panel.js
index ea5ecb8b..cfac6d2c 100644
--- a/conversejs/custom/templates/muc-bottom-panel.js
+++ b/conversejs/custom/templates/muc-bottom-panel.js
@@ -63,7 +63,7 @@ class SlowMode extends CustomElement {
LOC_slow_mode_info,
this.model.config.get('slow_mode_duration')
)}
-
+
`
diff --git a/conversejs/custom/templates/muc.js b/conversejs/custom/templates/muc.js
new file mode 100644
index 00000000..bb9820c6
--- /dev/null
+++ b/conversejs/custom/templates/muc.js
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2013-2024 JC Brand
+// SPDX-FileCopyrightText: 2024 John Livingston
+//
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Must import the original muc.js, because it imports some custom elements files.
+import '../../src/plugins/muc-views/templates/muc.js'
+import { getChatRoomBodyTemplate } from '../../src/plugins/muc-views/utils.js'
+import { html } from 'lit'
+
+// Overloading the original muc.js, to add some custom elements.
+export default (o) => {
+ return html`
+
+
+ ${
+ o.model
+ ? html`
+
+
+
+
+
${getChatRoomBodyTemplate(o)}
`
+ : ''}
+
`
+}
diff --git a/conversejs/custom/webpack.livechat.js b/conversejs/custom/webpack.livechat.js
index 85e68895..bf6e041c 100644
--- a/conversejs/custom/webpack.livechat.js
+++ b/conversejs/custom/webpack.livechat.js
@@ -40,6 +40,7 @@ module.exports = merge(prod, {
alias: {
'./templates/muc-bottom-panel.js': path.resolve('custom/templates/muc-bottom-panel.js'),
'./templates/muc-head.js': path.resolve(__dirname, 'custom/templates/muc-head.js'),
+ './templates/muc.js': path.resolve(__dirname, 'custom/templates/muc.js'),
'../../templates/background_logo.js$': path.resolve(__dirname, 'custom/templates/background_logo.js'),
'./templates/muc-chatarea.js': path.resolve('custom/templates/muc-chatarea.js'),
diff --git a/prosody-modules/mod_muc_peertubelivechat_terms/README.md b/prosody-modules/mod_muc_peertubelivechat_terms/README.md
new file mode 100644
index 00000000..0c2229f5
--- /dev/null
+++ b/prosody-modules/mod_muc_peertubelivechat_terms/README.md
@@ -0,0 +1,34 @@
+
+
+# mod_muc_peertubelivechat_terms
+
+This module is a custom module to handle Terms&Conditions in the livechat Peertube plugin.
+
+This module is part of peertube-plugin-livechat, and is under the same LICENSE.
+
+## Features
+
+When a new occupant session is created for a MUC, this module will send to the user the global terms,
+and the MUC-specific terms (if defined).
+
+This is done by sending groupchat messages.
+These messages will contain a "x-livechat-terms" tag, so that livechat front-end can detect these messages, and display them differently.
+For standard XMPP clients, these messages will show as standard MUC message coming from a specific nickname.
+
+## Configuration
+
+This modules take following options.
+
+### muc_terms_service_nickname
+
+The nickname that will be used by service messages.
+This module reserves the nickname, so than nobody can use it in MUC rooms
+(we don't want any user to spoof this nickname).
+
+### muc_terms
+
+The global terms.
diff --git a/prosody-modules/mod_muc_peertubelivechat_terms/mod_muc_peertubelivechat_terms.lua b/prosody-modules/mod_muc_peertubelivechat_terms/mod_muc_peertubelivechat_terms.lua
new file mode 100644
index 00000000..be4272ca
--- /dev/null
+++ b/prosody-modules/mod_muc_peertubelivechat_terms/mod_muc_peertubelivechat_terms.lua
@@ -0,0 +1,66 @@
+-- mod_muc_peertubelivechat_terms
+--
+-- 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/
+--
+local jid_escape = require "util.jid".escape;
+local jid_resource = require "util.jid".resource;
+local st = require "util.stanza";
+local id = require "util.id";
+
+local service_nickname = module:get_option_string("muc_terms_service_nickname", "Service");
+local global_terms = module:get_option_string("muc_terms", "");
+
+-- send the terms when joining:
+function send_terms(event)
+ local origin = event.origin;
+ local room = event.room;
+ local occupant = event.occupant;
+ if global_terms then
+ local from = room.jid .. '/' .. jid_escape(service_nickname);
+ module:log("debug", "Sending global terms to %s from %s (room %s)", occupant.jid, from, room);
+ local message = st.message({
+ type = "groupchat",
+ to = occupant.jid,
+ from = from,
+ id = id.medium()
+ }, global_terms)
+ :tag('x-livechat-terms', { type = "global" }):up(); -- adding a custom tag to specify that it is a "terms" message, so that frontend can display it with a special template.
+ origin.send(message);
+ end
+end
+-- Note: we could do that on muc-occupant-joined or muc-occupant-session-new.
+-- The first will not send it to multiple clients, the second will.
+-- After some reflexion, i will try muc-occupant-session-new, and see if it works as expected.
+module:hook("muc-occupant-session-new", send_terms);
+
+-- reserve the service_nickname:
+function enforce_nick_policy(event)
+ local origin, stanza = event.origin, event.stanza;
+ local requested_nick = jid_resource(stanza.attr.to);
+ local room = event.room;
+ if not room then return; end
+
+ if requested_nick == service_nickname then
+ module:log("debug", "Occupant tried to use the %s reserved nickname, blocking it.", service_nickname);
+ local reply = st.error_reply(stanza, "cancel", "conflict", nil, room.jid):up();
+ origin.send(reply);
+ return true;
+ end
+end
+module:hook("muc-occupant-pre-join", enforce_nick_policy);
+module:hook("muc-occupant-pre-change", enforce_nick_policy);
+
+-- security check: we must remove all "x-livechat-terms" tag, to be sure nobody tries to spoof terms!
+module:hook("muc-occupant-groupchat", function(event)
+ event.stanza:maptags(function (child)
+ if child.name == 'x-livechat-terms' then
+ return nil;
+ end
+ return child;
+ end);
+end, 100);
diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts
index 90e00c0b..d8f8bd54 100644
--- a/server/lib/prosody/config.ts
+++ b/server/lib/prosody/config.ts
@@ -175,7 +175,8 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise()
@@ -199,6 +200,9 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise
@@ -33,6 +56,9 @@ type ConfigLogExpiration =
ConfigLogExpirationNever | ConfigLogExpirationPeriod | ConfigLogExpirationSeconds | ConfigLogExpirationError
function writeValue (value: ConfigEntryValue): string {
+ if (value instanceof ConfigEntryValueMultiLineString) {
+ return value.serialize() + ';\n'
+ }
if (typeof value === 'boolean') {
return value.toString() + ';\n'
}
@@ -151,7 +177,7 @@ class ProsodyConfigContent {
log: string
prosodyDomain: string
- constructor (paths: ProsodyFilePaths, prosodyDomain: string) {
+ constructor (paths: ProsodyFilePaths, prosodyDomain: string, chatTerms?: string) {
this.paths = paths
this.global = new ProsodyConfigGlobal()
this.log = ''
@@ -212,9 +238,16 @@ class ProsodyConfigContent {
this.muc.set('muc_room_default_history_length', 20)
this.muc.add('modules_enabled', 'muc_slow_mode')
+ this.muc.add('slow_mode_duration_form_position', 120)
+
this.muc.add('modules_enabled', 'pubsub_peertubelivechat')
this.muc.add('modules_enabled', 'muc_peertubelivechat_roles')
- this.muc.add('slow_mode_duration_form_position', 120)
+
+ this.muc.add('modules_enabled', 'muc_peertubelivechat_terms')
+ this.muc.set('muc_terms_service_nickname', 'Peertube')
+ if (chatTerms) {
+ this.muc.set('muc_terms', new ConfigEntryValueMultiLineString(chatTerms))
+ }
}
useAnonymous (autoBanIP: boolean): void {