This commit is contained in:
matty 2024-10-01 19:19:13 -04:00
commit b5e18faaaa
342 changed files with 45714 additions and 16642 deletions

View File

@ -1,14 +0,0 @@
{
"root": true,
"env": {},
"extends": [],
"globals": {},
"plugins": [],
"ignorePatterns": [
"node_modules/", "dist/", "webpack.config.js",
"build/",
"vendor/",
"support/documentation",
"build-*js"],
"rules": {}
}

31
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
#
# SPDX-License-Identifier: AGPL-3.0-only
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
versioning-strategy: increase
ignore:
- dependency-name: typescript
versions:
- ">=5.6.0" # linting libs are not ready for 5.6
- dependency-name: "@types/node"
versions:
- ">=17.0.0" # must be set to the Peertube required version.
- dependency-name: "@peertube/peertube-types"
versions:
- ">5.2.0" # must be set to the Peertube required version.
- dependency-name: eslint
versions:
- ">=9.0.0" # not ready for v9, missing dependencies.
- dependency-name: got
versions:
- ">=12.0.0" # breaking changes, must adapt code.
- dependency-name: "@typescript-eslint/parser"
versions:
- ">=8.5.0" # for now 8.5.0 is broken because of the lack of ./tsconfig.json file. Must fix conf.

View File

@ -33,7 +33,7 @@ jobs:
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: '0.80.0'
hugo-version: '0.132.2'
extended: true
- name: Generate documentation translations

View File

@ -22,7 +22,7 @@ pages:
image: registry.gitlab.com/pages/hugo/hugo_extended:latest
variables:
GIT_SUBMODULE_STRATEGY: recursive
GIT_SUBMODULE_PATHS: support/documentation/themes/hugo-theme-learn
GIT_SUBMODULE_PATHS: support/documentation/themes/hugo-theme-relearn
script:
# gitlab need the generated documentation to be in the /public dir.
- hugo -s support/documentation/ --minify -d ../../public/ --baseURL='https://livingston.frama.io/peertube-plugin-livechat/'

6
.gitmodules vendored
View File

@ -2,6 +2,6 @@
#
# SPDX-License-Identifier: AGPL-3.0-only
[submodule "documentation/themes/hugo-theme-learn"]
path = support/documentation/themes/hugo-theme-learn
url = https://github.com/matcornic/hugo-theme-learn.git
[submodule "support/documentation/themes/hugo-theme-relearn"]
path = support/documentation/themes/hugo-theme-relearn
url = https://github.com/McShelby/hugo-theme-relearn.git

View File

@ -32,3 +32,7 @@ License: AGPL-3.0-only
Files: .github/PULL_REQUEST_TEMPLATE.md
Copyright: 2024 John Livingston <https://www.john-livingston.fr/>
License: AGPL-3.0-only
Files: prosody-modules/mod_firewall/*
Copyright: Prosody Community Modules <https://modules.prosody.im/mod_firewall>
License: MIT

View File

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
'use strict';
'use strict'
module.exports = {
extends: [
@ -14,7 +14,7 @@ module.exports = {
// extending the kebab-case to accept ConverseJS class names.
'^([a-z][a-z0-9]*)(-[a-z0-9]+)*((__|--)[a-z]+(-[a-z0-9]+)*)?$',
{
message: 'Expected class selector to be kebab-case, or ConverseJS-style.',
message: 'Expected class selector to be kebab-case, or ConverseJS-style.'
}
]
}

View File

@ -1,5 +1,74 @@
# Changelog
## 12.0.0 (Not Released Yet)
TODO Before releasing:
* update ConverseJS with latest merges (there are currently some known bugs).
* as the Prosody version changes, check these stress test https://github.com/JohnXLivingston/livechat-perf-test/tree/main/tests/33-prosody-gc and apply the correct gc parameter. Also see https://github.com/JohnXLivingston/peertube-plugin-livechat/issues/300
### Importante Notes
This version requires Peertube 5.2.0 or superior.
It also requires NodeJS 16 or superior (same as Peertube 5.2.0.).
If you use the "system Prosody", you should update to Prosody 0.12.4, and Lua 5.4.
### New features
* #131: Emoji only mode.
* #516: new option for the moderation bot: forbid duplicate messages.
* #517: new option for the moderation bot: forbid messages with too many special characters.
* #518: moderators can send announcements and highlighted messages.
### Minor changes and fixes
* Updating ConverseJS (v11 WIP) with latest fixes.
* Updating Prosody AppImage to Prosody 0.12.4 + Lua 5.4.
* Various translation updates.
* Using Typescript 5.5.4, and Eslint 8.57.0 (with new ruleset).
* Fix race condition in bot/ctl.
* Various type improvements.
* Update dependencies.
* Fix emoji picker colors and size.
* Fix: moderation delay max value was not correctly handled.
## 11.0.1
### Minor changes and fixes
* Fix "send message" button that was sending the message twice.
## 11.0.0
### Importante Notes
With the new [mod_firewall](https://livingston.frama.io/peertube-plugin-livechat/documentation/admin/mod_firewall/) feature, Peertube admins can write firewall rules for the Prosody server. These rules could be used to run arbitrary code on the server. If you are a hosting provider, and you don't want to allow Peertube admins to write such rules, you can disable the online editing by creating a `disable_mod_firewall_editing` file in the plugin directory. Check the documentation for more information. This is opt-out, as Peertube admins can already run arbitrary code just by installing any plugin.
The concord theme was removed from ConverseJS. If you had it set in the plugin settings, it will fallback to the Peertube theme.
### New features
* Updating ConverseJS, to use upstream (v11 WIP). This comes with many improvements and new features.
* #146: copy message button for moderators.
* #137: option to hide moderator name who made actions (kick, ban, message moderation, ...).
* #144: [moderator notes](https://livingston.frama.io/peertube-plugin-livechat/documentation/user/streamers/moderation_notes/).
* #145: action for moderators to find all messages from a given participant.
* #97: option to use and configure [mod_firewall](https://livingston.frama.io/peertube-plugin-livechat/documentation/admin/mod_firewall/) at the server level.
### Minor changes and fixes
* #118: improved accessibility.
* Avatar set for anonymous users: new 'none' choice (that will fallback to Converse new colorized avatars).
* New translation: Albanian.
* Translation updates: Crotian, Japanese, traditional Chinese, Arabic, Galician.
* Updated mod_muc_moderation to upstream.
* Fix new task ordering.
* Fix: clicking on the current user nickname in message history was failing to open the profile modal.
* Fix: increase chat height on small screens, try to better detect the device viewport size and orientation.
* Converse theme: removed concord, added cyberpunk.
* Fixed Converse theme settings localization.
* Fix: improved minimum chat width.
## 10.3.3
### Minor changes and fixes

View File

@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* stylelint-disable custom-property-pattern */
@use "sass:color";
@use "../../variables";
.peertube-plugin-livechat-admin-firewall {
h1 {
padding-top: 40px;
/* See Peertube sub-menu-h1 mixin */
font-size: 1.3rem;
border-bottom: 2px solid var(--greyBackgroundColor);
padding-bottom: 15px;
}
textarea[name^="_content_"] {
min-height: 10rem;
}
input[type="submit"],
input[type="reset"],
button[type="submit"],
button[type="reset"] {
// Peertube rounded-line-height-1-5 mixins
line-height: variables.$button-calc-line-height;
// Peertube peertube-button mixin
padding: 4px 13px;
border: 0;
font-weight: variables.$font-semibold;
border-radius: 3px !important;
text-align: center;
cursor: pointer;
font-size: variables.$button-font-size;
}
input[type="submit"],
button[type="submit"] {
// Peertube orange-button mixin
&,
&:active,
&:focus {
color: #fff;
background-color: var(--mainColor);
}
&:hover {
color: #fff;
background-color: var(--mainHoverColor);
}
&[disabled],
&.disabled {
cursor: default;
color: #fff;
background-color: var(--inputBorderColor);
}
}
input[type="reset"],
button[type="reset"] {
// Peertube grey-button mixin
background-color: var(--greyBackgroundColor);
color: var(--greyForegroundColor);
&:hover,
&:active,
&:focus,
&[disabled],
&.disabled {
color: var(--greyForegroundColor);
background-color: var(--greySecondaryBackgroundColor);
}
&[disabled],
&.disabled {
cursor: default;
}
}
.peertube-livechat-admin-firewall-col-name {
width: 25%;
}
.peertube-livechat-admin-firewall-col-content {
width: 65%;
}
}

View File

@ -174,6 +174,12 @@ $small-view: 800px;
a {
/* See Peertube .video-channel-names */
width: fit-content;
display: flex;
align-items: baseline;
/* stylelint-disable-next-line value-keyword-case */
color: var(--mainForegroundColor);
&:hover,
&:focus,
&:active {
@ -184,12 +190,6 @@ $small-view: 800px;
outline: none !important;
}
width: fit-content;
display: flex;
align-items: baseline;
/* stylelint-disable-next-line value-keyword-case */
color: var(--mainForegroundColor);
div:first-child {
/* See Peertube .video-channel-display-name */
font-weight: variables.$font-semibold;

View File

@ -15,9 +15,9 @@ livechat-spinner,
height: 48px;
margin: 20px;
/* stylelint-disable-next-line custom-property-pattern */
border: 5px solid var(--greyBackgroundColor);
border: 5px solid var(--greyBackgroundColor) !important; // !important is required for it to work in ConverseJS
/* stylelint-disable-next-line custom-property-pattern */
border-bottom-color: var(--mainColor);
border-bottom-color: var(--mainColor) !important; // !important is required for it to work in ConverseJS
border-radius: 50%;
display: inline-block;
box-sizing: border-box;

View File

@ -9,10 +9,10 @@
livechat-token-list {
table {
@include tables.data-table;
width: 100%;
@include tables.data-table;
tr th:first-child,
tr th:last-child {
width: 50px;

View File

@ -9,4 +9,5 @@
@use "elements/index";
@use "video";
@use "configuration/configuration";
@use "list-rooms/list-rooms.scss";
@use "admin/firewall/firewall";
@use "list-rooms/list-rooms";

View File

@ -18,17 +18,31 @@
/* Note: livechat-viewer-mode-content (the form where anonymous users can
choose nickname or log in with external account), can be something like
~180px height (at time of writing).
We must ensure that the 200px limit for converse-muc and converse-root is
We must ensure that the px height limit for converse-muc and converse-root is
always higher than livechat-viewer-mode-content max size.
Note: We also must ensure that when the user has choosen its nickname, and there is an
ongoing poll, the user can see the chat when the poll is folded.
*/
#peertube-plugin-livechat-container converse-root {
display: block;
border: 1px solid black;
min-height: max(30vh, 200px); // Always at least 200px, and ideally at least 30% of viewport.
min-height: max(30vh, 300px); // Always at least 200px, and ideally at least 30% of viewport.
height: 100%;
min-width: min(400px, 25vw);
converse-muc {
min-height: max(59vh, 400px);
min-height: max(30vh, 300px);
}
@media screen and (orientation: portrait) and (width <= 767px) {
/* On small screen, and when portrait mode, we are giving the chat more vertical space.
It should go under the video.
*/
min-height: max(58vh, 300px);
converse-muc {
min-height: max(58vh, 300px);
}
}
}

View File

@ -15,7 +15,7 @@ const clientFiles = [
'admin-plugin-client-plugin'
]
function loadLocs() {
function loadLocs(globalFile) {
// Loading english strings, so we can inject them as constants.
const refFile = path.resolve(__dirname, 'dist', 'languages', 'en.reference.json')
if (!fs.existsSync(refFile)) {
@ -25,7 +25,6 @@ function loadLocs() {
// Reading client/@types/global.d.ts, to have a list of needed localized strings.
const r = {}
const globalFile = path.resolve(__dirname, 'client', '@types', 'global.d.ts')
const globalFileContent = '' + fs.readFileSync(globalFile)
const matches = globalFileContent.matchAll(/^declare const LOC_(\w+)\b/gm)
for (const match of matches) {
@ -41,7 +40,7 @@ function loadLocs() {
const define = Object.assign({
PLUGIN_CHAT_PACKAGE_NAME: JSON.stringify(packagejson.name),
PLUGIN_CHAT_SHORT_NAME: JSON.stringify(packagejson.name.replace(/^peertube-plugin-/, ''))
}, loadLocs())
}, loadLocs(path.resolve(__dirname, 'client', '@types', 'global.d.ts')))
const configs = clientFiles.map(f => ({
entryPoints: [ path.resolve(__dirname, 'client', f + '.ts') ],
@ -59,8 +58,14 @@ const configs = clientFiles.map(f => ({
outfile: path.resolve(__dirname, 'dist/client', f + '.js'),
}))
const defineBuiltin = Object.assign(
{},
loadLocs(path.resolve(__dirname, 'conversejs', 'lib', '@types', 'global.d.ts'))
)
configs.push({
entryPoints: ["./conversejs/builtin.ts"],
define: defineBuiltin,
bundle: true,
minify: true,
sourcemap,

View File

@ -10,12 +10,12 @@ set -euo pipefail
# This script download the Prosody AppImage from the https://github.com/JohnXLivingston/prosody-appimage project.
repo_base_url='https://github.com/JohnXLivingston/prosody-appimage/releases/download'
wanted_release='v0.12.3-1'
wanted_release='v0.12.4-3'
x86_64_filename='prosody-x86_64.AppImage'
x86_64_sha256sum='f4af9bfefa2f804ad7e8b03a68f04194abb801f070ae620b3d4bcedb144e8523'
x86_64_sha256sum='83a583ac7036387514bed17afab257dab4161ccdd0ab7453818c78b51f830357'
aarch64_filename='prosody-aarch64.AppImage'
aarch64_sha256sum='878c5be719e1e36a84d637fd2bd44e3059aa91ddb6906ad05f1dd0334078df09'
aarch64_sha256sum='7b7e6bf30d4498fc99a40022232c3065707ee4f4df24dc17947b007621634304'
download_dir="$(pwd)/vendor/prosody-appimage"
dist_dir="$(pwd)/dist/server/prosody"

View File

@ -1,41 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"es6": true
},
"extends": [
"standard-with-typescript",
"plugin:lit/recommended"
],
"globals": {},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
"project": [
"./client/tsconfig.json"
]
},
"plugins": [
"@typescript-eslint"
],
"ignorePatterns": [],
"rules": {
"@typescript-eslint/no-unused-vars": [2, {"argsIgnorePattern": "^_"}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/return-await": [2, "in-try-catch"], // FIXME: correct?
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/triple-slash-reference": "off",
"max-len": [
"error",
{
"code": 120,
"comments": 120
}
],
"no-unused-vars": "off"
}
}

View File

@ -12,6 +12,7 @@ declare const MUSTACHE_CONFIGURATION_CHANNEL: string
// Constants that begins with "LOC_" are loaded by build-client.js, reading the english locale file.
// See the online documentation: https://livingston.frama.io/peertube-plugin-livechat/contributing/translate/
declare const LOC_ONLINE_HELP: string
declare const LOC_CHAT: string
declare const LOC_OPEN_CHAT: string
declare const LOC_OPEN_CHAT_NEW_WINDOW: string
declare const LOC_CLOSE_CHAT: string
@ -56,12 +57,12 @@ declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_BOT_OPTIONS_TITLE: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC2: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_LABEL: string
@ -133,3 +134,29 @@ declare const LOC_POLL_VOTE_OK: string
declare const LOC_MODERATION_DELAY: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_MODERATION_DELAY_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_ANONYMIZE_MODERATION_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_ANONYMIZE_MODERATION_DESC: string
declare const LOC_PROSODY_FIREWALL_CONFIGURATION: string
declare const LOC_PROSODY_FIREWALL_CONFIGURATION_HELP: string
declare const LOC_PROSODY_FIREWALL_DISABLED_WARNING: string
declare const LOC_PROSODY_FIREWALL_FILE_ENABLED: string
declare const LOC_PROSODY_FIREWALL_NAME: string
declare const LOC_PROSODY_FIREWALL_NAME_DESC: string
declare const LOC_PROSODY_FIREWALL_CONTENT: string
declare const LOC_EMOJI_ONLY_MODE_TITLE: string
declare const LOC_EMOJI_ONLY_MODE_DESC_1: string
declare const LOC_EMOJI_ONLY_MODE_DESC_2: string
declare const LOC_EMOJI_ONLY_MODE_DESC_3: string
declare const LOC_EMOJI_ONLY_ENABLE_ALL_ROOMS: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_TOLERANCE_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_TOLERANCE_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_DESC: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_DELAY_LABEL: string
declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_DELAY_DESC: string

View File

@ -243,7 +243,10 @@ function register (clientOptions: RegisterClientOptions): void {
}
} catch (error: any) {
console.error(error)
peertubeHelpers.notifier.error(error.toString(), await peertubeHelpers.translate(LOC_LOADING_ERROR))
peertubeHelpers.notifier.error(
(error as Error).toString(),
await peertubeHelpers.translate(LOC_LOADING_ERROR)
)
}
}
})
@ -270,6 +273,8 @@ function register (clientOptions: RegisterClientOptions): void {
return !(options.formValues['chat-all-lives'] === true && options.formValues['chat-per-live-video'] === true)
case 'auto-ban-anonymous-ip':
return options.formValues['chat-no-anonymous'] !== false
case 'prosody-firewall-configure-button':
return options.formValues['prosody-firewall-enabled'] !== true
}
if (name?.startsWith('external-auth-')) {

View File

@ -8,6 +8,7 @@ import { registerConfiguration } from './common/configuration/register'
import { registerVideoWatch } from './common/videowatch/register'
import { registerRoom } from './common/room/register'
import { initPtContext } from './common/lib/contexts/peertube'
import { registerAdminFirewall } from './common/admin/firewall/register'
import './common/lib/elements' // Import shared elements.
async function register (clientOptions: RegisterClientOptions): Promise<void> {
@ -45,7 +46,7 @@ async function register (clientOptions: RegisterClientOptions): Promise<void> {
])
const webchatFieldOptions: RegisterClientFormFieldOptions = {
name: 'livechat-active',
label: label,
label,
descriptionHTML: description,
type: 'input-checkbox',
default: true,
@ -69,7 +70,8 @@ async function register (clientOptions: RegisterClientOptions): Promise<void> {
await Promise.all([
registerVideoWatch(),
registerRoom(clientOptions),
registerConfiguration(clientOptions)
registerConfiguration(clientOptions),
registerAdminFirewall(clientOptions)
])
}

View File

@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { AdminFirewallConfiguration } from 'shared/lib/types'
import { AdminFirewallService } from '../services/admin-firewall'
import { LivechatElement } from '../../../lib/elements/livechat'
import { ValidationError, ValidationErrorType } from '../../../lib/models/validation'
import { tplAdminFirewall } from '../templates/admin-firewall'
import { TemplateResult, html, nothing } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { Task } from '@lit/task'
@customElement('livechat-admin-firewall')
export class AdminFirewallElement extends LivechatElement {
private _adminFirewallService?: AdminFirewallService
@state()
public firewallConfiguration?: AdminFirewallConfiguration
@state()
public validationError?: ValidationError
@state()
public actionDisabled = false
private _asyncTaskRender: Task
constructor () {
super()
this._asyncTaskRender = this._initTask()
}
protected _initTask (): Task {
return new Task(this, {
task: async () => {
this._adminFirewallService = new AdminFirewallService(this.ptOptions)
this.firewallConfiguration = await this._adminFirewallService.fetchConfiguration()
this.actionDisabled = false // in case of reset
},
args: () => []
})
}
/**
* Resets the form by reloading data from backend.
*/
public async reset (event?: Event): Promise<void> {
event?.preventDefault()
this.actionDisabled = true
this._asyncTaskRender = this._initTask()
this.requestUpdate()
}
/**
* Resets the validation errors.
* @param ev the vent
*/
public resetValidation (_ev?: Event): void {
if (this.validationError) {
this.validationError = undefined
this.requestUpdate('_validationError')
}
}
/**
* Saves the configuration.
* @param event event
*/
public readonly saveConfig = async (event?: Event): Promise<void> => {
event?.preventDefault()
if (!this.firewallConfiguration || !this._adminFirewallService) {
return
}
this.actionDisabled = true
this._adminFirewallService.saveConfiguration(this.firewallConfiguration)
.then((result: AdminFirewallConfiguration) => {
this.validationError = undefined
this.ptTranslate(LOC_SUCCESSFULLY_SAVED).then((msg) => {
this.ptNotifier.info(msg)
}, () => {})
this.firewallConfiguration = result
this.requestUpdate('firewallConfiguration')
this.requestUpdate('_validationError')
})
.catch(async (error: Error) => {
this.validationError = undefined
if (error instanceof ValidationError) {
this.validationError = error
}
this.logger.warn(`A validation error occurred in saving configuration. ${error.name}: ${error.message}`)
this.ptNotifier.error(
error.message
? error.message
: await this.ptTranslate(LOC_ERROR)
)
this.requestUpdate('_validationError')
})
.finally(() => {
this.actionDisabled = false
})
}
public readonly getInputValidationClass = (propertyName: string): Record<string, boolean> => {
const validationErrorTypes: ValidationErrorType[] | undefined =
this.validationError?.properties[`${propertyName}`]
return validationErrorTypes ? (validationErrorTypes.length ? { 'is-invalid': true } : { 'is-valid': true }) : {}
}
public readonly renderFeedback = (feedbackId: string,
propertyName: string): TemplateResult | typeof nothing => {
const errorMessages: TemplateResult[] = []
const validationErrorTypes: ValidationErrorType[] | undefined =
this.validationError?.properties[`${propertyName}`] ?? undefined
// FIXME: this code is duplicated in dymamic table form
if (validationErrorTypes && validationErrorTypes.length !== 0) {
return html`<div id=${feedbackId} class="invalid-feedback">${errorMessages}</div>`
} else {
return nothing
}
}
protected override render = (): unknown => {
return this._asyncTaskRender.render({
pending: () => html`<livechat-spinner></livechat-spinner>`,
error: () => html`<livechat-error></livechat-error>`,
complete: () => tplAdminFirewall(this)
})
}
}

View File

@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import './admin-firewall'

View File

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterClientOptions } from '@peertube/peertube-types/client'
import { html, render } from 'lit'
import './elements' // Import all needed elements.
/**
* Registers stuff related to mod_firewall configuration.
* @param clientOptions Peertube client options
*/
async function registerAdminFirewall (clientOptions: RegisterClientOptions): Promise<void> {
const { registerClientRoute } = clientOptions
registerClientRoute({
route: 'livechat/admin/firewall',
onMount: async ({ rootEl }) => {
render(html`<livechat-admin-firewall .registerClientOptions=${clientOptions}></livechat-admin-firewall>`, rootEl)
}
})
}
export {
registerAdminFirewall
}

View File

@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { RegisterClientOptions } from '@peertube/peertube-types/client'
import type { AdminFirewallConfiguration } from 'shared/lib/types'
import {
maxFirewallFileSize, maxFirewallNameLength, maxFirewallFiles, firewallNameRegexp
} from 'shared/lib/admin-firewall'
import { ValidationError, ValidationErrorType } from '../../../lib/models/validation'
import { getBaseRoute } from '../../../../utils/uri'
export class AdminFirewallService {
public _registerClientOptions: RegisterClientOptions
private readonly _headers: any = {}
constructor (registerClientOptions: RegisterClientOptions) {
this._registerClientOptions = registerClientOptions
this._headers = this._registerClientOptions.peertubeHelpers.getAuthHeader() ?? {}
this._headers['content-type'] = 'application/json;charset=UTF-8'
}
async validateConfiguration (adminFirewallConfiguration: AdminFirewallConfiguration): Promise<boolean> {
const propertiesError: ValidationError['properties'] = {}
if (adminFirewallConfiguration.files.length > maxFirewallFiles) {
const validationError = new ValidationError(
'AdminFirewallConfigurationValidationError',
await this._registerClientOptions.peertubeHelpers.translate(LOC_TOO_MANY_ENTRIES),
propertiesError
)
throw validationError
}
const seen = new Map<string, true>()
for (const [i, e] of adminFirewallConfiguration.files.entries()) {
propertiesError[`files.${i}.name`] = []
if (e.name === '') {
propertiesError[`files.${i}.name`].push(ValidationErrorType.Missing)
} else if (e.name.length > maxFirewallNameLength) {
propertiesError[`files.${i}.name`].push(ValidationErrorType.TooLong)
} else if (!firewallNameRegexp.test(e.name)) {
propertiesError[`files.${i}.name`].push(ValidationErrorType.WrongFormat)
} else if (seen.has(e.name)) {
propertiesError[`files.${i}.name`].push(ValidationErrorType.Duplicate)
} else {
seen.set(e.name, true)
}
propertiesError[`files.${i}.content`] = []
if (e.content.length > maxFirewallFileSize) {
propertiesError[`files.${i}.content`].push(ValidationErrorType.TooLong)
}
}
if (Object.values(propertiesError).find(e => e.length > 0)) {
const validationError = new ValidationError(
'AdminFirewallConfigurationValidationError',
await this._registerClientOptions.peertubeHelpers.translate(LOC_VALIDATION_ERROR),
propertiesError
)
throw validationError
}
return true
}
async saveConfiguration (
adminFirewallConfiguration: AdminFirewallConfiguration
): Promise<AdminFirewallConfiguration> {
if (!await this.validateConfiguration(adminFirewallConfiguration)) {
throw new Error('Invalid form data')
}
const response = await fetch(
getBaseRoute(this._registerClientOptions) + '/api/admin/firewall/',
{
method: 'POST',
headers: this._headers,
body: JSON.stringify(adminFirewallConfiguration)
}
)
if (!response.ok) {
throw new Error('Failed to save configuration.')
}
return response.json()
}
async fetchConfiguration (): Promise<AdminFirewallConfiguration> {
const response = await fetch(
getBaseRoute(this._registerClientOptions) + '/api/admin/firewall/',
{
method: 'GET',
headers: this._headers
}
)
if (!response.ok) {
throw new Error('Can\'t get firewall configuration.')
}
return response.json()
}
}

View File

@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import type { AdminFirewallElement } from '../elements/admin-firewall'
import type { TemplateResult } from 'lit'
import type { DynamicFormHeader, DynamicFormSchema } from '../../../lib/elements/dynamic-table-form'
import { maxFirewallFiles, maxFirewallNameLength, maxFirewallFileSize } from 'shared/lib/admin-firewall'
import { ptTr } from '../../../lib/directives/translation'
import { html } from 'lit'
export function tplAdminFirewall (el: AdminFirewallElement): TemplateResult {
const tableHeaderList: DynamicFormHeader = {
enabled: {
colName: ptTr(LOC_PROSODY_FIREWALL_FILE_ENABLED)
},
name: {
colName: ptTr(LOC_PROSODY_FIREWALL_NAME),
description: ptTr(LOC_PROSODY_FIREWALL_NAME_DESC),
headerClassList: ['peertube-livechat-admin-firewall-col-name']
},
content: {
colName: ptTr(LOC_PROSODY_FIREWALL_CONTENT),
headerClassList: ['peertube-livechat-admin-firewall-col-content']
}
}
const tableSchema: DynamicFormSchema = {
enabled: {
inputType: 'checkbox',
default: true
},
name: {
inputType: 'text',
default: '',
maxlength: maxFirewallNameLength
},
content: {
inputType: 'textarea',
default: '',
maxlength: maxFirewallFileSize
}
}
return html`
<div class="margin-content peertube-plugin-livechat-admin-firewall">
<h1>
${ptTr(LOC_PROSODY_FIREWALL_CONFIGURATION)}
</h1>
<p>
${ptTr(LOC_PROSODY_FIREWALL_CONFIGURATION_HELP, true)}
<livechat-help-button .page=${'documentation/admin/mod_firewall'}>
</livechat-help-button>
</p>
${
el.firewallConfiguration?.enabled
? ''
: html`<p class="peertube-plugin-livechat-warning">${ptTr(LOC_PROSODY_FIREWALL_DISABLED_WARNING, true)}</p>`
}
<form role="form" @submit=${el.saveConfig} @change=${el.resetValidation}>
<livechat-dynamic-table-form
.header=${tableHeaderList}
.schema=${tableSchema}
.maxLines=${maxFirewallFiles}
.validation=${el.validationError?.properties}
.validationPrefix=${'files'}
.rows=${el.firewallConfiguration?.files ?? []}
@update=${(e: CustomEvent) => {
el.resetValidation(e)
if (el.firewallConfiguration) {
el.firewallConfiguration.files = e.detail
el.requestUpdate('firewallConfiguration')
}
}
}
></livechat-dynamic-table-form>
<div class="form-group mt-5">
<button type="reset" @click=${el.reset} ?disabled=${el.actionDisabled}>
${ptTr(LOC_CANCEL)}
</button>
<button type="submit" ?disabled=${el.actionDisabled}>
${ptTr(LOC_SAVE)}
</button>
</div>
</form>
</div>`
}

View File

@ -32,7 +32,7 @@ export class ChannelConfigurationElement extends LivechatElement {
public validationError?: ValidationError
@state()
public actionDisabled: boolean = false
public actionDisabled = false
private _asyncTaskRender: Task
@ -113,7 +113,7 @@ export class ChannelConfigurationElement extends LivechatElement {
}
}
public readonly getInputValidationClass = (propertyName: string): { [key: string]: boolean } => {
public readonly getInputValidationClass = (propertyName: string): Record<string, boolean> => {
const validationErrorTypes: ValidationErrorType[] | undefined =
this.validationError?.properties[`${propertyName}`]
return validationErrorTypes ? (validationErrorTypes.length ? { 'is-invalid': true } : { 'is-valid': true }) : {}

View File

@ -30,7 +30,7 @@ export class ChannelEmojisElement extends LivechatElement {
public validationError?: ValidationError
@state()
public actionDisabled: boolean = false
public actionDisabled = false
private _asyncTaskRender: Task
@ -192,7 +192,7 @@ export class ChannelEmojisElement extends LivechatElement {
throw new Error('Invalid data')
}
const url = await this._convertImageToDataUrl(entry.url)
const url = await this._convertImageToDataUrl(entry.url as string)
const sn = entry.sn as string
const item: ChannelEmojisConfiguration['emojis']['customEmojis'][0] = {
@ -211,7 +211,7 @@ export class ChannelEmojisElement extends LivechatElement {
await this.ptTranslate(LOC_ACTION_IMPORT_EMOJIS_INFO)
)
} catch (err: any) {
this.ptNotifier.error(err.toString(), await this.ptTranslate(LOC_ERROR))
this.ptNotifier.error((err as Error).toString(), await this.ptTranslate(LOC_ERROR))
} finally {
this.actionDisabled = false
}
@ -250,12 +250,27 @@ export class ChannelEmojisElement extends LivechatElement {
a.remove()
} catch (err: any) {
this.logger.error(err)
this.ptNotifier.error(err.toString())
this.ptNotifier.error((err as Error).toString())
} finally {
this.actionDisabled = false
}
}
public async enableEmojisOnlyModeOnAllRooms (ev: Event): Promise<void> {
ev.preventDefault()
if (!this._channelDetailsService || !this.channelId) {
this.ptNotifier.error(await this.ptTranslate(LOC_ERROR))
return
}
try {
await this._channelDetailsService.enableEmojisOnlyModeOnAllRooms(this.channelId)
this.ptNotifier.info(await this.ptTranslate(LOC_SUCCESSFULLY_SAVED))
} catch (err) {
console.error(err)
this.ptNotifier.error(await this.ptTranslate(LOC_ERROR))
}
}
/**
* Takes an url (or dataUrl), download the image, and converts to dataUrl.
* @param url the url

View File

@ -2,6 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { html } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { ptTr } from '../../lib/directives/translation'
@ -50,7 +53,7 @@ export class ChannelHomeElement extends LivechatElement {
<ul class="peertube-plugin-livechat-configuration-home-channels">
${this._channels?.map((channel) => html`
<li>
<a href="${channel.livechatConfigurationUri}">
<a href="${channel.livechatConfigurationUri}" aria-hidden="true">
${channel.avatar
? html`<img class="avatar channel" src="${channel.avatar.path}">`
: html`<div class="avatar channel initial gray"></div>`

View File

@ -2,6 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { LivechatElement } from '../../lib/elements/livechat'
import { ptTr } from '../../lib/directives/translation'
import { html, TemplateResult } from 'lit'

View File

@ -2,14 +2,18 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import type { ChannelConfigurationElement } from '../channel-configuration'
import type { DynamicFormHeader, DynamicFormSchema } from '../../../lib/elements/dynamic-table-form'
import { ptTr } from '../../../lib/directives/translation'
import { html, TemplateResult } from 'lit'
import { classMap } from 'lit/directives/class-map.js'
import { noDuplicateMaxDelay, forbidSpecialCharsMaxTolerance } from 'shared/lib/constants'
export function tplChannelConfiguration (el: ChannelConfigurationElement): TemplateResult {
const tableHeaderList: {[key: string]: DynamicFormHeader} = {
const tableHeaderList: Record<string, DynamicFormHeader> = {
forbiddenWords: {
entries: {
colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL),
@ -20,16 +24,16 @@ export function tplChannelConfiguration (el: ChannelConfigurationElement): Templ
description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REGEXP_DESC)
},
applyToModerators: {
colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_LABEL),
description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_APPLYTOMODERATORS_DESC)
colName: ptTr(LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_LABEL),
description: ptTr(LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_DESC)
},
label: {
colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_LABEL),
description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL_DESC)
},
reason: {
colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_LABEL),
description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_REASON_DESC)
colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_LABEL),
description: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_DESC)
},
comments: {
colName: ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_COMMENTS_LABEL),
@ -57,7 +61,7 @@ export function tplChannelConfiguration (el: ChannelConfigurationElement): Templ
}
}
}
const tableSchema: {[key: string]: DynamicFormSchema} = {
const tableSchema: Record<string, DynamicFormSchema> = {
forbiddenWords: {
entries: {
inputType: 'tags',
@ -135,6 +139,7 @@ export function tplChannelConfiguration (el: ChannelConfigurationElement): Templ
</livechat-configuration-section-header>
<div class="form-group">
<textarea
.title=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_TERMS_LABEL) as any}
name="terms"
id="peertube-livechat-terms"
.value=${el.channelConfiguration?.configuration.terms ?? ''}
@ -167,7 +172,7 @@ export function tplChannelConfiguration (el: ChannelConfigurationElement): Templ
<label>
<input
type="checkbox"
name="bot"
name="mute_anonymous"
id="peertube-livechat-mute-anonymous"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
@ -254,6 +259,32 @@ export function tplChannelConfiguration (el: ChannelConfigurationElement): Templ
${el.renderFeedback('peertube-livechat-moderation-delay-feedback', 'moderation.delay')}
</div>
<livechat-configuration-section-header
.label=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_ANONYMIZE_MODERATION_LABEL)}
.description=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_ANONYMIZE_MODERATION_DESC, true)}
.helpPage=${'documentation/user/streamers/moderation'}>
</livechat-configuration-section-header>
<div class="form-group">
<label>
<input
type="checkbox"
name="anonymize-moderation"
id="peertube-livechat-anonymize-moderation"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.moderation.anonymize =
(event.target as HTMLInputElement).checked
}
el.requestUpdate('channelConfiguration')
}
}
value="1"
?checked=${el.channelConfiguration?.configuration.moderation.anonymize}
/>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_ANONYMIZE_MODERATION_LABEL)}
</label>
</div>
<livechat-configuration-section-header
.label=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_BOT_OPTIONS_TITLE)}
.description=${''}
@ -310,6 +341,246 @@ export function tplChannelConfiguration (el: ChannelConfigurationElement): Templ
${el.renderFeedback('peertube-livechat-bot-nickname-feedback', 'bot.nickname')}
</div>
<livechat-configuration-section-header
.label=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_LABEL)}
.description=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_DESC)}
.helpPage=${'documentation/user/streamers/bot/special_chars'}>
</livechat-configuration-section-header>
<div class="form-group">
<label>
<input
type="checkbox"
name="forbid_special_chars"
id="peertube-livechat-forbid-special-chars"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.forbidSpecialChars.enabled =
(event.target as HTMLInputElement).checked
}
el.requestUpdate('channelConfiguration')
}
}
value="1"
?checked=${el.channelConfiguration?.configuration.bot.forbidSpecialChars.enabled}
/>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_LABEL)}
</label>
</div>
${!el.channelConfiguration?.configuration.bot.forbidSpecialChars.enabled
? ''
: html`
<div class="form-group">
<label>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_TOLERANCE_LABEL)}
<input
type="number"
name="special_chars_tolerance"
class=${classMap(
Object.assign(
{ 'form-control': true },
el.getInputValidationClass('bot.forbidSpecialChars.tolerance')
)
)}
min="0"
max="${forbidSpecialCharsMaxTolerance}"
id="peertube-livechat-forbid-special-chars-tolerance"
aria-describedby="peertube-livechat-forbid-special-chars-tolerance-feedback"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.forbidSpecialChars.tolerance =
Number((event.target as HTMLInputElement).value)
}
el.requestUpdate('channelConfiguration')
}
}
value="${el.channelConfiguration?.configuration.bot.forbidSpecialChars.tolerance ?? '0'}"
/>
</label>
<small class="form-text text-muted">
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_SPECIAL_CHARS_TOLERANCE_DESC)}
</small>
${el.renderFeedback('peertube-livechat-forbid-special-chars-tolerance-feedback',
'bot.forbidSpecialChars.tolerance')
}
</div>
<div class="form-group">
<label>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_LABEL)}
<input
type="text"
name="special_chars_reason"
class=${classMap(
Object.assign(
{ 'form-control': true },
el.getInputValidationClass('bot.forbidSpecialChars.reason')
)
)}
id="peertube-livechat-forbid-special-chars-reason"
aria-describedby="peertube-livechat-forbid-special-chars-reason-feedback"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.forbidSpecialChars.reason =
(event.target as HTMLInputElement).value
}
el.requestUpdate('channelConfiguration')
}
}
value="${el.channelConfiguration?.configuration.bot.forbidSpecialChars.reason ?? ''}"
/>
</label>
<small class="form-text text-muted">
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_DESC)}
</small>
${el.renderFeedback('peertube-livechat-forbid-special-chars-reason-feedback',
'bot.forbidSpecialChars.reason')
}
</div>
<div class="form-group">
<label>
<input
type="checkbox"
name="forbid_special_chars_applyToModerators"
id="peertube-livechat-forbid-special-chars-applyToModerators"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.forbidSpecialChars.applyToModerators =
(event.target as HTMLInputElement).checked
}
el.requestUpdate('channelConfiguration')
}
}
value="1"
?checked=${el.channelConfiguration?.configuration.bot.forbidSpecialChars.applyToModerators}
/>
${ptTr(LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_LABEL)}
</label>
<small class="form-text text-muted">
${ptTr(LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_DESC)}
</small>
</div>
`
}
<livechat-configuration-section-header
.label=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_LABEL)}
.description=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_DESC)}
.helpPage=${'documentation/user/streamers/bot/no_duplicate'}>
</livechat-configuration-section-header>
<div class="form-group">
<label>
<input
type="checkbox"
name="no_duplicate"
id="peertube-livechat-no-duplicate"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.noDuplicate.enabled =
(event.target as HTMLInputElement).checked
}
el.requestUpdate('channelConfiguration')
}
}
value="1"
?checked=${el.channelConfiguration?.configuration.bot.noDuplicate.enabled}
/>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_LABEL)}
</label>
</div>
${!el.channelConfiguration?.configuration.bot.noDuplicate.enabled
? ''
: html`
<div class="form-group">
<label>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_DELAY_LABEL)}
<input
type="number"
name="no_duplicate_delay"
class=${classMap(
Object.assign(
{ 'form-control': true },
el.getInputValidationClass('bot.noDuplicate.delay')
)
)}
min="0"
max="${noDuplicateMaxDelay.toString()}"
id="peertube-livechat-no-duplicate-delay"
aria-describedby="peertube-livechat-no-duplicate-delay-feedback"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.noDuplicate.delay =
Number((event.target as HTMLInputElement).value)
}
el.requestUpdate('channelConfiguration')
}
}
value="${el.channelConfiguration?.configuration.bot.noDuplicate.delay ?? '0'}"
/>
</label>
<small class="form-text text-muted">
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_NO_DUPLICATE_DELAY_DESC)}
</small>
${el.renderFeedback('peertube-livechat-no-duplicate-delay-feedback',
'bot.noDuplicate.delay')
}
</div>
<div class="form-group">
<label>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_LABEL)}
<input
type="text"
name="no_duplicate_reason"
class=${classMap(
Object.assign(
{ 'form-control': true },
el.getInputValidationClass('bot.noDuplicate.reason')
)
)}
id="peertube-livechat-no-duplicate-reason"
aria-describedby="peertube-livechat-no-duplicate-reason-feedback"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.noDuplicate.reason =
(event.target as HTMLInputElement).value
}
el.requestUpdate('channelConfiguration')
}
}
value="${el.channelConfiguration?.configuration.bot.noDuplicate.reason ?? ''}"
/>
</label>
<small class="form-text text-muted">
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_RETRACTATION_REASON_DESC)}
</small>
${el.renderFeedback('peertube-livechat-no-duplicate-reason-feedback',
'bot.noDuplicate.reason')
}
</div>
<div class="form-group">
<label>
<input
type="checkbox"
name="no_duplicate_applyToModerators"
id="peertube-livechat-no-duplicate-applyToModerators"
@input=${(event: InputEvent) => {
if (event?.target && el.channelConfiguration) {
el.channelConfiguration.configuration.bot.noDuplicate.applyToModerators =
(event.target as HTMLInputElement).checked
}
el.requestUpdate('channelConfiguration')
}
}
value="1"
?checked=${el.channelConfiguration?.configuration.bot.noDuplicate.applyToModerators}
/>
${ptTr(LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_LABEL)}
</label>
<small class="form-text text-muted">
${ptTr(LOC_LIVECHAT_CONFIGURATION_APPLYTOMODERATORS_DESC)}
</small>
</div>
`
}
<livechat-configuration-section-header
.label=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_LABEL)}
.description=${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_FORBIDDEN_WORDS_DESC)}

View File

@ -2,6 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import type { ChannelEmojisElement } from '../channel-emojis'
import type { DynamicFormHeader, DynamicFormSchema } from '../../../lib/elements/dynamic-table-form'
import { maxEmojisPerChannel } from 'shared/lib/emojis'
@ -45,13 +48,14 @@ export function tplChannelEmojis (el: ChannelEmojisElement): TemplateResult {
<livechat-channel-tabs .active=${'emojis'} .channelId=${el.channelId}></livechat-channel-tabs>
<h2>${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_EMOJIS_TITLE)}</h2>
<p>
${ptTr(LOC_LIVECHAT_CONFIGURATION_CHANNEL_EMOJIS_DESC)}
<livechat-help-button .page=${'documentation/user/streamers/emojis'}>
</livechat-help-button>
</p>
<form role="form" @submit=${el.saveEmojis} @change=${el.resetValidation}>
<div class="peertube-plugin-livechat-configuration-actions">
${
@ -86,7 +90,7 @@ export function tplChannelEmojis (el: ChannelEmojisElement): TemplateResult {
.maxLines=${maxEmojisPerChannel}
.validation=${el.validationError?.properties}
.validationPrefix=${'emojis'}
.rows=${el.channelEmojisConfiguration?.emojis.customEmojis}
.rows=${el.channelEmojisConfiguration?.emojis.customEmojis ?? []}
@update=${(e: CustomEvent) => {
el.resetValidation(e)
if (el.channelEmojisConfiguration) {
@ -106,5 +110,23 @@ export function tplChannelEmojis (el: ChannelEmojisElement): TemplateResult {
</button>
</div>
</form>
<h2>${ptTr(LOC_EMOJI_ONLY_MODE_TITLE)}</h2>
<p>
${ptTr(LOC_EMOJI_ONLY_MODE_DESC_1, true)}
</p>
<p>
${ptTr(LOC_EMOJI_ONLY_MODE_DESC_2, true)}
</p>
<p>
${ptTr(LOC_EMOJI_ONLY_MODE_DESC_3, true)}
</p>
<div class="peertube-plugin-livechat-configuration-actions">
<button type="button" @click=${el.enableEmojisOnlyModeOnAllRooms}>
${ptTr(LOC_EMOJI_ONLY_ENABLE_ALL_ROOMS)}
</button>
</div>
</div>`
}

View File

@ -11,7 +11,7 @@ import type {
import { ValidationError, ValidationErrorType } from '../../lib/models/validation'
import { getBaseRoute } from '../../../utils/uri'
import { maxEmojisPerChannel } from 'shared/lib/emojis'
import { channelTermsMaxLength } from 'shared/lib/constants'
import { channelTermsMaxLength, noDuplicateMaxDelay, forbidSpecialCharsMaxTolerance } from 'shared/lib/constants'
export class ChannelDetailsService {
public _registerClientOptions: RegisterClientOptions
@ -67,11 +67,43 @@ export class ChannelDetailsService {
// The backend will ignore those values.
if (botConf.enabled) {
propertiesError['bot.nickname'] = []
propertiesError['bot.forbidSpecialChars.tolerance'] = []
propertiesError['bot.noDuplicate.delay'] = []
if (/[^\p{L}\p{N}\p{Z}_-]/u.test(botConf.nickname ?? '')) {
propertiesError['bot.nickname'].push(ValidationErrorType.WrongFormat)
}
if (botConf.forbidSpecialChars.enabled) {
const forbidSpecialCharsTolerance = channelConfigurationOptions.bot.forbidSpecialChars.tolerance
if (
(typeof forbidSpecialCharsTolerance !== 'number') ||
isNaN(forbidSpecialCharsTolerance)
) {
propertiesError['bot.forbidSpecialChars.tolerance'].push(ValidationErrorType.WrongType)
} else if (
forbidSpecialCharsTolerance < 0 ||
forbidSpecialCharsTolerance > forbidSpecialCharsMaxTolerance
) {
propertiesError['bot.forbidSpecialChars.tolerance'].push(ValidationErrorType.NotInRange)
}
}
if (botConf.noDuplicate.enabled) {
const noDuplicateDelay = channelConfigurationOptions.bot.noDuplicate.delay
if (
(typeof noDuplicateDelay !== 'number') ||
isNaN(noDuplicateDelay)
) {
propertiesError['bot.noDuplicate.delay'].push(ValidationErrorType.WrongType)
} else if (
noDuplicateDelay < 0 ||
noDuplicateDelay > noDuplicateMaxDelay
) {
propertiesError['bot.noDuplicate.delay'].push(ValidationErrorType.NotInRange)
}
}
for (const [i, fw] of botConf.forbiddenWords.entries()) {
for (const v of fw.entries) {
propertiesError[`bot.forbiddenWords.${i}.entries`] = []
@ -146,7 +178,8 @@ export class ChannelDetailsService {
}
for (const channel of channels.data) {
channel.livechatConfigurationUri = '/p/livechat/configuration/channel?channelId=' + encodeURIComponent(channel.id)
channel.livechatConfigurationUri =
'/p/livechat/configuration/channel?channelId=' + encodeURIComponent(channel.id as string | number)
// Note: since Peertube v6.0.0, channel.avatar is dropped, and we have to use channel.avatars.
// So, if !channel.avatar, we will search a suitable one in channel.avatars, and fill channel.avatar.
@ -180,10 +213,11 @@ export class ChannelDetailsService {
}
public async fetchEmojisConfiguration (channelId: number): Promise<ChannelEmojisConfiguration> {
const url = getBaseRoute(this._registerClientOptions) +
'/api/configuration/channel/emojis/' +
encodeURIComponent(channelId)
const response = await fetch(
getBaseRoute(this._registerClientOptions) +
'/api/configuration/channel/emojis/' +
encodeURIComponent(channelId),
url,
{
method: 'GET',
headers: this._headers
@ -295,10 +329,11 @@ export class ChannelDetailsService {
channelId: number,
channelEmojis: ChannelEmojis
): Promise<ChannelEmojisConfiguration> {
const url = getBaseRoute(this._registerClientOptions) +
'/api/configuration/channel/emojis/' +
encodeURIComponent(channelId)
const response = await fetch(
getBaseRoute(this._registerClientOptions) +
'/api/configuration/channel/emojis/' +
encodeURIComponent(channelId),
url,
{
method: 'POST',
headers: this._headers,
@ -312,4 +347,24 @@ export class ChannelDetailsService {
return response.json()
}
public async enableEmojisOnlyModeOnAllRooms (channelId: number): Promise<void> {
const url = getBaseRoute(this._registerClientOptions) +
'/api/configuration/channel/emojis/' +
encodeURIComponent(channelId) +
'/enable_emoji_only'
const response = await fetch(
url,
{
method: 'POST',
headers: this._headers
}
)
if (!response.ok) {
throw new Error('Can\'t enable Emojis Only Mode on all rooms.')
}
return response.json()
}
}

View File

@ -4,8 +4,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
// This content comes from the file assets/images/plus-square.svg, from the Feather icons set https://feathericons.com/
export const AddSVG: string =
export const AddSVG =
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
aria-hidden="true"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="feather feather-plus-square">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
@ -13,8 +14,9 @@ export const AddSVG: string =
</svg>`
// This content comes from the file assets/images/x-square.svg, from the Feather icons set https://feathericons.com/
export const RemoveSVG: string =
export const RemoveSVG =
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
aria-hidden="true"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="feather feather-x-square">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>

View File

@ -13,8 +13,8 @@ import { getPtContext } from '../contexts/peertube'
export class TranslationDirective extends AsyncDirective {
private readonly _peertubeHelpers: RegisterClientHelpers
private _translatedValue: string = ''
private _localizationId: string = ''
private _translatedValue = ''
private _localizationId = ''
private _allowUnsafeHTML = false
@ -25,7 +25,7 @@ export class TranslationDirective extends AsyncDirective {
this._asyncUpdateTranslation().then(() => {}, () => {})
}
public override render = (locId: string, allowHTML: boolean = false): TemplateResult | string => {
public override render = (locId: string, allowHTML = false): TemplateResult | string => {
this._localizationId = locId // TODO Check current component for context (to infer the prefix)
this._allowUnsafeHTML = allowHTML

View File

@ -3,6 +3,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { ptTr } from '../directives/translation'
import { html } from 'lit'
import { customElement, property } from 'lit/decorators.js'

View File

@ -3,6 +3,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import type { TagsInputElement } from './tags-input'
import type { DirectiveResult } from 'lit/directive'
import { ValidationErrorType } from '../models/validation'
@ -20,26 +23,26 @@ import { AddSVG, RemoveSVG } from '../buttons'
type DynamicTableAcceptedTypes = number | string | boolean | Date | Array<number | string>
type DynamicTableAcceptedInputTypes = 'textarea'
| 'select'
| 'checkbox'
| 'range'
| 'color'
| 'date'
| 'datetime'
| 'datetime-local'
| 'email'
| 'file'
| 'image'
| 'month'
| 'number'
| 'password'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week'
| 'tags'
| 'image-file'
| 'select'
| 'checkbox'
| 'range'
| 'color'
| 'date'
| 'datetime'
| 'datetime-local'
| 'email'
| 'file'
| 'image'
| 'month'
| 'number'
| 'password'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week'
| 'tags'
| 'image-file'
interface CellDataSchema {
min?: number
@ -47,11 +50,11 @@ interface CellDataSchema {
minlength?: number
maxlength?: number
size?: number
label?: TemplateResult | string
options?: { [key: string]: string }
options?: Record<string, string>
datalist?: DynamicTableAcceptedTypes[]
separator?: string
inputType?: DynamicTableAcceptedInputTypes
inputTitle?: string
default?: DynamicTableAcceptedTypes
colClassList?: string[] // CSS classes to add to the <td> element.
}
@ -59,19 +62,17 @@ interface CellDataSchema {
interface DynamicTableRowData {
_id: number
_originalIndex: number
row: { [key: string]: DynamicTableAcceptedTypes }
row: Record<string, DynamicTableAcceptedTypes>
}
interface DynamicFormHeaderCellData {
colName: TemplateResult | DirectiveResult
description: TemplateResult | DirectiveResult
description?: TemplateResult | DirectiveResult
headerClassList?: string[]
}
export interface DynamicFormHeader {
[key: string]: DynamicFormHeaderCellData
}
export interface DynamicFormSchema { [key: string]: CellDataSchema }
export type DynamicFormHeader = Record<string, DynamicFormHeaderCellData>
export type DynamicFormSchema = Record<string, CellDataSchema>
@customElement('livechat-dynamic-table-form')
export class DynamicTableFormElement extends LivechatElement {
@ -85,19 +86,19 @@ export class DynamicTableFormElement extends LivechatElement {
public maxLines?: number = undefined
@property()
public validation?: {[key: string]: ValidationErrorType[] }
public validation?: Record<string, ValidationErrorType[]>
@property({ attribute: false })
public validationPrefix: string = ''
public validationPrefix = ''
@property({ attribute: false })
public rows: Array<{ [key: string]: DynamicTableAcceptedTypes }> = []
public rows: Array<Record<string, DynamicTableAcceptedTypes>> = []
@state()
public _rowsById: DynamicTableRowData[] = []
@property({ attribute: false })
public formName: string = ''
public formName = ''
@state()
private _lastRowId = 1
@ -112,7 +113,7 @@ export class DynamicTableFormElement extends LivechatElement {
}
}
private readonly _getDefaultRow = (): { [key: string]: DynamicTableAcceptedTypes } => {
private readonly _getDefaultRow = (): Record<string, DynamicTableAcceptedTypes> => {
this._updateLastRowId()
return Object.fromEntries([...Object.entries(this.schema).map((entry) => [entry[0], entry[1].default ?? ''])])
}
@ -236,7 +237,7 @@ export class DynamicTableFormElement extends LivechatElement {
classList.push(...headerCellData.headerClassList)
}
return html`<th scope="col" class=${classList.join(' ')}>
${headerCellData.description}
${headerCellData.description ?? ''}
</th>`
}
@ -245,11 +246,11 @@ export class DynamicTableFormElement extends LivechatElement {
return html`<tr id=${inputId}>
${Object.keys(this.header)
.sort((k1, k2) => this.columnOrder.indexOf(k1) - this.columnOrder.indexOf(k2))
.map(key => this.renderDataCell(key,
rowData.row[key] ?? this.schema[key].default,
rowData._id,
rowData._originalIndex))}
.sort((k1, k2) => this.columnOrder.indexOf(k1) - this.columnOrder.indexOf(k2))
.map(key => this.renderDataCell(key,
rowData.row[key] ?? this.schema[key].default,
rowData._id,
rowData._originalIndex))}
<td class="form-group">
<button type="button"
class="dynamic-table-remove-row"
@ -295,6 +296,7 @@ export class DynamicTableFormElement extends LivechatElement {
const inputId =
`peertube-livechat-${this.formName.replace(/_/g, '-')}-${propertyName.toString().replace(/_/g, '-')}-${rowId}`
const inputTitle: DirectiveResult | undefined = propertySchema.inputTitle ?? this.header[propertyName]?.colName
const feedback = this._renderFeedback(inputId, propertyName, originalIndex)
switch (propertySchema.default?.constructor) {
@ -320,6 +322,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -332,6 +335,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderTextarea(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -344,6 +348,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderSelect(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -356,6 +361,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderImageFileInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue?.toString(),
@ -376,6 +382,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
(propertyValue as Date).toISOString(),
@ -394,6 +401,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -411,6 +419,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderCheckbox(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as boolean,
@ -446,10 +455,10 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
(propertyValue)?.join(propertySchema.separator ?? ',') ??
propertyValue ?? propertySchema.default ?? '',
(propertyValue)?.join(propertySchema.separator ?? ',') ?? propertyValue ?? propertySchema.default ?? '',
originalIndex)}
${feedback}
`
@ -461,10 +470,10 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderTextarea(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
(propertyValue)?.join(propertySchema.separator ?? ',') ??
propertyValue ?? propertySchema.default ?? '',
(propertyValue)?.join(propertySchema.separator ?? ',') ?? propertyValue ?? propertySchema.default ?? '',
originalIndex)}
${feedback}
`
@ -476,6 +485,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderTagsInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue,
@ -487,8 +497,10 @@ export class DynamicTableFormElement extends LivechatElement {
}
if (!formElement) {
this.logger.warn(`value type '${(propertyValue.constructor.toString())}' is incompatible` +
`with field type '${propertySchema.inputType as string}' for form entry '${propertyName.toString()}'.`)
this.logger.warn(
`value type '${(propertyValue.constructor.toString())}' is incompatible` +
`with field type '${propertySchema.inputType as string}' for form entry '${propertyName.toString()}'.`
)
}
const classList = ['form-group']
@ -501,6 +513,7 @@ export class DynamicTableFormElement extends LivechatElement {
_renderInput = (rowId: number,
inputId: string,
inputName: string,
inputTitle: string | DirectiveResult | undefined,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
@ -515,6 +528,7 @@ export class DynamicTableFormElement extends LivechatElement {
)
)}
id=${inputId}
title=${ifDefined(inputTitle)}
aria-describedby="${inputId}-feedback"
list=${ifDefined(propertySchema.datalist ? inputId + '-datalist' : undefined)}
min=${ifDefined(propertySchema.min)}
@ -534,6 +548,7 @@ export class DynamicTableFormElement extends LivechatElement {
_renderTagsInput = (rowId: number,
inputId: string,
inputName: string,
inputTitle: string | DirectiveResult | undefined,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: Array<string | number>,
@ -547,7 +562,7 @@ export class DynamicTableFormElement extends LivechatElement {
)
)}
id=${inputId}
.inputPlaceholder=${propertySchema.label as any}
.inputTitle=${inputTitle as any}
aria-describedby="${inputId}-feedback"
.min=${propertySchema.min}
.max=${propertySchema.max}
@ -563,6 +578,7 @@ export class DynamicTableFormElement extends LivechatElement {
_renderTextarea = (rowId: number,
inputId: string,
inputName: string,
inputTitle: string | DirectiveResult | undefined,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
@ -576,6 +592,7 @@ export class DynamicTableFormElement extends LivechatElement {
)
)}
id=${inputId}
title=${ifDefined(inputTitle)}
aria-describedby="${inputId}-feedback"
min=${ifDefined(propertySchema.min)}
max=${ifDefined(propertySchema.max)}
@ -588,6 +605,7 @@ export class DynamicTableFormElement extends LivechatElement {
_renderCheckbox = (rowId: number,
inputId: string,
inputName: string,
inputTitle: string | DirectiveResult | undefined,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: boolean,
@ -602,6 +620,7 @@ export class DynamicTableFormElement extends LivechatElement {
)
)}
id=${inputId}
title=${ifDefined(inputTitle)}
aria-describedby="${inputId}-feedback"
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
value="1"
@ -611,6 +630,7 @@ export class DynamicTableFormElement extends LivechatElement {
_renderSelect = (rowId: number,
inputId: string,
inputName: string,
inputTitle: string | DirectiveResult | undefined,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
@ -623,11 +643,12 @@ export class DynamicTableFormElement extends LivechatElement {
)
)}
id=${inputId}
title=${ifDefined(inputTitle)}
aria-describedby="${inputId}-feedback"
aria-label=${inputName}
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
>
<option ?selected=${!propertyValue}>${propertySchema.label ?? 'Choose your option'}</option>
<option ?selected=${!propertyValue}>${inputTitle ?? ''}</option>
${Object.entries(propertySchema.options ?? {})
?.map(([value, name]) =>
html`<option ?selected=${propertyValue === value} value=${value}>${name}</option>`
@ -638,6 +659,7 @@ export class DynamicTableFormElement extends LivechatElement {
_renderImageFileInput = (rowId: number,
inputId: string,
inputName: string,
inputTitle: string | DirectiveResult | undefined,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
@ -647,6 +669,7 @@ export class DynamicTableFormElement extends LivechatElement {
.name=${inputName}
class=${classMap(this._getInputValidationClass(propertyName, originalIndex))}
id=${inputId}
.inputTitle=${inputTitle as any}
aria-describedby="${inputId}-feedback"
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
.value=${propertyValue}
@ -656,7 +679,7 @@ export class DynamicTableFormElement extends LivechatElement {
}
_getInputValidationClass = (propertyName: string,
originalIndex: number): { [key: string]: boolean } => {
originalIndex: number): Record<string, boolean> => {
const validationErrorTypes: ValidationErrorType[] | undefined =
this.validation?.[`${this.validationPrefix}.${originalIndex}.${propertyName}`]

View File

@ -18,7 +18,7 @@ export class HelpButtonElement extends LivechatElement {
public buttonTitle: string | DirectiveResult = ptTr(LOC_ONLINE_HELP)
@property({ attribute: false })
public page: string = ''
public page = ''
@state()
public url: URL = new URL('https://lmddgtfy.net/')

View File

@ -2,10 +2,14 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { LivechatElement } from './livechat'
import { html } from 'lit'
import type { DirectiveResult } from 'lit/directive'
import { customElement, property } from 'lit/decorators.js'
import { ifDefined } from 'lit/directives/if-defined.js'
/**
* Special element to upload image files.
* If no current value, displays an input type="file" field.
@ -29,13 +33,16 @@ export class ImageFileInputElement extends LivechatElement {
@property({ attribute: false })
public maxSize?: number
@property({ attribute: false })
public inputTitle?: string | DirectiveResult
@property({ attribute: false })
public accept: string[] = ['image/jpg', 'image/png', 'image/gif']
protected override render = (): unknown => {
return html`
${this.value
? html`<img src=${this.value} @click=${(ev: Event) => {
? html`<img src=${this.value} alt=${ifDefined(this.inputTitle)} @click=${(ev: Event) => {
ev.preventDefault()
const upload: HTMLInputElement | null | undefined = this.parentElement?.querySelector('input[type="file"]')
upload?.click()
@ -44,6 +51,7 @@ export class ImageFileInputElement extends LivechatElement {
}
<input
type="file"
title=${ifDefined(this.inputTitle)}
accept="${this.accept.join(',')}"
class="form-control"
style=${this.value ? 'display: none;' : ''}

View File

@ -26,7 +26,7 @@ export class LivechatElement extends LitElement {
this.logger = this.ptContext.logger.createLogger(this.tagName.toLowerCase())
}
protected override createRenderRoot = (): Element | ShadowRoot => {
protected override createRenderRoot = (): HTMLElement | DocumentFragment => {
return this
}
}

View File

@ -3,6 +3,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { LivechatElement } from './livechat'
import { ptTr } from '../directives/translation'
import { html } from 'lit'
@ -12,6 +15,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'
import { classMap } from 'lit/directives/class-map.js'
import { animate, fadeOut, fadeIn } from '@lit-labs/motion'
import { repeat } from 'lit/directives/repeat.js'
import type { DirectiveResult } from 'lit/directive'
// FIXME: find a better way to store this image.
// This content comes from the file assets/images/copy.svg, after svgo cleaning.
@ -20,10 +24,11 @@ import { repeat } from 'lit/directives/repeat.js'
// Then replace the main color by «currentColor»
const copySVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 4.233 4.233">
<g style="stroke-width:1.00021;stroke-miterlimit:4;stroke-dasharray:none">` +
// eslint-disable-next-line max-len
// eslint-disable-next-line max-len, @stylistic/indent-binary-ops
'<path style="opacity:.998;fill:none;fill-opacity:1;stroke:currentColor;stroke-width:1.17052;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m4.084 4.046-.616.015-.645-.004a.942.942 0 0 1-.942-.942v-4.398a.94.94 0 0 1 .942-.943H7.22a.94.94 0 0 1 .942.943l-.006.334-.08.962" transform="matrix(.45208 0 0 .45208 -.528 1.295)"/>' +
// eslint-disable-next-line max-len
'<path style="opacity:.998;fill:none;fill-opacity:1;stroke:currentColor;stroke-width:1.17052;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="M8.434 5.85c-.422.009-1.338.009-1.76.01-.733.004-2.199 0-2.199 0a.94.94 0 0 1-.942-.941V.52a.94.94 0 0 1 .942-.942h4.398a.94.94 0 0 1 .943.942s.004 1.466 0 2.2c-.003.418-.019 1.251-.006 1.67.024.812-.382 1.439-1.376 1.46z" transform="matrix(.45208 0 0 .45208 -.528 1.295)"/>' +
// eslint-disable-next-line @stylistic/indent-binary-ops
`</g>
</svg>`
@ -48,7 +53,7 @@ export class TagsInputElement extends LivechatElement {
private _inputValue?: string = ''
@property({ attribute: false })
public inputPlaceholder?: string = ''
public inputTitle?: string | DirectiveResult = ''
@property({ attribute: false })
public datalist?: string[]
@ -63,10 +68,10 @@ export class TagsInputElement extends LivechatElement {
private readonly _isPressingKey: string[] = []
@property({ attribute: false })
public separator: string = '\n'
public separator = '\n'
@property({ attribute: false })
public animDuration: number = 200
public animDuration = 200
/**
* Overloading the standard focus method.
@ -166,7 +171,7 @@ export class TagsInputElement extends LivechatElement {
@input=${(e: InputEvent) => this._handleInputEvent(e)}
@change=${(e: Event) => e.stopPropagation()}
.value=${this._inputValue ?? ''}
placeholder=${ifDefined(this.inputPlaceholder)} />
title=${ifDefined(this.inputTitle)} />
${(this.datalist)
? html`<datalist id="${this.id ?? 'tags-input'}-datalist">
${(this.datalist ?? []).map((value) => html`<option value=${value}>`)}
@ -244,8 +249,9 @@ export class TagsInputElement extends LivechatElement {
if (!this._isPressingKey.includes(e.key)) {
this._isPressingKey.push(e.key)
if ((target.selectionStart === target.selectionEnd) &&
target.selectionStart === 0) {
if (
(target.selectionStart === target.selectionEnd) && target.selectionStart === 0
) {
this._handleDeleteTag((this._searchedTagsIndex.length)
? this._searchedTagsIndex.slice(-1)[0]
: (this.value.length - 1))
@ -258,8 +264,9 @@ export class TagsInputElement extends LivechatElement {
if (!this._isPressingKey.includes(e.key)) {
this._isPressingKey.push(e.key)
if ((target.selectionStart === target.selectionEnd) &&
target.selectionStart === target.value.length) {
if (
(target.selectionStart === target.selectionEnd) && target.selectionStart === target.value.length
) {
this._handleDeleteTag((this._searchedTagsIndex.length)
? this._searchedTagsIndex[0]
: 0)

View File

@ -2,6 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import type { LivechatTokenListElement } from '../token-list'
import { html, TemplateResult } from 'lit'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
@ -23,11 +26,11 @@ export function tplTokenList (el: LivechatTokenListElement): TemplateResult {
<tbody>
${
repeat(el.tokenList ?? [], (token) => token.id, (token) => {
let dateStr: string = ''
let dateStr = ''
try {
const date = new Date(token.date)
dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
} catch (err) {}
} catch (_err) {}
return html`<tr>
<td>${
el.mode === 'select'

View File

@ -27,7 +27,7 @@ export class LivechatTokenListElement extends LivechatElement {
public currentSelectedToken?: LivechatToken
@property({ attribute: false })
public actionDisabled: boolean = false
public actionDisabled = false
private readonly _tokenListService: TokenListService
private readonly _asyncTaskRender: Task
@ -83,7 +83,7 @@ export class LivechatTokenListElement extends LivechatElement {
this.dispatchEvent(new CustomEvent('update', {}))
} catch (err: any) {
this.logger.error(err)
this.ptNotifier.error(err.toString(), await this.ptTranslate(LOC_ERROR))
this.ptNotifier.error((err as Error).toString(), await this.ptTranslate(LOC_ERROR))
} finally {
this.actionDisabled = false
}
@ -102,7 +102,7 @@ export class LivechatTokenListElement extends LivechatElement {
this.dispatchEvent(new CustomEvent('update', {}))
} catch (err: any) {
this.logger.error(err)
this.ptNotifier.error(err.toString(), await this.ptTranslate(LOC_ERROR))
this.ptNotifier.error((err as Error).toString(), await this.ptTranslate(LOC_ERROR))
} finally {
this.actionDisabled = false
}

View File

@ -12,7 +12,7 @@ export enum ValidationErrorType {
}
export class ValidationError extends Error {
properties: {[key: string]: ValidationErrorType[] } = {}
properties: Record<string, ValidationErrorType[]> = {}
constructor (name: string, message: string | undefined, properties: ValidationError['properties']) {
super(message)

View File

@ -42,6 +42,23 @@ function displayButton (dbo: displayButtonOptions): void {
if ('href' in dbo) {
button.href = dbo.href
}
if (!button.href || button.href === '#') {
// No href => it is not a link.
button.role = 'button'
button.tabIndex = 0
// We must also ensure that the enter key is triggering the onclick
if (button.onclick) {
button.onkeydown = ev => {
if (ev.key === 'Enter') {
ev.preventDefault()
button.click()
}
}
}
}
if (('targetBlank' in dbo) && dbo.targetBlank) {
button.target = '_blank'
}
@ -52,6 +69,10 @@ function displayButton (dbo: displayButtonOptions): void {
tmp.innerHTML = svg.trim()
const svgDom = tmp.firstChild
if (svgDom) {
if ('ariaHidden' in (svgDom as HTMLElement)) {
// Icon must be hidden for screen readers.
(svgDom as HTMLElement).ariaHidden = 'true'
}
button.prepend(svgDom)
}
} catch (err) {

View File

@ -16,8 +16,6 @@ import { localizedHelpUrl } from '../../utils/help'
import { getBaseRoute } from '../../utils/uri'
import { displayConverseJS } from '../../utils/conversejs'
let savedMyPluginFlexGrow: string | undefined
/**
* Initialize the chat for the current video
* @param video the video
@ -25,7 +23,6 @@ let savedMyPluginFlexGrow: string | undefined
async function initChat (video: Video): Promise<void> {
const ptContext = getPtContext()
const logger = ptContext.logger
savedMyPluginFlexGrow = undefined
if (!video) {
logger.error('No video provided')
@ -46,6 +43,8 @@ async function initChat (video: Video): Promise<void> {
container.setAttribute('id', 'peertube-plugin-livechat-container')
container.setAttribute('peertube-plugin-livechat-state', 'initializing')
container.setAttribute('peertube-plugin-livechat-current-url', window.location.href)
container.role = 'region'
container.ariaLabel = await ptContext.ptOptions.peertubeHelpers.translate(LOC_CHAT)
placeholder.append(container)
try {
@ -61,8 +60,8 @@ async function initChat (video: Video): Promise<void> {
return
}
let showShareUrlButton: boolean = false
let showPromote: boolean = false
let showShareUrlButton = false
let showPromote = false
if (video.isLocal) { // No need for shareButton on remote chats.
const chatShareUrl = settings['chat-share-url'] ?? ''
if (chatShareUrl === 'everyone') {
@ -188,9 +187,10 @@ async function _insertChatDom (
callback: async () => {
try {
// First we must get the room JID (can be video.uuid@ or channel.id@)
const url = getBaseRoute(ptContext.ptOptions) + '/api/configuration/room/' +
encodeURIComponent(video.uuid)
const response = await fetch(
getBaseRoute(ptContext.ptOptions) + '/api/configuration/room/' +
encodeURIComponent(video.uuid),
url,
{
method: 'GET',
headers: peertubeHelpers.getAuthHeader()
@ -304,7 +304,7 @@ async function _openChat (video: Video): Promise<void | false> {
// Loading converseJS...
await displayConverseJS(ptContext.ptOptions, container, roomkey, 'peertube-video', false)
} catch (err) {
} catch (_err) {
// Displaying an error page.
if (container) {
const message = document.createElement('div')
@ -353,19 +353,6 @@ function _hackStyles (on: boolean): void {
buttons.classList.remove('peertube-plugin-livechat-buttons-open')
}
})
const myPluginPlaceholder: HTMLElement | null = document.querySelector('my-plugin-placeholder')
if (on) {
// Saving current style attributes and maximazing space for the chat
if (myPluginPlaceholder) {
savedMyPluginFlexGrow = myPluginPlaceholder.style.flexGrow // Should be "", but can be anything else.
myPluginPlaceholder.style.flexGrow = '1'
}
} else {
// restoring values...
if (savedMyPluginFlexGrow !== undefined && myPluginPlaceholder) {
myPluginPlaceholder.style.flexGrow = savedMyPluginFlexGrow
}
}
} catch (err) {
getPtContext().logger.error(`Failed hacking styles: '${err as string}'`)
}

View File

@ -14,7 +14,7 @@ import { getIframeUri, getXMPPAddr, UriOptions } from '../uri'
import { isAnonymousUser } from '../../../utils/user'
// First is default tab.
const validTabNames = ['embed', 'dock', 'peertube', 'xmpp'] as const
const validTabNames: string[] = ['embed', 'dock', 'peertube', 'xmpp'] as const
type ValidTabNames = typeof validTabNames[number]
@ -61,49 +61,49 @@ export class ShareChatElement extends LivechatElement {
* Should we render the XMPP tab?
*/
@property({ attribute: false })
public xmppUriEnabled: boolean = false
public xmppUriEnabled = false
/**
* Should we render the Dock tab?
*/
@property({ attribute: false })
public dockEnabled: boolean = false
public dockEnabled = false
/**
* Can we use autocolors?
*/
@property({ attribute: false })
public autocolorsAvailable: boolean = false
public autocolorsAvailable = false
/**
* In the Embed tab, should we generated an iframe link.
*/
@property({ attribute: false })
public embedIFrame: boolean = false
public embedIFrame = false
/**
* In the Embed tab, should we generated a read-only chat link.
*/
@property({ attribute: false })
public embedReadOnly: boolean = false
public embedReadOnly = false
/**
* Read-only, with scrollbar?
*/
@property({ attribute: false })
public embedReadOnlyScrollbar: boolean = false
public embedReadOnlyScrollbar = false
/**
* Read-only, transparent background?
*/
@property({ attribute: false })
public embedReadOnlyTransparentBackground: boolean = false
public embedReadOnlyTransparentBackground = false
/**
* In the Embed tab, should we use current theme color?
*/
@property({ attribute: false })
public embedAutocolors: boolean = false
public embedAutocolors = false
protected override firstUpdated (changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties)
@ -156,7 +156,7 @@ export class ShareChatElement extends LivechatElement {
return
}
this.logger.log('Restoring previous state')
if (validTabNames.includes(v.currentTab)) {
if (validTabNames.includes(v.currentTab as string)) {
this.currentTab = v.currentTab
}
this.embedIFrame = !!v.embedIFrame

View File

@ -2,6 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import type { ShareChatElement } from '../share-chat'
import { html, TemplateResult } from 'lit'
import { ptTr } from '../../../lib/directives/translation'

View File

@ -71,8 +71,7 @@ async function shareChatUrl (
addedNodes.forEach(node => {
if ((node as HTMLElement).localName === 'ngb-modal-window') {
logger.info('Detecting a new modal, checking if this is the good one...')
if (!(node as HTMLElement).querySelector) { return }
const title = (node as HTMLElement).querySelector('.modal-title')
const title = (node as HTMLElement).querySelector?.('.modal-title')
if (!(title?.textContent === labelShare)) {
return
}

View File

@ -4,6 +4,7 @@
import type { RegisterClientOptions } from '@peertube/peertube-types/client'
import type { Video } from '@peertube/peertube-types'
import type { LiveChatSettings } from '../lib/contexts/peertube'
import { AutoColors, isAutoColorsAvailable } from 'shared/lib/autocolors'
import { getBaseRoute } from '../../utils/uri'
import { logger } from '../../utils/logger'
@ -17,7 +18,7 @@ interface UriOptions {
}
function getIframeUri (
registerOptions: RegisterClientOptions, settings: any, video: Video, uriOptions: UriOptions = {}
registerOptions: RegisterClientOptions, settings: LiveChatSettings, video: Video, uriOptions: UriOptions = {}
): string | null {
if (!settings) {
logger.error('Settings are not initialized, too soon to compute the iframeUri')

View File

@ -156,12 +156,12 @@ function launchTests (): void {
'content-type': 'application/json;charset=UTF-8'
}),
body: JSON.stringify({
test: test
test
})
})
if (!response.ok) {
return {
test: test,
test,
messages: [response.statusText ?? 'Unknown error'],
ok: false
}
@ -169,7 +169,7 @@ function launchTests (): void {
const data = await response.json()
if ((typeof data) !== 'object') {
return {
test: test,
test,
messages: ['Incorrect reponse type: ' + (typeof data)],
ok: false
}
@ -190,6 +190,7 @@ function launchTests (): void {
waiting.innerHTML = '<i>Testing...</i>'
ul.append(waiting)
if ((typeof result.next) === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const r: Result = (result.next as Function)()
waiting.remove()
await machine(r)

View File

@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import type { RegisterClientOptions } from '@peertube/peertube-types/client'
import type { InitConverseJSParams, ChatPeertubeIncludeMode } from 'shared/lib/types'
@ -17,7 +18,7 @@ declare global {
}
}
let pollListenerInitiliazed: boolean = false
let pollListenerInitiliazed = false
/**
* load the ConverseJS CSS.
@ -152,10 +153,11 @@ async function displayConverseJS (
const authHeader = peertubeHelpers.getAuthHeader()
const url = getBaseRoute(clientOptions) + '/api/configuration/room/' +
encodeURIComponent(roomKey) +
(forceType ? '?forcetype=1' : '')
const response = await fetch(
getBaseRoute(clientOptions) + '/api/configuration/room/' +
encodeURIComponent(roomKey) +
(forceType ? '?forcetype=1' : ''),
url,
{
method: 'GET',
headers: authHeader
@ -167,7 +169,7 @@ async function displayConverseJS (
const converseJSParams: InitConverseJSParams = await (response).json()
if (!pollListenerInitiliazed) {
// First time we got here, initiliaze this event:
// First time we got here, initialize this event:
const i18nVoteOk = await clientOptions.peertubeHelpers.translate(LOC_POLL_VOTE_OK)
pollListenerInitiliazed = true
document.addEventListener('livechat-poll-vote', () => {

View File

@ -4,7 +4,7 @@
import type { RegisterClientOptions } from '@peertube/peertube-types/client'
function getBaseRoute ({ peertubeHelpers }: RegisterClientOptions, permanent: boolean = false): string {
function getBaseRoute ({ peertubeHelpers }: RegisterClientOptions, permanent = false): string {
if (permanent) {
return '/plugins/livechat/router'
}

View File

@ -1,40 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"es6": true
},
"extends": [
"standard-with-typescript"
],
"globals": {},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
"project": [
"./conversejs/tsconfig.json"
]
},
"plugins": [
"@typescript-eslint"
],
"ignorePatterns": [],
"rules": {
"@typescript-eslint/no-unused-vars": [2, {"argsIgnorePattern": "^_"}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/return-await": [2, "in-try-catch"], // FIXME: correct?
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/triple-slash-reference": "off",
"max-len": [
"error",
{
"code": 120,
"comments": 120
}
],
"no-unused-vars": "off"
}
}

View File

@ -1,3 +0,0 @@
SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
SPDX-License-Identifier: AGPL-3.0-only

View File

@ -4,6 +4,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs')
const path = require('node:path')
const YAML = require('yaml')

View File

@ -15,32 +15,22 @@ set -x
# Set CONVERSE_VERSION and CONVERSE_REPO to select which repo and tag/commit/branch use.
# Defaults values:
CONVERSE_VERSION="v10.1.6"
CONVERSE_VERSION="v11.0.0"
CONVERSE_REPO="https://github.com/conversejs/converse.js.git"
# You can eventually set CONVERSE_COMMIT to a specific commit ID, if you want to apply some patches.
CONVERSE_COMMIT=""
# 2024-09-17: using Converse upstream (v11 WIP).
CONVERSE_COMMIT="07dc6f4f5da5890b02a46a8a2f2d0498649786bc"
# 2014-01-16: we are using a custom version, to wait for some PR to be apply upstream.
# This version includes following changes:
# - #converse.js/3300: Adding the maxWait option for `debouncedPruneHistory`
# - #converse.js/3302: debounce MUC sidebar rendering
# - Fix: refresh the MUC sidebar when participants collection is sorted
# - Fix: MUC occupant list does not sort itself on nicknames or roles changes
# - Fix inconsistency between browsers on textarea outlines
# - Fix: room information not correctly refreshed when modifications are made by other users
# This version already includes following changes that will not be merged in ConverseJS upstream:
# - Don't load vCards for all room occupants when the right menu is closed
# - Changing the default avatar, for something very light (to mitigate blinking effect when vCards are loaded)
# - Custom settings livechat_load_all_vcards for the readonly mode
# - Adding "users" icon in the menu toggle button
# - Removing unecessary plugins: headless/pubsub, minimize, notifications, profile, omemo, push, roomlist, dragresize.
# - Destroy room: remove the challenge, and the new JID
# - New config option [colorize_username](https://conversejs.org/docs/html/configuration.html#colorize_username)
# - New loadEmojis hook, to customize emojis at runtime.
# - Fix custom emojis path when assets_path is not the default path.
CONVERSE_VERSION="livechat-10.1.0"
# CONVERSE_COMMIT="4402fcc3fc60f6c9334f86528c33a0b463371d12"
# It is possible to use another repository, if we want some customization that are not upstream (yet):
# CONVERSE_VERSION="livechat"
# # CONVERSE_COMMIT="4402fcc3fc60f6c9334f86528c33a0b463371d12"
# CONVERSE_REPO="https://github.com/JohnXLivingston/converse.js"
# CONVERSE_COMMIT="xxxx"
# 2024-09-03: include badges short label and quick fix for sendMessage button
CONVERSE_REPO="https://github.com/JohnXLivingston/converse.js"
CONVERSE_VERSION="livechat-12.0.0"
# CONVERSE_COMMIT=""
rootdir="$(pwd)"
src_dir="$rootdir/conversejs"
@ -50,6 +40,7 @@ if [ -n "$CONVERSE_COMMIT" ]; then
fi
converse_build_dir="$rootdir/build/conversejs"
converse_destination_dir="$rootdir/dist/client/conversejs"
converse_emoji_destination="$rootdir/dist/converse-emoji.json"
if [[ ! -d $src_dir ]]; then
echo "$0 must be called from the plugin livechat root dir."
@ -129,6 +120,9 @@ cd $rootdir
echo "Copying ConverseJS dist files..."
mkdir -p "$converse_destination_dir" && cp -r $converse_build_dir/dist/* "$converse_destination_dir/"
echo "Copying ConverseJS original emoji.json file..." # this is needed for some backend code.
cp "$converse_build_dir/src/headless/plugins/emoji/emoji.json" "$converse_emoji_destination"
echo "ConverseJS OK."
exit 0

View File

@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import type { InitConverseJSParams, ChatIncludeMode, ExternalAuthResult } from 'shared/lib/types'
import { inIframe } from './lib/utils'
import { initDom } from './lib/dom'
@ -21,6 +23,7 @@ import { livechatViewerModePlugin } from './lib/plugins/livechat-viewer-mode'
import { livechatMiniMucHeadPlugin } from './lib/plugins/livechat-mini-muc-head'
import { livechatEmojisPlugin } from './lib/plugins/livechat-emojis'
import { moderationDelayPlugin } from './lib/plugins/moderation-delay'
import { livechatAnnouncementsPlugin } from './lib/plugins/livechat-announcements'
declare global {
interface Window {
@ -34,11 +37,18 @@ declare global {
env: {
html: Function
sizzle: Function
dayjs: Function
__: Function
u: {
hasClass: Function
addClass: Function
removeClass: Function
}
}
}
initConversePlugins: typeof initConversePlugins
initConverse: typeof initConverse
reconnectConverse?: (room: string) => void
reconnectConverse?: (params: any) => void
externalAuthGetResult?: (data: ExternalAuthResult) => void
}
}
@ -73,6 +83,8 @@ function initConversePlugins (peertubeEmbedded: boolean): void {
converse.plugins.add('livechatViewerModePlugin', livechatViewerModePlugin)
converse.plugins.add('converse-moderation-delay', moderationDelayPlugin)
converse.plugins.add('livechatAnnouncementsPlugin', livechatAnnouncementsPlugin)
}
window.initConversePlugins = initConversePlugins
@ -85,7 +97,7 @@ window.initConversePlugins = initConversePlugins
async function initConverse (
initConverseParams: InitConverseJSParams,
chatIncludeMode: ChatIncludeMode = 'chat-only',
peertubeAuthHeader?: { [header: string]: string } | null
peertubeAuthHeader?: Record<string, string> | null
): Promise<void> {
// First, fixing relative websocket urls.
if (initConverseParams.localWebsocketServiceUrl?.startsWith('/')) {
@ -120,9 +132,9 @@ async function initConverse (
params.view_mode = chatIncludeMode === 'chat-only' ? 'fullscreen' : 'embedded'
params.allow_url_history_change = chatIncludeMode === 'chat-only'
let isAuthenticated: boolean = false
let isAuthenticatedWithExternalAccount: boolean = false
let isRemoteWithNicknameSet: boolean = false
let isAuthenticated = false
let isAuthenticatedWithExternalAccount = false
let isRemoteWithNicknameSet = false
// OIDC (OpenID Connect):
const tryOIDC = (initConverseParams.externalAuthOIDC?.length ?? 0) > 0
@ -218,20 +230,24 @@ async function initConverse (
// * mode === chat-only + !transparent + !readonly + is using a livechat token
// Technically it would work in 'chat-only' mode, but i don't want to add too many things to test
// (and i now there is some CSS bugs in the task list).
let enableTask = false
// Same for the moderator notes app.
let enableApps = false
if (chatIncludeMode === 'peertube-video' || chatIncludeMode === 'peertube-fullpage') {
enableTask = true
enableApps = true
} else if (
chatIncludeMode === 'chat-only' &&
usedLivechatToken &&
!initConverseParams.transparent &&
!initConverseParams.forceReadonly
) {
enableTask = true
enableApps = true
}
if (enableTask) {
if (enableApps) {
params.livechat_task_app_enabled = true
params.livechat_task_app_restore = chatIncludeMode === 'peertube-fullpage' || chatIncludeMode === 'chat-only'
params.livechat_note_app_enabled = true
params.livechat_note_app_restore = chatIncludeMode === 'peertube-fullpage' || chatIncludeMode === 'chat-only'
params.livechat_mam_search_app_enabled = true
}
try {

View File

@ -8,14 +8,13 @@
* @description This files will override the original ConverseJS index.js file.
*/
import '@converse/headless'
import 'shared/styles/index.scss'
import './i18n/index.js'
import 'shared/registry.js'
import { CustomElement } from 'shared/components/element'
import { VIEW_PLUGINS } from './shared/constants.js'
import { _converse, converse } from '@converse/headless/core'
import 'shared/styles/index.scss'
import { _converse, converse } from '@converse/headless'
/* START: Removable plugins
* ------------------------
@ -45,11 +44,16 @@ import './plugins/singleton/index.js'
import './plugins/fullscreen/index.js'
import '../custom/plugins/size/index.js'
import '../custom/plugins/mam-search/index.js'
import '../custom/plugins/notes/index.js'
import '../custom/plugins/tasks/index.js'
import '../custom/plugins/terms/index.js'
import '../custom/plugins/poll/index.js'
/* END: Removable components */
// Running some specific livechat patches:
import '../custom/livechat-patch-vcard.js'
import { CORE_PLUGINS } from './headless/shared/constants.js'
import { ROOM_FEATURES } from './headless/plugins/muc/constants.js'
// We must add our custom plugins to CORE_PLUGINS (so it is white listed):
@ -57,11 +61,14 @@ CORE_PLUGINS.push('livechat-converse-size')
CORE_PLUGINS.push('livechat-converse-tasks')
CORE_PLUGINS.push('livechat-converse-terms')
CORE_PLUGINS.push('livechat-converse-poll')
CORE_PLUGINS.push('livechat-converse-notes')
CORE_PLUGINS.push('livechat-converse-mam-search')
// 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')
ROOM_FEATURES.push('x_peertubelivechat_emoji_only_mode')
_converse.CustomElement = CustomElement
_converse.exports.CustomElement = CustomElement
const initialize = converse.initialize

View File

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api } from '@converse/headless/core.js'
import { api } from '@converse/headless/index.js'
import { CustomElement } from 'shared/components/element.js'
import { tplExternalLoginModal } from 'templates/livechat-external-login-modal.js'
import { __ } from 'i18n'

View File

@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// Here we are patching the vCard plugin, to add some specific optimizations.
import { _converse, api } from '@converse/headless/index.js'
import {
onOccupantAvatarChanged,
setVCardOnModel,
setVCardOnOccupant
} from '@converse/headless/plugins/vcard/utils.js'
const pluginDefinition = _converse.pluggable.plugins['converse-vcard']
const originalInitialize = pluginDefinition.initialize
pluginDefinition.initialize = function initialize () {
const previousListeners = _converse._events.chatRoomInitialized ?? []
originalInitialize.apply(this)
_converse.api.settings.extend({
livechat_load_all_vcards: false
})
// Now we must detect the new chatRoomInitialized listener, and remove it:
const listenersToRemove = []
for (const def of _converse._events.chatRoomInitialized ?? []) {
if (def.callback && !previousListeners.includes(def.callback)) {
listenersToRemove.push(def.callback)
}
}
for (const callback of listenersToRemove) {
console.debug('Livechat patching vcard: we must remove this listener', callback)
api.listen.not('chatRoomInitialized', callback)
}
// Adding the new listener:
api.listen.on('chatRoomInitialized', (m) => {
console.debug('Patched version of the vcard chatRoomInitialized event.')
setVCardOnModel(m)
// loadAll: when in readonly mode (ie: OBS integration), always load all avatars.
const loadAll = api.settings.get('livechat_load_all_vcards') === true
let hiddenOccupants = m.get('hidden_occupants')
if (hiddenOccupants !== true || loadAll) {
m.occupants.forEach(setVCardOnOccupant)
}
m.listenTo(m.occupants, 'add', (occupant) => {
if (hiddenOccupants !== true || loadAll) {
setVCardOnOccupant(occupant)
}
})
m.on('change:hidden_occupants', () => {
hiddenOccupants = m.get('hidden_occupants')
if (hiddenOccupants !== true || loadAll) {
m.occupants.forEach(setVCardOnOccupant)
}
})
m.listenTo(m.occupants, 'change:image_hash', o => onOccupantAvatarChanged(o))
})
}

View File

@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api, converse } from '../../../src/headless/index.js'
import { XMLNS_MAM_SEARCH } from './constants.js'
const env = converse.env
const {
$iq,
Strophe,
sizzle,
log,
TimeoutError,
__,
u
} = env
const NS = Strophe.NS
async function query (options) {
if (!api.connection.connected()) {
throw new Error('Can\'t call `api.livechat_mam_search.query` before having established an XMPP session')
}
if (!options?.room) {
throw new Error('api.livechat_mam_search.query: Missing room parameter.')
}
const attrs = {
type: 'set',
to: options.room
}
const jid = attrs.to
const supported = await api.disco.supports(XMLNS_MAM_SEARCH, jid)
if (!supported) {
log.warn(`Did not search MAM archive for ${jid} because it doesn't support ${XMLNS_MAM_SEARCH}`)
return { messages: [] }
}
const queryid = u.getUniqueId()
const stanza = $iq(attrs).c('query', { xmlns: XMLNS_MAM_SEARCH, queryid: queryid })
stanza.c('x', { xmlns: NS.XFORM, type: 'submit' })
.c('field', { var: 'FORM_TYPE', type: 'hidden' })
.c('value').t(XMLNS_MAM_SEARCH).up().up()
if (options.from) {
stanza.c('field', { var: 'from' }).c('value')
.t(options.from).up().up()
}
if (options.occupant_id) {
stanza.c('field', { var: 'occupant_id' }).c('value')
.t(options.occupant_id).up().up()
}
stanza.up()
// TODO: handle RSM (pagination.)
const connection = api.connection.get()
const messages = []
const messageHandler = connection.addHandler((stanza) => {
const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop()
if (result === undefined || result.getAttribute('queryid') !== queryid) {
return true
}
const from = stanza.getAttribute('from')
if (from !== attrs.to) {
log.warn(`Ignoring alleged groupchat MAM message from ${from}`)
return true
}
messages.push(stanza)
return true
}, NS.MAM)
let error
const timeout = api.settings.get('message_archiving_timeout')
const iqResult = await api.sendIQ(stanza, timeout, false)
if (iqResult === null) {
const errMsg = __('Timeout while trying to fetch archived messages.')
log.error(errMsg)
error = new TimeoutError(errMsg)
return { messages, error }
} else if (u.isErrorStanza(iqResult)) {
const errMsg = __('An error occurred while querying for archived messages.')
log.error(errMsg)
log.error(iqResult)
error = new Error(errMsg)
return { messages, error }
}
connection.deleteHandler(messageHandler)
return { messages }
}
async function showMessagesFrom (occupant) {
const appElement = document.querySelector('livechat-converse-muc-mam-search-app')
if (!appElement) {
throw new Error('Cant find Search App Element')
}
appElement.searchFrom(occupant)
await appElement.showApp()
await appElement.updateComplete // waiting for the app to be open
return appElement
}
export default {
query,
showMessagesFrom
}

View File

@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api } from '@converse/headless'
import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers.js'
import { MUCApp } from '../../../shared/components/muc-app/index.js'
import { tplMamSearchApp } from '../templates/muc-mam-search-app.js'
/**
* Custom Element to display the Mam Search Application.
*/
export default class MUCMamSearchApp extends MUCApp {
restoreSettingName = undefined
sessionStorageRestoreKey = undefined
static get properties () {
return {
model: { type: Object, attribute: true }, // the muc model
occupant: { type: Object, attribute: true }, // the occupant to search (can be undefined if no current search)
results: { type: Object, attribute: true } // a Collection with the results.
}
}
render () {
return tplMamSearchApp(this, this.model, this.occupant)
}
searchFrom (occupant) {
this.results = undefined
this.occupant = occupant
const p = api.livechat_mam_search.query({
room: this.model.get('jid'),
// FIXME: shouldn't we escape the nick? cant see any code that escapes it in Converse.
from: occupant.get('from') || this.model.get('jid') + '/' + (occupant.get('nick') ?? ''),
occupant_id: occupant.get('occupant_id')
})
// don't wait the result to show something! (there will be a spinner)
p.then(async (results) => {
this.occupant = occupant // in case user did simultaneous requests
const messages = await Promise.all(results.messages.map(s => parseMUCMessage(s, this.model)))
// Note: we are not using MUCMessage objects, because we don't want the objects
// used here to interract with objects in the chat rooms.
// We could have a lot of unwanted sideeffects.
this.results = messages.reverse()
})
}
}
api.elements.define('livechat-converse-muc-mam-search-app', MUCMamSearchApp)

View File

@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { CustomElement } from 'shared/components/element.js'
import { tplMucMamSearchMessage } from '../templates/muc-mam-search-message.js'
import { api } from '@converse/headless'
import '../styles/muc-mam-search-message.scss'
export default class MUCMamSearchMessageView extends CustomElement {
static get properties () {
return {
message: { type: Object, attribute: true }, // /!\ this is not a model
mucModel: { type: Object, attribute: true },
searchOccupantModel: { type: Object, attribute: true }
}
}
async initialize () {
this.listenTo(this.mucModel, 'change', () => this.requestUpdate())
this.listenTo(this.searchOccupantModel, 'change', () => this.requestUpdate())
}
render () {
return tplMucMamSearchMessage(this, this.mucModel, this.searchOccupantModel, this.message)
}
getMessageOccupant () {
const occupants = this.mucModel?.occupants
if (!occupants?.findOccupant) { return undefined }
const nick = this.message.nick
const jid = this.message.from
const occupantId = this.message.occupant_id
if (!nick && !jid && !occupantId) {
return undefined
}
if (occupantId) {
const o = occupants.findOccupant({ occupant_id: occupantId })
if (o) {
return o
}
}
if (jid) {
const o = occupants.findOccupant({
jid,
nick
})
if (o) {
return o
}
}
// If we don't find it, maybe it is a user that has spoken a long time ago (or never spoked).
// In such case, we must create a dummy occupant:
const o = occupants.create({
nick,
occupant_id: occupantId,
jid
})
return o
}
getDateTime () {
if (!this.message.time) {
return undefined
}
try {
const d = new Date(this.message.time)
return d.toLocaleDateString() + ' - ' + d.toLocaleTimeString()
} catch (err) {
console.log(err)
return undefined
}
}
}
api.elements.define('livechat-converse-muc-mam-search-message', MUCMamSearchMessageView)

View File

@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { CustomElement } from 'shared/components/element.js'
import { tplMucMamSearchOccupant } from '../templates/muc-mam-search-occupant'
import { api } from '@converse/headless'
import '../styles/muc-mam-search-occupant.scss'
export default class MUCMamSearchOccupantView extends CustomElement {
static get properties () {
return {
model: { type: Object, attribute: true },
message: { type: Object, attribute: true } // optional message.
}
}
async initialize () {
this.listenTo(this.model, 'change', () => this.requestUpdate())
}
render () {
return tplMucMamSearchOccupant(this, this.model, this.message)
}
}
api.elements.define('livechat-converse-muc-mam-search-occupant', MUCMamSearchOccupantView)

View File

@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
export const XMLNS_MAM_SEARCH = 'urn:xmpp:mam:2#x-search'

View File

@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api, converse } from '../../../src/headless/index.js'
import { getMessageActionButtons, getOccupantActionButtons } from './utils.js'
import mamSearchApi from './api.js'
import './components/muc-mam-search-app-view.js'
import './components/muc-mam-search-occupant-view.js'
import './components/muc-mam-search-message-view.js'
converse.plugins.add('livechat-converse-mam-search', {
dependencies: ['converse-muc', 'converse-muc-views'],
async initialize () {
const _converse = this._converse
Object.assign(api, {
livechat_mam_search: mamSearchApi
})
_converse.api.settings.extend({
livechat_mam_search_app_enabled: false
})
// Adding buttons on messages:
_converse.api.listen.on('getMessageActionButtons', getMessageActionButtons)
// Adding buttons on occupants:
_converse.api.listen.on('getOccupantActionButtons', getOccupantActionButtons)
// FIXME: should we listen to any event (feature/affiliation change?, mam_enabled?) to refresh messageActionButtons?
}
})

View File

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
livechat-converse-muc-mam-search-message {
border: 1px solid var(--chatroom-head-bg-color);
border-radius: 4px;
display: block;
margin: 0.25em 0;
padding: 0.25em;
width: 100%;
converse-rich-text {
color: var(--message-text-color);
font-size: var(--message-font-size);
padding: 0;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-word;
}
.livechat-message-date {
font-size: 0.75em;
list-style: none;
text-align: right;
}
}
}

View File

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
livechat-converse-muc-mam-search-occupant {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 0.25em;
& > a {
display: flex;
flex-flow: row nowrap;
align-items: center;
span {
font-weight: bold;
margin-left: 0.5em;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
}
& > ul {
font-weight: lighter;
font-size: 0.75em;
list-style: none;
text-align: right;
}
}
}

View File

@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { converseLocalizedHelpUrl } from '../../../shared/lib/help'
import { tplMUCApp } from '../../../shared/components/muc-app/templates/muc-app.js'
import { html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { __ } from 'i18n'
function tplContent (el, mucModel, occupantModel) {
return html`
${
occupantModel
? html`
<livechat-converse-muc-mam-search-occupant
.model=${occupantModel}
></livechat-converse-muc-mam-search-occupant>
`
: ''
}
<hr>
${
el.results
? repeat(el.results, (message) => message.id, message => {
return html`<livechat-converse-muc-mam-search-message
.message=${message} .mucModel=${mucModel} .searchOccupantModel=${occupantModel}
></livechat-converse-muc-mam-search-message>`
})
: html`<livechat-spinner></livechat-spinner>`
}
`
}
export function tplMamSearchApp (el, mucModel, occupantModel) {
if (!mucModel) {
// should not happen
return html``
}
if (!el.show) {
return html``
}
// eslint-disable-next-line no-undef
const i18nSearch = __(LOC_message_search)
// eslint-disable-next-line no-undef
const i18nHelp = __(LOC_online_help)
const helpUrl = converseLocalizedHelpUrl({
page: 'documentation/user/streamers/moderation'
})
return tplMUCApp(
el,
i18nSearch,
helpUrl,
i18nHelp,
tplContent(el, mucModel, occupantModel)
)
}

View File

@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { html } from 'lit'
/**
* Renders the message as a search result.
* @param el The message element
* @param mucModel The MUC model
* @param searchOccupantModel The model of the occupant for which we are searching
* @param message The message (warning: this is not a model)
* @returns TemplateResult (or equivalent)
*/
export function tplMucMamSearchMessage (el, mucModel, searchOccupantModel, message) {
const occupant = el.getMessageOccupant()
return html`
${
occupant
? html`
<livechat-converse-muc-mam-search-occupant
.model=${occupant}
.message=${message}
></livechat-converse-muc-mam-search-occupant>`
: ''
}
<converse-rich-text
render_styling
text=${message.body}>
</converse-rich-text>
<div class="livechat-message-date">${el.getDateTime()}</div>`
}

View File

@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { html } from 'lit'
import { api } from '@converse/headless'
import { getAuthorStyle } from '../../../../src/utils/color.js'
import { __ } from 'i18n'
export function tplMucMamSearchOccupant (el, occupant, message) {
const authorStyle = getAuthorStyle(occupant)
const jid = occupant.get('jid')
const occupantId = occupant.get('occupant_id')
return html`
<a @click=${(ev) => {
api.modal.show('converse-muc-occupant-modal', { model: occupant }, ev)
}}>
<converse-avatar
.model=${occupant}
class="avatar chat-msg__avatar"
name="${occupant.getDisplayName()}"
nonce=${occupant.vcard?.get('vcard_updated')}
height="30" width="30"></converse-avatar>
<span style=${authorStyle}>${occupant.getDisplayName()}</span>
</a>
<ul aria-hidden="true">
${
// user changed nick: display the original nick
message && message.nick !== undefined && message.nick !== occupant.get('nick')
// eslint-disable-next-line no-undef
? html`<li title=${__(LOC_message_search_original_nick)}>${message.nick}</li>`
: ''
}
${jid ? html`<li title=${__('XMPP Address')}>${jid}</li>` : ''}
${occupantId ? html`<li title=${__('Occupant Id')}>${occupantId}</li>` : ''}
</ul>`
}

View File

@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api } from '../../../src/headless/index.js'
import { XMLNS_MAM_SEARCH } from './constants.js'
import { __ } from 'i18n'
function getMessageActionButtons (messageActionsEl, buttons) {
const messageModel = messageActionsEl.model
if (!api.settings.get('livechat_mam_search_app_enabled')) {
return buttons
}
if (messageModel.get('type') !== 'groupchat') {
// only on groupchat message.
return buttons
}
if (!messageModel.occupant) {
return buttons
}
const muc = messageModel.collection?.chatbox
if (!muc) {
return buttons
}
if (!muc.features?.get?.(XMLNS_MAM_SEARCH)) {
return buttons
}
const myself = muc.getOwnOccupant()
if (!myself || !['admin', 'owner'].includes(myself.get('affiliation'))) {
return buttons
}
// eslint-disable-next-line no-undef
const i18nSearch = __(LOC_search_occupant_message)
buttons.push({
i18n_text: i18nSearch,
handler: async (ev) => {
ev.preventDefault()
api.livechat_mam_search.showMessagesFrom(messageModel.occupant)
},
button_class: '',
icon_class: 'fa fa-magnifying-glass',
name: 'muc-mam-search'
})
return buttons
}
function getOccupantActionButtons (occupant, buttons) {
if (!api.settings.get('livechat_mam_search_app_enabled')) {
return buttons
}
const muc = occupant.collection?.chatroom
if (!muc) {
return buttons
}
if (!muc.features?.get?.(XMLNS_MAM_SEARCH)) {
return buttons
}
const myself = muc.getOwnOccupant()
if (!myself || !['admin', 'owner'].includes(myself.get('affiliation'))) {
return buttons
}
// eslint-disable-next-line no-undef
const i18nSearch = __(LOC_search_occupant_message)
buttons.push({
i18n_text: i18nSearch,
handler: async (ev) => {
ev.preventDefault()
api.livechat_mam_search.showMessagesFrom(occupant)
},
button_class: '',
icon_class: 'fa fa-magnifying-glass',
name: 'muc-mam-search'
})
return buttons
}
export {
getMessageActionButtons,
getOccupantActionButtons
}

View File

@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
async function openNotes () {
const appElement = document.querySelector('livechat-converse-muc-note-app')
if (!appElement) {
throw new Error('Cant find Note App Element')
}
await appElement.showApp()
await appElement.updateComplete // waiting for the app to be open
const notesElement = appElement.querySelector('livechat-converse-muc-notes')
if (!notesElement) {
throw new Error('Cant find Notes Element')
}
await notesElement.updateComplete
return notesElement
}
async function openCreateNoteForm (occupant) {
const notesElement = await openNotes()
await notesElement.openCreateNoteForm(undefined, occupant)
}
async function searchNotesAbout (occupant) {
const notesElement = await openNotes()
await notesElement.filterNotes({ occupant })
}
export default {
openNotes,
openCreateNoteForm,
searchNotesAbout
}

View File

@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api } from '@converse/headless'
import { MUCApp } from '../../../shared/components/muc-app/index.js'
import { tplMUCNoteApp } from '../templates/muc-note-app.js'
/**
* Custom Element to display the Notes Application.
*/
export default class MUCNoteApp extends MUCApp {
restoreSettingName = 'livechat_note_app_restore'
sessionStorageRestoreKey = 'livechat-converse-note-app-show'
render () {
return tplMUCNoteApp(this, this.model)
}
}
api.elements.define('livechat-converse-muc-note-app', MUCNoteApp)

View File

@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { CustomElement } from 'shared/components/element.js'
import { tplMucNoteOccupant } from '../templates/muc-note-occupant'
import { api } from '@converse/headless'
import '../styles/muc-note-occupant.scss'
export default class MUCNoteOccupantView extends CustomElement {
static get properties () {
return {
model: { type: Object, attribute: true },
note: { type: Object, attribute: true }, // optional associated note
full_display: { type: Boolean, attribute: true }
}
}
async initialize () {
this.listenTo(this.model, 'change', () => this.requestUpdate())
}
render () {
return tplMucNoteOccupant(this, this.model, this.note)
}
}
api.elements.define('livechat-converse-muc-note-occupant', MUCNoteOccupantView)

View File

@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { CustomElement } from 'shared/components/element.js'
import { api } from '@converse/headless'
import { tplMucNote } from '../templates/muc-note'
import { __ } from 'i18n'
import '../styles/muc-note.scss'
export default class MUCNoteView extends CustomElement {
static get properties () {
return {
model: { type: Object, attribute: true },
edit: { type: Boolean, attribute: false },
is_ocupant_filter: { type: Boolean, attribute: true }
}
}
async initialize () {
this.edit = false
if (!this.model) {
return
}
this.listenTo(this.model, 'change', () => this.requestUpdate())
}
render () {
return tplMucNote(this, this.model)
}
shouldUpdate (changedProperties) {
if (!super.shouldUpdate(...arguments)) { return false }
// When a note is currently edited, and another users change the order,
// it could refresh losing the current form.
// To avoid this, we cancel update here.
// Note: of course, if 'edit' is part of the edited properties, we must update anyway
// (it means we just leaved the form)
if (this.edit && !changedProperties.has('edit')) {
console.info('Canceling an update on note, because it is currently edited', this)
return false
}
return true
}
async saveNote (ev) {
ev?.preventDefault?.()
const description = ev.target.description.value
if ((description ?? '') === '') { return }
try {
this.querySelectorAll('input[type=submit]').forEach(el => {
el.setAttribute('disabled', true)
el.classList.add('disabled')
})
const note = this.model
note.set('description', description)
await note.saveItem()
this.edit = false
this.requestUpdate() // In case we cancel another update in shouldUpdate
} catch (err) {
console.error(err)
} finally {
this.querySelectorAll('input[type=submit]').forEach(el => {
el.removeAttribute('disabled')
el.classList.remove('disabled')
})
}
}
async deleteNote (ev) {
ev?.preventDefault?.()
// eslint-disable-next-line no-undef
const i18nConfirmDelete = __(LOC_moderator_note_delete_confirm)
const result = await api.confirm(i18nConfirmDelete)
if (!result) { return }
try {
await this.model.deleteItem()
} catch (err) {
api.alert(
'error', __('Error'), [__('Error')]
)
}
}
async toggleEdit () {
this.edit = !this.edit
if (this.edit) {
await this.updateComplete
const textarea = this.querySelector('textarea[name="description"]')
if (textarea) {
textarea.focus()
// Placing cursor at the end:
textarea.selectionStart = textarea.value.length
textarea.selectionEnd = textarea.selectionStart
}
}
}
}
api.elements.define('livechat-converse-muc-note', MUCNoteView)

View File

@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api } from '@converse/headless'
import tplMucNotes from '../templates/muc-notes'
import { __ } from 'i18n'
import { DraggablesCustomElement } from '../../../shared/components/draggables/index.js'
import '../styles/muc-notes.scss'
export default class MUCNotesView extends DraggablesCustomElement {
static get properties () {
return {
model: { type: Object, attribute: true },
create_note_error_message: { type: String, attribute: false },
create_note_opened: { type: Boolean, attribute: false },
create_note_about_occupant: { type: Object, attribute: false },
occupant_filter: { type: Object, attribute: false }
}
}
async initialize () {
this.create_note_error_message = ''
if (!this.model) {
return
}
this.draggableTagName = 'livechat-converse-muc-note'
this.droppableTagNames = ['livechat-converse-muc-note']
this.droppableAlwaysBottomTagNames = []
// Adding or removing a new note: we must update.
this.listenTo(this.model, 'add', () => this.requestUpdate())
this.listenTo(this.model, 'remove', () => this.requestUpdate())
this.listenTo(this.model, 'sort', () => this.requestUpdate())
await super.initialize()
}
render () {
return tplMucNotes(this, this.model)
}
async openCreateNoteForm (ev, occupant) {
ev?.preventDefault?.()
this.create_note_opened = true
this.create_note_about_occupant = occupant ?? undefined
if (this.create_note_about_occupant === undefined && this.occupant_filter) {
// if we have a current filter, we can use it for the new note.
this.create_note_about_occupant = this.occupant_filter
}
await this.updateComplete
const textarea = this.querySelector('.notes-create-note textarea[name="description"]')
if (textarea) {
textarea.focus()
}
}
closeCreateNoteForm (ev) {
ev?.preventDefault?.()
this.create_note_opened = false
this.create_note_about_occupant = undefined
}
filterNotes (filters) {
this.occupant_filter = filters?.occupant || undefined
}
async submitCreateNote (ev) {
ev.preventDefault()
const description = ev.target.description.value
if (this.create_note_error_message) {
this.create_note_error_message = ''
}
if ((description ?? '') === '') { return }
try {
this.querySelectorAll('input[type=submit]').forEach(el => {
el.setAttribute('disabled', true)
el.classList.add('disabled')
})
await this.model.createNote({
description: description,
about_jid: ev.target.about_jid?.value || undefined,
about_nick: ev.target.about_nick?.value || undefined,
about_occupant_id: ev.target.about_occupant_id?.value || undefined
})
this.closeCreateNoteForm()
} catch (err) {
console.error(err)
// eslint-disable-next-line no-undef
this.create_note_error_message = __(LOC_moderator_notes_create_error)
} finally {
this.querySelectorAll('input[type=submit]').forEach(el => {
el.removeAttribute('disabled')
el.classList.remove('disabled')
})
}
}
_dropDone (draggedEl, droppedOnEl, onTopHalf) {
super._dropDone(...arguments)
console.log('[livechat note drag&drop] Note dropped...')
const note = draggedEl.model
if (!note) {
throw new Error('No model for the draggedEl')
}
const targetNote = droppedOnEl.model
if (!targetNote) {
throw new Error('No model for the droppedOnEl')
}
if (note === targetNote) {
console.log('[livechat note drag&drop] Note dropped on itself, nothing to do')
return
}
let newOrder = targetNote.get('order') ?? 0
if (onTopHalf) { newOrder = Math.max(0, newOrder + 1) } // reverse order!
// Warning: the order of the collection is reversed!
// _saveOrders needs it in ascending order!
this._saveOrders(Array.from(this.model).reverse(), note, newOrder)
}
}
api.elements.define('livechat-converse-muc-notes', MUCNotesView)

View File

@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
export const XMLNS_NOTE = 'urn:peertube-plugin-livechat:note'

View File

@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { _converse, converse } from '../../../src/headless/index.js'
import { XMLNS_NOTE } from './constants.js'
import { ChatRoomNote } from './note.js'
import { ChatRoomNotes } from './notes.js'
import {
initOrDestroyChatRoomNotes, getHeadingButtons, getMessageActionButtons, getOccupantActionButtons
} from './utils.js'
import notesApi from './api.js'
import './components/muc-note-app-view.js'
import './components/muc-notes-view.js'
import './components/muc-note-view.js'
import './components/muc-note-occupant-view.js'
converse.plugins.add('livechat-converse-notes', {
dependencies: ['converse-muc', 'converse-disco', 'converse-pubsub'],
initialize () {
Object.assign(
_converse.exports,
{
ChatRoomNotes,
ChatRoomNote
}
)
_converse.api.settings.extend({
livechat_note_app_enabled: false,
livechat_note_app_restore: false // should we open the app by default if it was previously oppened?
})
Object.assign(_converse.api, {
livechat_notes: notesApi
})
_converse.api.listen.on('chatRoomInitialized', muc => {
muc.session.on('change:connection_status', _session => {
// When joining a room, initializing the Notes object (if user has access),
// When disconnected from a room, destroying the Notes object:
initOrDestroyChatRoomNotes(muc)
})
// When the current user affiliation changes, we must also delete or initialize the TaskLists object:
muc.occupants.on('change:affiliation', occupant => {
if (occupant.get('jid') !== _converse.bare_jid) { // only for myself
return
}
initOrDestroyChatRoomNotes(muc)
})
// To be sure that everything works in any case, we also must listen for addition in muc.features.
muc.features.on('change:' + XMLNS_NOTE, () => {
initOrDestroyChatRoomNotes(muc)
})
})
// adding the "Notes" button in the MUC heading buttons:
_converse.api.listen.on('getHeadingButtons', getHeadingButtons)
// Adding buttons on messages:
_converse.api.listen.on('getMessageActionButtons', getMessageActionButtons)
// Adding buttons on occupants:
_converse.api.listen.on('getOccupantActionButtons', getOccupantActionButtons)
}
})

View File

@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { PubSubManager } from '../../shared/lib/pubsub-manager.js'
export class NotePubSubManager extends PubSubManager {
_additionalModelToData (item, data) {
super._additionalModelToData(item, data)
data.about_jid = item.get('about_jid')
data.about_occupant_id = item.get('about_occupant_id')
data.about_nick = item.get('about_nick')
}
_additionalDataToItemNode (data, item) {
super._additionalDataToItemNode(data, item)
const aboutAttributes = {}
if (data.about_jid !== undefined) {
aboutAttributes.jid = data.about_jid
}
if (data.about_nick !== undefined) {
aboutAttributes.nick = data.about_nick
}
const occupantId = data.about_occupant_id
if (occupantId !== undefined || Object.values(aboutAttributes).length) {
item.c('note-about', aboutAttributes)
if (occupantId) {
item.c('occupant-id', { xmlns: 'urn:xmpp:occupant-id:0', id: occupantId }).up()
}
item.up()
}
}
_additionalParseItemNode (itemNode, type, data) {
super._additionalParseItemNode(itemNode, type, data)
const about = itemNode.querySelector('& > note-about')
if (!about) { return }
data.about_jid = about.getAttribute('jid')
data.about_nick = about.getAttribute('nick')
const occupantIdEl = about.querySelector('& > occupant-id')
if (occupantIdEl) {
data.about_occupant_id = occupantIdEl.getAttribute('id')
}
}
}

View File

@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { Model } from '@converse/skeletor/src/model.js'
/**
* A chat room note.
* @class
* @namespace _converse.exports.ChatRoomNote
* @memberof _converse
*/
class ChatRoomNote extends Model {
idAttribute = 'id'
_aboutOccupantCache = null
_aboutOccupantCacheFor = null
async saveItem () {
console.log('Saving note ' + this.get('id') + '...')
await this.collection.chatroom.noteManager.saveItem(this)
console.log('Note ' + this.get('id') + ' saved.')
}
async deleteItem () {
return this.collection.chatroom.noteManager.deleteItems([this])
}
getAboutOccupant () {
const occupants = this.collection.chatroom?.occupants
if (!occupants?.findOccupant) { return undefined }
const nick = this.get('about_nick')
const jid = this.get('about_jid')
const occupantId = this.get('about_occupant_id')
if (!nick && !jid && !occupantId) {
this._aboutOccupantCache = null
this._aboutOccupantCacheFor = null
return undefined
}
// Keeping some cache, to avoid intensive search on each rendering.
const cacheKey = `${occupantId ?? ''} ${jid ?? ''} ${nick ?? ''}`
if (this._aboutOccupantCacheFor === cacheKey && this._aboutOccupantCache) {
return this._aboutOccupantCache
}
this._aboutOccupantCacheFor = cacheKey
if (occupantId) {
const o = occupants.findOccupant({ occupant_id: occupantId })
if (o) {
this._aboutOccupantCache = o
return o
}
}
if (jid) {
const o = occupants.findOccupant({
jid,
nick
})
if (o) {
this._aboutOccupantCache = o
return o
}
}
// If we don't find it, maybe it is a user that has spoken a long time ago (or never spoked).
// In such case, we must create a dummy occupant:
this._aboutOccupantCache = occupants.create({
nick,
occupant_id: occupantId,
jid
})
return this._aboutOccupantCache
}
}
export {
ChatRoomNote
}

View File

@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { Collection } from '@converse/skeletor/src/collection.js'
import { ChatRoomNote } from './note'
import { initStorage } from '@converse/headless/utils/storage.js'
/**
* A list of {@link _converse.exports.ChatRoomNote} instances, representing notes associated to a MUC.
* @class
* @namespace _converse.exports.ChatRoomNotes
* @memberOf _converse
*/
class ChatRoomNotes extends Collection {
model = ChatRoomNote
initialize (models, options) {
this.model = ChatRoomNote // don't know why, must do it again here
super.initialize(arguments)
this.chatroom = options.chatroom
const id = `converse-livechat-notes-${this.chatroom.get('jid')}`
initStorage(this, id, 'session')
this.on('change:order', () => this.sort())
}
comparator (n1, n2) {
// must reverse order
const o1 = n1.get('order') ?? 0
const o2 = n2.get('order') ?? 0
return o1 < o2 ? 1 : o1 > o2 ? -1 : 0
}
async createNote (data) {
data = Object.assign({}, data)
if (!data.order) {
data.order = 1 + Math.max(
0,
...(this.map(n => n.get('order') ?? 0).filter(o => !isNaN(o)))
)
}
console.log('Creating note...')
await this.chatroom.noteManager.createItem(this, data)
console.log('Note created.')
}
}
export {
ChatRoomNotes
}

View File

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
livechat-converse-muc-note-occupant {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
padding: 0.25em;
& > a {
display: flex;
flex-flow: row nowrap;
align-items: center;
span {
font-weight: bold;
margin-left: 0.5em;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
}
& > ul {
font-weight: lighter;
font-size: 0.75em;
list-style: none;
text-align: right;
}
}
}

View File

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
livechat-converse-muc-note {
padding: 0;
width: 100%;
.note-line {
border: 1px solid var(--chatroom-head-bg-color);
border-radius: 4px;
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
margin: 0.25em 0;
padding: 0.25em;
column-gap: 0.25em;
width: 100%;
.note-content {
flex-grow: 2;
}
.note-description {
white-space: pre-wrap;
}
.note-action {
background: unset;
border: 0;
padding-left: 0.25em;
padding-right: 0.25em;
}
form {
width: 100%;
}
}
}
}

View File

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
.notes-actions {
display: flex;
flex-flow: row nowrap;
justify-content: right;
width: 100%;
}
.notes-action {
background: unset;
border: 0;
padding-left: 0.25em;
padding-right: 0.25em;
}
.notes-filters {
border: 1px solid var(--chatroom-head-bg-color);
border-radius: 4px;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
margin: 0.25em 0;
padding: 0.25em;
column-gap: 0.25em;
width: 100%;
livechat-converse-muc-note-occupant {
flex-grow: 2;
}
}
}

View File

@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { converseLocalizedHelpUrl } from '../../../shared/lib/help'
import { tplMUCApp } from '../../../shared/components/muc-app/templates/muc-app.js'
import { html } from 'lit'
import { __ } from 'i18n'
export function tplMUCNoteApp (el, mucModel) {
if (!mucModel) {
// should not happen
return html``
}
if (!mucModel.notes) {
// too soon, not initialized yet (this will happen)
return html``
}
if (!el.show) {
return html``
}
// eslint-disable-next-line no-undef
const i18nNotes = __(LOC_moderator_notes)
// eslint-disable-next-line no-undef
const i18nHelp = __(LOC_online_help)
const helpUrl = converseLocalizedHelpUrl({
page: 'documentation/user/streamers/moderation_notes'
})
return tplMUCApp(
el,
i18nNotes,
helpUrl,
i18nHelp,
html`<livechat-converse-muc-notes .model=${mucModel.notes}></livechat-converse-muc-notes>`
)
}

View File

@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { html } from 'lit'
import { api } from '@converse/headless'
import { getAuthorStyle } from '../../../../src/utils/color.js'
import { __ } from 'i18n'
export function tplMucNoteOccupant (el, occupant, note) {
const authorStyle = getAuthorStyle(occupant)
const jid = occupant.get('jid')
const occupantId = occupant.get('occupant_id')
return html`
<a @click=${(ev) => {
api.modal.show('converse-muc-occupant-modal', { model: occupant }, ev)
}}>
<converse-avatar
.model=${occupant}
class="avatar chat-msg__avatar"
name="${occupant.getDisplayName()}"
nonce=${occupant.vcard?.get('vcard_updated')}
height="30" width="30"></converse-avatar>
<span style=${authorStyle}>${occupant.getDisplayName()}</span>
</a>
${
el.full_display
? html`<ul aria-hidden="true">
${
// user changed nick: display the original nick
note && note.get('about_nick') && note.get('about_nick') !== occupant.get('nick')
// eslint-disable-next-line no-undef
? html`<li title=${__(LOC_moderator_note_original_nick)}>${note.get('about_nick')}</li>`
: ''
}
${jid ? html`<li title=${__('XMPP Address')}>${jid}</li>` : ''}
${occupantId ? html`<li title=${__('Occupant Id')}>${occupantId}</li>` : ''}
</ul>`
: ''
}
`
}

View File

@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { api } from '@converse/headless'
import { html } from 'lit'
import { __ } from 'i18n'
export function tplMucNote (el, note) {
// eslint-disable-next-line no-undef
const i18nDelete = __(LOC_moderator_note_delete)
// eslint-disable-next-line no-undef
const i18nSearch = __(LOC_moderator_note_search_for_participant)
const aboutOccupant = note.getAboutOccupant()
return !el.edit
? html`
<div draggable="true" class="note-line draggables-line">
<div class="note-content">
${
aboutOccupant
? html`
<livechat-converse-muc-note-occupant
.full_display=${el.is_ocupant_filter}
.model=${aboutOccupant}
.note=${note}
></livechat-converse-muc-note-occupant>`
: ''
}
<div class="note-description">${note.get('description') ?? ''}</div>
</div>
${
aboutOccupant && el.is_ocupant_filter
? ''
: html`
<button type="button" class="note-action" @click=${ev => {
ev.preventDefault()
api.livechat_notes.searchNotesAbout(aboutOccupant)
}}>
<converse-icon class="fa fa-magnifying-glass" size="1em" title=${i18nSearch}></converse-icon>
</button>`
}
<button type="button" class="note-action" title="${__('Edit')}"
@click=${el.toggleEdit}
>
<converse-icon class="fa fa-edit" size="1em"></converse-icon>
</button>
<button type="button" class="note-action" title="${i18nDelete}"
@click=${el.deleteNote}
>
<converse-icon class="fa fa-trash-alt" size="1em"></converse-icon>
</button>
</div>`
: html`
<div class="note-line draggables-line">
<form class="converse-form" @submit=${el.saveNote}>
${
aboutOccupant
? html`
<livechat-converse-muc-note-occupant
full_display=${true}
.model=${aboutOccupant}
.note=${note}
></livechat-converse-muc-note-occupant>
`
: ''
}
${_tplNoteForm(note)}
<fieldset>
<input type="submit" class="btn btn-primary" value="${__('Ok')}" />
<input type="button" class="btn btn-secondary button-cancel"
value="${__('Cancel')}" @click=${el.toggleEdit}
/>
</fieldset>
</form>
</div>`
}
function _tplNoteForm (note) {
// eslint-disable-next-line no-undef
const i18nNoteDesc = __(LOC_moderator_note_description)
return html`<fieldset>
<textarea
class="form-control" name="description"
placeholder="${i18nNoteDesc}"
>${note ? note.get('description') : ''}</textarea>
</fieldset>`
}
function _tplNoteOccupantFormFields (occupant) {
if (!occupant) { return '' }
return html`
<input type="hidden" name="about_nick" value=${occupant.get('nick')} />
<input type="hidden" name="about_jid" value=${occupant.get('jid')} />
<input type="hidden" name="about_occupant_id" value=${occupant.get('occupant_id')} />
`
}
export function tplMucCreateNoteForm (notesEl, occupant) {
const i18nOk = __('Ok')
const i18nCancel = __('Cancel')
return html`
<form class="notes-create-note converse-form" @submit=${notesEl.submitCreateNote}>
${
occupant
? html`
${_tplNoteOccupantFormFields(occupant)}
<livechat-converse-muc-note-occupant
full_display=${true}
.model=${occupant}
></livechat-converse-muc-note-occupant>
`
: ''
}
${_tplNoteForm(undefined)}
<fieldset>
<input type="submit" class="btn btn-primary" value="${i18nOk}" />
<input type="button" class="btn btn-secondary button-cancel"
value="${i18nCancel}" @click=${notesEl.closeCreateNoteForm}
/>
${!notesEl.create_note_error_message
? ''
: html`<div class="invalid-feedback d-block">${notesEl.create_note_error_message}</div>`
}
</fieldset>
</form>`
}

View File

@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { __ } from 'i18n'
import { tplMucCreateNoteForm } from './muc-note'
function tplFilters (el) {
const filterOccupant = el.occupant_filter
if (!filterOccupant) { return '' }
// eslint-disable-next-line no-undef
const i18nSearch = __(LOC_moderator_note_filters)
return html`
<div class="notes-filters">
<converse-icon class="fa fa-magnifying-glass" size="1em" title=${i18nSearch}></converse-icon>
${
filterOccupant
? html`<livechat-converse-muc-note-occupant
full_display=${true}
.model=${filterOccupant}
></livechat-converse-muc-note-occupant>`
: ''
}
<button type="button" class="notes-action" @click=${(ev) => {
ev?.preventDefault()
el.filterNotes({})
}} title="${__('Close')}">
<converse-icon class="fa fa-times" size="1em"></converse-icon>
</button>
</div>
<hr/>
`
}
function isFiltered (el, note) {
const filterOccupant = el.occupant_filter
if (!filterOccupant) { return false }
const noteOccupant = note.getAboutOccupant()
// there is an occupant filter, so if current note has no associated occupant, we can pass.
if (!noteOccupant) { return true }
if (noteOccupant === filterOccupant) {
// Yes!
return false
}
// We will also test for nickname, so that we can found anonymous users
// (they can have multiple associated occupants)
if (filterOccupant.get('nick') && filterOccupant.get('nick') === noteOccupant.get('nick')) {
return false
}
return true
}
export default function tplMucNotes (el, notes) {
if (!notes) { // if user loses rights
return html`` // FIXME: add a message like "you dont have access"?
}
return html`
${
el.create_note_opened ? tplMucCreateNoteForm(el, el.create_note_about_occupant) : tplCreateButton(el)
}
${tplFilters(el)}
${
repeat(notes, (note) => note.get('id'), (note) => {
return isFiltered(el, note)
? ''
: html`<livechat-converse-muc-note
.model=${note}
.is_ocupant_filter=${!!el.occupant_filter}
></livechat-converse-muc-note>`
})
}`
}
function tplCreateButton (el) {
// eslint-disable-next-line no-undef
const i18nCreateNote = __(LOC_moderator_note_create)
return html`
<div class="notes-actions">
<button type="button" class="notes-action" title="${i18nCreateNote}" @click=${el.openCreateNoteForm}>
<converse-icon class="fa fa-plus" size="1em"></converse-icon>
</button>
</div>`
}

View File

@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { XMLNS_NOTE } from './constants.js'
import { NotePubSubManager } from './note-pubsub-manager.js'
import { converse, _converse, api } from '../../../src/headless/index.js'
import { __ } from 'i18n'
export function getHeadingButtons (view, buttons) {
const muc = view.model
if (muc.get('type') !== _converse.constants.CHATROOMS_TYPE) {
// only on MUC.
return buttons
}
if (!muc.notes) { // this is defined only if user has access (see initOrDestroyChatRoomNotes)
return buttons
}
// Adding a "Open moderator noteds" button.
buttons.unshift({
// eslint-disable-next-line no-undef
i18n_text: __(LOC_moderator_notes),
handler: async (ev) => {
ev.preventDefault()
// opening or closing the muc notes:
const NoteAppEl = ev.target.closest('converse-root').querySelector('livechat-converse-muc-note-app')
NoteAppEl.toggleApp()
},
a_class: '',
icon_class: 'fa-note-sticky',
name: 'muc-notes'
})
return buttons
}
export function getMessageActionButtons (messageActionsEl, buttons) {
const messageModel = messageActionsEl.model
if (messageModel.get('type') !== 'groupchat') {
// only on groupchat message.
return buttons
}
if (!messageModel.occupant) {
return buttons
}
const muc = messageModel.collection?.chatbox
if (!muc?.notes) {
return buttons
}
// eslint-disable-next-line no-undef
const i18nCreate = __(LOC_moderator_note_create_for_participant)
// eslint-disable-next-line no-undef
const i18nSearch = __(LOC_moderator_note_search_for_participant)
buttons.push({
i18n_text: i18nCreate,
handler: async (ev) => {
ev.preventDefault()
await api.livechat_notes.openCreateNoteForm(messageModel.occupant)
},
button_class: '',
icon_class: 'fa fa-note-sticky',
name: 'muc-note-create-for-occupant'
})
buttons.push({
i18n_text: i18nSearch,
handler: async (ev) => {
ev.preventDefault()
await api.livechat_notes.searchNotesAbout(messageModel.occupant)
},
button_class: '',
icon_class: 'fa fa-magnifying-glass',
name: 'muc-note-search-for-occupant'
})
return buttons
}
export function getOccupantActionButtons (occupant, buttons) {
const muc = occupant.collection?.chatroom
if (!muc?.notes) {
// We dont have access.
return buttons
}
// eslint-disable-next-line no-undef
const i18nCreate = __(LOC_moderator_note_create_for_participant)
// eslint-disable-next-line no-undef
const i18nSearch = __(LOC_moderator_note_search_for_participant)
buttons.push({
i18n_text: i18nCreate,
handler: async (ev) => {
ev.preventDefault()
await api.livechat_notes.openCreateNoteForm(occupant)
},
button_class: '',
icon_class: 'fa fa-note-sticky',
name: 'muc-note-create-for-occupant'
})
buttons.push({
i18n_text: i18nSearch,
handler: async (ev) => {
ev.preventDefault()
await api.livechat_notes.searchNotesAbout(occupant)
},
button_class: '',
icon_class: 'fa fa-magnifying-glass',
name: 'muc-note-search-for-occupant'
})
return buttons
}
function _initChatRoomNotes (mucModel) {
if (mucModel.noteManager) {
// already initiliazed
return
}
mucModel.notes = new _converse.exports.ChatRoomNotes(undefined, { chatroom: mucModel })
mucModel.noteManager = new NotePubSubManager(
mucModel.get('jid'),
'livechat-notes', // the node name
{
note: {
itemTag: 'note',
xmlns: XMLNS_NOTE,
collection: mucModel.notes,
fields: {
description: String
},
attributes: {
order: Number
}
}
}
)
mucModel.noteManager.start().catch(err => console.log(err))
// We must requestUpdate for all message actions, to add the "create note" button.
// FIXME: this should not be done here (but it is simplier for now)
document.querySelectorAll('converse-message-actions').forEach(el => el.requestUpdate())
}
function _destroyChatRoomNotes (mucModel) {
if (!mucModel.noteManager) { return }
mucModel.noteManager.stop().catch(err => console.log(err))
mucModel.noteManager = undefined
mucModel.notes = undefined
// We must requestUpdate for all message actions, to remove the "create note" button.
// FIXME: this should not be done here (but it is simplier for now)
document.querySelectorAll('converse-message-actions').forEach(el => el.requestUpdate())
}
export function initOrDestroyChatRoomNotes (mucModel) {
if (mucModel.get('type') !== _converse.constants.CHATROOMS_TYPE) {
// only on MUC.
return _destroyChatRoomNotes(mucModel)
}
if (!api.settings.get('livechat_note_app_enabled')) {
// Feature disabled, no need to handle notes.
return _destroyChatRoomNotes(mucModel)
}
if (mucModel.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
return _destroyChatRoomNotes(mucModel)
}
// We must check disco features
// (if the chat is remote, the server could use a livechat version that does not support this feature)
if (!mucModel.features?.get?.(XMLNS_NOTE)) {
return _destroyChatRoomNotes(mucModel)
}
const myself = mucModel.getOwnOccupant()
if (!myself || !['admin', 'owner'].includes(myself.get('affiliation'))) {
// User must be admin or owner
return _destroyChatRoomNotes(mucModel)
}
return _initChatRoomNotes(mucModel)
}

View File

@ -4,7 +4,7 @@
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 { converse, api, parsers } from '@converse/headless'
import { webForm2xForm } from '@converse/headless/utils/form'
import { __ } from 'i18n'
import '../styles/poll-form.scss'
@ -18,7 +18,6 @@ export default class MUCPollFormView extends CustomElement {
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 }
@ -27,6 +26,8 @@ export default class MUCPollFormView extends CustomElement {
_fieldTranslationMap = new Map()
xform = undefined
async initialize () {
this.alert_message = undefined
if (!this.model) {
@ -36,20 +37,18 @@ export default class MUCPollFormView extends CustomElement {
try {
this._initFieldTranslations()
const stanza = await this._fetchPollForm()
const query = stanza.querySelector('query')
const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query)[0]
const xform = parsers.parseXForm(stanza)
if (!xform) {
throw Error('Missing xform in stanza')
}
xform.fields?.map(f => this._translateField(f))
this.xform = xform
// 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')
@ -86,10 +85,10 @@ export default class MUCPollFormView extends CustomElement {
}
_translateField (field) {
const v = field.getAttribute('var')
const v = field.var
const label = this._fieldTranslationMap.get(v)
if (label) {
field.setAttribute('label', label)
field.label = label
}
}
@ -114,7 +113,7 @@ export default class MUCPollFormView extends CustomElement {
await api.sendIQ(iq)
if (this.modal) {
this.modal.onHide()
this.modal.close()
}
} catch (err) {
if (u.isErrorStanza(err)) {

View File

@ -4,7 +4,7 @@
import { tplPoll } from '../templates/poll.js'
import { CustomElement } from 'shared/components/element.js'
import { converse, _converse, api } from '@converse/headless/core'
import { converse, _converse, api } from '@converse/headless'
import '../styles/poll.scss'
export default class MUCPollView extends CustomElement {

View File

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
import { _converse, converse } from '../../../src/headless/core.js'
import { _converse, converse } from '../../../src/headless/index.js'
import { getHeadingButtons } from './utils.js'
import { POLL_MESSAGE_TAG, POLL_QUESTION_TAG, POLL_CHOICE_TAG } from './constants.js'
import { __ } from 'i18n'

View File

@ -2,9 +2,12 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { __ } from 'i18n'
import BaseModal from 'plugins/modal/modal.js'
import { api } from '@converse/headless/core'
import { api } from '@converse/headless'
import { modal_close_button as ModalCloseButton } from 'plugins/modal/templates/buttons.js'
import { html } from 'lit'
@ -13,8 +16,8 @@ class PollFormModal extends BaseModal {
super.initialize()
}
onHide () {
super.onHide()
close () {
super.close()
api.modal.remove('livechat-converse-poll-form-modal')
}

View File

@ -2,9 +2,16 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { converseLocalizedHelpUrl } from '../../../shared/lib/help'
import { html } from 'lit'
import { __ } from 'i18n'
import { converse } from '@converse/headless'
const u = converse.env.utils
export function tplPollForm (el) {
const i18nOk = __('Ok')
// eslint-disable-next-line no-undef
@ -13,10 +20,18 @@ export function tplPollForm (el) {
page: 'documentation/user/streamers/polls'
})
let formFieldTemplates
if (el.xform) {
const fields = el.xform.fields
formFieldTemplates = fields.map(field => {
return u.xFormField2TemplateResult(field)
})
}
return html`
${el.alert_message ? html`<div class="error">${el.alert_message}</div>` : ''}
${
el.form_fields
formFieldTemplates
? html`
<form class="converse-form" @submit=${ev => el.formSubmit(ev)}>
<p class="title">
@ -30,9 +45,9 @@ export function tplPollForm (el) {
<p class="form-help instructions">${el.instructions}</p>
<div class="form-errors hidden"></div>
${el.form_fields}
${formFieldTemplates}
<fieldset class="buttons form-group">
<fieldset class="buttons">
<input type="submit" class="btn btn-primary" value="${i18nOk}" />
</fieldset>
</form>`

View File

@ -2,6 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
// FIXME: @stylistic/indent is buggy with strings literrals.
/* eslint-disable @stylistic/indent */
import { html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { __ } from 'i18n'
@ -63,7 +66,7 @@ function _tplChoice (el, currentPoll, choice, canVote) {
<div class="livechat-progress-bar">
<div
role="progressbar"
style="width: ${percent}%;"
style=${'width: ' + percent + '%;'}
aria-valuenow="${percent}" aria-valuemin="0" aria-valuemax="100"
></div>
<p>
@ -83,21 +86,21 @@ export function tplPoll (el, currentPoll, canVote) {
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')}">
? html`<button type="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">
<button type="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">
<button type="button" @click=${el.toggle} class="livechat-poll-toggle">
<converse-icon
color="var(--muc-toolbar-btn-color)"
class="fa fa-angle-down"

View File

@ -3,12 +3,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { XMLNS_POLL } from './constants.js'
import { _converse, api } from '../../../src/headless/core.js'
import { _converse, api } from '../../../src/headless/index.js'
import { __ } from 'i18n'
export function getHeadingButtons (view, buttons) {
const muc = view.model
if (muc.get('type') !== _converse.CHATROOMS_TYPE) {
if (muc.get('type') !== _converse.constants.CHATROOMS_TYPE) {
// only on MUC.
return buttons
}

View File

@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
import { _converse, converse, api } from '../../../src/headless/core.js'
import { _converse, converse, api } from '../../../src/headless/index.js'
let currentSize
/**
* This plugin computes the available width of converse-root, and adds classes
@ -16,6 +18,27 @@ converse.plugins.add('livechat-converse-size', {
dependencies: [],
initialize () {
Object.assign(api, {
livechat_size: {
current: () => {
return currentSize
},
width_is: (sizes) => {
if (!Array.isArray(sizes)) {
sizes = [sizes]
}
if (!currentSize) { return false }
return sizes.includes(currentSize.width)
},
height_is: (sizes) => {
if (!Array.isArray(sizes)) {
sizes = [sizes]
}
if (!currentSize) { return false }
return sizes.includes(currentSize.height)
}
}
})
_converse.api.listen.on('connected', start)
_converse.api.listen.on('reconnected', start)
_converse.api.listen.on('disconnected', stop)
@ -42,6 +65,7 @@ function start () {
}
function stop () {
currentSize = undefined
rootResizeObserver.disconnect()
const root = document.querySelector('converse-root')
if (root) {
@ -60,8 +84,9 @@ function handle (el) {
el.setAttribute('livechat-converse-root-width', width)
el.setAttribute('livechat-converse-root-height', height)
api.trigger('livechatSizeChanged', {
currentSize = {
height: height,
width: width
})
}
api.trigger('livechatSizeChanged', Object.assign({}, currentSize)) // cloning...
}

View File

@ -2,36 +2,20 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
import { api } from '@converse/headless/core'
import { CustomElement } from 'shared/components/element.js'
import { api } from '@converse/headless'
import { MUCApp } from '../../../shared/components/muc-app/index.js'
import { tplMUCTaskApp } from '../templates/muc-task-app.js'
import '../styles/muc-task-app.scss'
/**
* Custom Element to display the Task Application.
*/
export default class MUCTaskApp extends CustomElement {
static get properties () {
return {
model: { type: Object, attribute: true }, // mucModel
show: { type: Boolean, attribute: false }
}
}
async initialize () {
this.show = api.settings.get('livechat_task_app_restore') &&
(window.sessionStorage?.getItem?.('livechat-converse-task-app-show') === '1')
}
export default class MUCTaskApp extends MUCApp {
restoreSettingName = 'livechat_task_app_restore'
sessionStorageRestoreKey = 'livechat-converse-task-app-show'
render () {
return tplMUCTaskApp(this, this.model)
}
toggleApp () {
this.show = !this.show
window.sessionStorage?.setItem?.('livechat-converse-task-app-show', this.show ? '1' : '')
}
}
api.elements.define('livechat-converse-muc-task-app', MUCTaskApp)

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { CustomElement } from 'shared/components/element.js'
import { api } from '@converse/headless/core'
import { api } from '@converse/headless'
import tplMucTaskList from '../templates/muc-task-list'
import { __ } from 'i18n'

Some files were not shown because too many files have changed in this diff Show More