This commit is contained in:
matty 2024-09-04 17:36:59 -04:00
commit 4a91028a76
251 changed files with 30235 additions and 6952 deletions

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

@ -1,5 +1,42 @@
# Changelog
## 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

@ -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,4 +9,5 @@
@use "elements/index";
@use "video";
@use "configuration/configuration";
@use "admin/firewall/firewall";
@use "list-rooms/list-rooms.scss";

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 (max-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(50vh, 300px);
converse-muc {
min-height: max(50vh, 300px);
}
}
}

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
@ -133,3 +134,13 @@ 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

View File

@ -270,6 +270,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> {
@ -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: boolean = 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): { [key: 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,88 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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

@ -50,7 +50,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

@ -135,6 +135,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 +168,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 +255,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=${''}

View File

@ -6,6 +6,7 @@
// This content comes from the file assets/images/plus-square.svg, from the Feather icons set https://feathericons.com/
export const AddSVG: string =
`<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>
@ -15,6 +16,7 @@ export const AddSVG: string =
// This content comes from the file assets/images/x-square.svg, from the Feather icons set https://feathericons.com/
export const RemoveSVG: string =
`<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

@ -47,11 +47,11 @@ interface CellDataSchema {
minlength?: number
maxlength?: number
size?: number
label?: TemplateResult | string
options?: { [key: string]: string }
datalist?: DynamicTableAcceptedTypes[]
separator?: string
inputType?: DynamicTableAcceptedInputTypes
inputTitle?: string
default?: DynamicTableAcceptedTypes
colClassList?: string[] // CSS classes to add to the <td> element.
}
@ -64,7 +64,7 @@ interface DynamicTableRowData {
interface DynamicFormHeaderCellData {
colName: TemplateResult | DirectiveResult
description: TemplateResult | DirectiveResult
description?: TemplateResult | DirectiveResult
headerClassList?: string[]
}
@ -236,7 +236,7 @@ export class DynamicTableFormElement extends LivechatElement {
classList.push(...headerCellData.headerClassList)
}
return html`<th scope="col" class=${classList.join(' ')}>
${headerCellData.description}
${headerCellData.description ?? ''}
</th>`
}
@ -295,6 +295,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 +321,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -332,6 +334,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderTextarea(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -344,6 +347,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderSelect(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -356,6 +360,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderImageFileInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue?.toString(),
@ -376,6 +381,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
(propertyValue as Date).toISOString(),
@ -394,6 +400,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as string,
@ -411,6 +418,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderCheckbox(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue as boolean,
@ -446,6 +454,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
(propertyValue)?.join(propertySchema.separator ?? ',') ??
@ -461,6 +470,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderTextarea(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
(propertyValue)?.join(propertySchema.separator ?? ',') ??
@ -476,6 +486,7 @@ export class DynamicTableFormElement extends LivechatElement {
formElement = html`${this._renderTagsInput(rowId,
inputId,
inputName,
inputTitle,
propertyName,
propertySchema,
propertyValue,
@ -501,6 +512,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 +527,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 +547,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 +561,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 +577,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 +591,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 +604,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 +619,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 +629,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 +642,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 +658,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 +668,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}

View File

@ -1,11 +1,11 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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 +29,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 +47,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

@ -12,6 +12,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.
@ -48,7 +49,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[]
@ -166,7 +167,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}>`)}

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 {
@ -353,19 +352,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

@ -167,7 +167,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

@ -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-02: using Converse upstream (v11 WIP).
CONVERSE_COMMIT="9952046d580bc2930e29833f4c9987a3d4c95bc2"
# 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-11.0.1"
CONVERSE_COMMIT=""
rootdir="$(pwd)"
src_dir="$rootdir/conversejs"

View File

@ -34,6 +34,7 @@ declare global {
env: {
html: Function
sizzle: Function
dayjs: Function
}
}
initConversePlugins: typeof initConversePlugins
@ -218,20 +219,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,13 @@ 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')
_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,60 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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,32 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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,39 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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,39 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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,44 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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,130 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
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,92 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { __ } from 'i18n'
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

@ -4,7 +4,7 @@
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 +13,8 @@ class PollFormModal extends BaseModal {
super.initialize()
}
onHide () {
super.onHide()
close () {
super.close()
api.modal.remove('livechat-converse-poll-form-modal')
}

View File

@ -5,6 +5,10 @@
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 +17,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 +42,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

@ -63,7 +63,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 +83,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'

View File

@ -2,17 +2,14 @@
//
// 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 tplMucTaskLists from '../templates/muc-task-lists'
import { __ } from 'i18n'
import { DraggablesCustomElement } from '../../../shared/components/draggables/index.js'
import '../styles/muc-task-lists.scss'
import '../styles/muc-task-drag.scss'
export default class MUCTaskListsView extends CustomElement {
currentDraggedTask = null
export default class MUCTaskListsView extends DraggablesCustomElement {
static get properties () {
return {
model: { type: Object, attribute: true },
@ -27,42 +24,22 @@ export default class MUCTaskListsView extends CustomElement {
return
}
this.draggableTagName = 'livechat-converse-muc-task'
this.droppableTagNames = ['livechat-converse-muc-task', 'livechat-converse-muc-task-list']
this.droppableAlwaysBottomTagNames = ['livechat-converse-muc-task-list']
// Adding or removing a new task list: we must update.
this.listenTo(this.model, 'add', () => this.requestUpdate())
this.listenTo(this.model, 'remove', () => this.requestUpdate())
this.listenTo(this.model, 'sort', () => this.requestUpdate())
this._handleDragStartBinded = this._handleDragStart.bind(this)
this._handleDragOverBinded = this._handleDragOver.bind(this)
this._handleDragLeaveBinded = this._handleDragLeave.bind(this)
this._handleDragEndBinded = this._handleDragEnd.bind(this)
this._handleDropBinded = this._handleDrop.bind(this)
return super.initialize()
}
render () {
return tplMucTaskLists(this, this.model)
}
connectedCallback () {
super.connectedCallback()
this.currentDraggedTask = null
this.addEventListener('dragstart', this._handleDragStartBinded)
this.addEventListener('dragover', this._handleDragOverBinded)
this.addEventListener('dragleave', this._handleDragLeaveBinded)
this.addEventListener('dragend', this._handleDragEndBinded)
this.addEventListener('drop', this._handleDropBinded)
}
disconnectedCallback () {
super.disconnectedCallback()
this.currentDraggedTask = null
this.removeEventListener('dragstart', this._handleDragStartBinded)
this.removeEventListener('dragover', this._handleDragOverBinded)
this.removeEventListener('dragleave', this._handleDragLeaveBinded)
this.removeEventListener('dragend', this._handleDragEndBinded)
this.removeEventListener('drop', this._handleDropBinded)
}
async submitCreateTaskList (ev) {
ev.preventDefault()
@ -96,15 +73,7 @@ export default class MUCTaskListsView extends CustomElement {
}
}
_getParentTaskEl (target) {
return target.closest?.('livechat-converse-muc-task')
}
_getParentTaskOrTaskListEl (target) {
return target.closest?.('livechat-converse-muc-task, livechat-converse-muc-task-list')
}
_isATaskEl (target) {
isATaskEl (target) {
return target.nodeName?.toLowerCase() === 'livechat-converse-muc-task'
}
@ -112,71 +81,18 @@ export default class MUCTaskListsView extends CustomElement {
return target.nodeName?.toLowerCase() === 'livechat-converse-muc-task-list'
}
_isOnTopHalf (ev, taskEl) {
const y = ev.clientY
const bounding = taskEl.getBoundingClientRect()
return (y <= bounding.y + (bounding.height / 2))
}
_resetDropOver () {
document.querySelectorAll('.livechat-drag-bottom-half, .livechat-drag-top-half').forEach(
el => el.classList.remove('livechat-drag-bottom-half', 'livechat-drag-top-half')
)
}
_handleDragStart (ev) {
// The draggable=true is on a livechat-converse-muc-task child
const possibleTaskEl = ev.target.parentElement
if (!this._isATaskEl(possibleTaskEl)) { return }
console.log('[livechat task drag&drop] Starting to drag a task...')
this.currentDraggedTask = possibleTaskEl
this._resetDropOver()
}
_handleDragOver (ev) {
if (!this.currentDraggedTask) { return }
const taskOrTaskListEl = this._getParentTaskOrTaskListEl(ev.target)
if (!taskOrTaskListEl) { return }
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drop_event says we should preventDefault
ev.preventDefault()
// Are we on the top or bottom part of the taskEl?
// Note: for task list, we always add the task in the task list, so no need to test here.
const topHalf = this._isATaskEl(taskOrTaskListEl) ? this._isOnTopHalf(ev, taskOrTaskListEl) : false
taskOrTaskListEl.classList.add(topHalf ? 'livechat-drag-top-half' : 'livechat-drag-bottom-half')
taskOrTaskListEl.classList.remove(topHalf ? 'livechat-drag-bottom-half' : 'livechat-drag-top-half')
}
_handleDragLeave (ev) {
if (!this.currentDraggedTask) { return }
const taskOrTaskListEl = this._getParentTaskOrTaskListEl(ev.target)
if (!taskOrTaskListEl) { return }
taskOrTaskListEl.classList.remove('livechat-drag-bottom-half', 'livechat-drag-top-half')
}
_handleDragEnd (_ev) {
this.currentDraggedTask = null
this._resetDropOver()
}
_handleDrop (_ev) {
if (!this.currentDraggedTask) { return }
const droppedOnEl = document.querySelector('.livechat-drag-bottom-half, .livechat-drag-top-half')
const droppedOntaskOrTaskListEl = this._getParentTaskOrTaskListEl(droppedOnEl)
if (!droppedOntaskOrTaskListEl) { return }
_dropDone (draggedEl, droppedOnEl, onTopHalf) {
super._dropDone(...arguments)
console.log('[livechat task drag&drop] Task dropped...')
const task = this.currentDraggedTask.model
const task = draggedEl.model
let newOrder, targetTasklist
if (this.isATaskListEl(droppedOntaskOrTaskListEl)) {
if (this.isATaskListEl(droppedOnEl)) {
// We dropped on a task list, we must add as first entry.
newOrder = 0
targetTasklist = droppedOntaskOrTaskListEl.model
targetTasklist = droppedOnEl.model
if (task.get('list') !== targetTasklist.get('id')) {
console.log('[livechat task drag&drop] Changing task list...')
task.set('list', targetTasklist.get('id'))
@ -185,9 +101,9 @@ export default class MUCTaskListsView extends CustomElement {
console.log('[livechat task drag&drop] Task dropped on tasklist, but already first item, nothing to do')
return
}
} else if (this._isATaskEl(droppedOntaskOrTaskListEl)) {
} else if (this.isATaskEl(droppedOnEl)) {
// We dropped on a task, we must get its order (+1 if !onTopHalf)
const droppedOnTask = droppedOntaskOrTaskListEl.model
const droppedOnTask = droppedOnEl.model
if (task === droppedOnTask) {
// But of course, if dropped on itself there is nothing to do.
console.log('[livechat task drag&drop] Task dropped on itself, nothing to do')
@ -199,9 +115,8 @@ export default class MUCTaskListsView extends CustomElement {
task.set('list', droppedOnTask.get('list'))
}
const topHalf = droppedOnEl.classList.contains('livechat-drag-top-half')
newOrder = droppedOnTask.get('order') ?? 0
if (!topHalf) { newOrder = Math.max(0, newOrder + 1) }
if (!onTopHalf) { newOrder = Math.max(0, newOrder + 1) }
if (typeof newOrder !== 'number' || isNaN(newOrder)) {
console.error(
@ -217,45 +132,7 @@ export default class MUCTaskListsView extends CustomElement {
return
}
if (typeof newOrder !== 'number' || isNaN(newOrder)) {
console.error('[livechat task drag&drop] Computed new order is not a number, aborting.')
return
}
console.log('[livechat task drag&drop] Task new order will be ' + newOrder)
console.log('[livechat task drag&drop] Reordering tasks...')
let currentOrder = newOrder + 1
for (const t of targetTasklist.getTasks()) {
if (t === task) {
console.log('[livechat task drag&drop] Skipping the currently moved task')
continue
}
let order = t.get('order') ?? 0
if (typeof order !== 'number' || isNaN(order)) {
console.error('[livechat task drag&drop] Found a task with an invalid order, fixing it.')
order = currentOrder // this will cause the code bellow to increment task order
}
if (order < newOrder) { continue }
currentOrder++
if (order > currentOrder) {
console.log(
`Task "${t.get('name')}" as already on order greater than ${currentOrder.toString()}, stoping.`
)
break
}
console.log(`Changing order of task "${t.get('name')}" to ${currentOrder}`)
t.set('order', currentOrder)
t.saveItem() // TODO: handle errors?
}
console.log('[livechat task drag&drop] Setting new order on the moved task')
task.set('order', newOrder)
task.saveItem() // TODO: handle errors?
this._resetDropOver()
this._saveOrders(targetTasklist.getTasks(), task, newOrder)
}
}

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 { tplMucTask } from '../templates/muc-task'
import { __ } from 'i18n'

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 { ChatRoomTaskLists } from './task-lists.js'
import { ChatRoomTaskList } from './task-list.js'
import { ChatRoomTasks } from './tasks.js'
@ -18,9 +18,14 @@ converse.plugins.add('livechat-converse-tasks', {
dependencies: ['converse-muc', 'converse-disco', 'converse-pubsub'],
initialize () {
_converse.ChatRoomTaskLists = ChatRoomTaskLists
_converse.ChatRoomTaskList = ChatRoomTaskList
_converse.ChatRoomTasks = ChatRoomTasks
Object.assign(
_converse.exports,
{
ChatRoomTaskLists,
ChatRoomTaskList,
ChatRoomTasks
}
)
_converse.api.settings.extend({
livechat_task_app_enabled: false,

View File

@ -4,7 +4,7 @@
import BaseModal from 'plugins/modal/modal.js'
import tplPickTaskList from './templates/pick-task-list.js'
import { api } from '@converse/headless/core'
import { api } from '@converse/headless'
import { __ } from 'i18n'
export default class PickTaskListModal extends BaseModal {

View File

@ -19,22 +19,22 @@ export default function (el) {
return html`
<form class="converse-form converse-form--modal confirm" action="#" @submit=${ev => el.onPick(ev)}>
<div class="form-group">
<select class="form-control" name="tasklist">
${
repeat(muc.tasklists, (tasklist) => tasklist.get('id'), (tasklist) => {
return html`<option value="${tasklist.get('id')}">${tasklist.get('name')}</option>`
})
}
</select>
<small class="form-text text-muted">
${i18nMessage}
</small>
</div>
<fieldset>
<select class="form-control" name="tasklist">
${
repeat(muc.tasklists, (tasklist) => tasklist.get('id'), (tasklist) => {
return html`<option value="${tasklist.get('id')}">${tasklist.get('name')}</option>`
})
}
</select>
<small class="form-text text-muted">
${i18nMessage}
</small>
</fieldset>
<div class="form-group">
<button type="submit" class="btn btn-primary">${__('OK')}</button>
<input type="button" class="btn btn-secondary" data-dismiss="modal" value="${__('Cancel')}"/>
</div>
<fieldset>
<button type="submit" class="btn btn-primary">${__('OK')}</button>
<input type="button" class="btn btn-secondary" data-dismiss="modal" value="${__('Cancel')}"/>
</fieldset>
</form>`
}

View File

@ -1,27 +0,0 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
livechat-converse-muc-task {
&.livechat-drag-bottom-half .task-line {
border-bottom: 4px solid blue;
}
&.livechat-drag-top-half .task-line {
border-top: 4px solid blue;
}
}
livechat-converse-muc-task-list {
&.livechat-drag-bottom-half .task-list-line {
border-bottom: 4px solid blue;
}
&.livechat-drag-top-half .task-list-line {
border-top: 4px solid blue;
}
}
}

View File

@ -7,7 +7,7 @@ import { Model } from '@converse/skeletor/src/model.js'
/**
* A chat room task list.
* @class
* @namespace _converse.ChatRoomTaskList
* @namespace _converse.exports.ChatRoomTaskList
* @memberof _converse
*/
class ChatRoomTaskList extends Model {
@ -40,7 +40,7 @@ class ChatRoomTaskList extends Model {
data.list = this.get('id')
if (!data.order) {
data.order = 0 + Math.max(
data.order = 1 + Math.max(
0,
...(this.getTasks().map(t => t.get('order') ?? 0).filter(o => !isNaN(o)))
)

View File

@ -7,9 +7,9 @@ import { ChatRoomTaskList } from './task-list'
import { initStorage } from '@converse/headless/utils/storage.js'
/**
* A list of {@link _converse.ChatRoomTaskList} instances, representing task lists associated to a MUC.
* A list of {@link _converse.exports.ChatRoomTaskList} instances, representing task lists associated to a MUC.
* @class
* @namespace _converse.ChatRoomTaskLists
* @namespace _converse.exports.ChatRoomTaskLists
* @memberOf _converse
*/
class ChatRoomTaskLists extends Collection {

View File

@ -7,7 +7,7 @@ import { Model } from '@converse/skeletor/src/model.js'
/**
* A chat room task.
* @class
* @namespace _converse.ChatRoomTask
* @namespace _converse.exports.ChatRoomTask
* @memberof _converse
*/
class ChatRoomTask extends Model {

View File

@ -7,9 +7,9 @@ import { ChatRoomTask } from './task'
import { initStorage } from '@converse/headless/utils/storage.js'
/**
* A list of {@link _converse.ChatRoomTask} instances, representing all tasks associated to a MUC.
* A list of {@link _converse.exports.ChatRoomTask} instances, representing all tasks associated to a MUC.
* @class
* @namespace _converse.ChatRoomTasks
* @namespace _converse.exports.ChatRoomTasks
* @memberOf _converse
*/
class ChatRoomTasks extends Collection {

View File

@ -3,28 +3,24 @@
// SPDX-License-Identifier: AGPL-3.0-only
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 tplMUCTaskApp (el, mucModel) {
if (!mucModel) {
// should not happen
el.classList.add('hidden') // we must do this, otherwise will have CSS side effects
return html``
}
if (!mucModel.tasklists) {
// too soon, not initialized yet (this will happen)
el.classList.add('hidden') // we must do this, otherwise will have CSS side effects
return html``
}
if (!el.show) {
el.classList.add('hidden')
return html``
}
el.classList.remove('hidden')
// eslint-disable-next-line no-undef
const i18nTasks = __(LOC_tasks)
// eslint-disable-next-line no-undef
@ -33,19 +29,11 @@ export function tplMUCTaskApp (el, mucModel) {
page: 'documentation/user/streamers/tasks'
})
return html`
<div class="livechat-converse-muc-app-header">
<h5>${i18nTasks}</h5>
<a href="${helpUrl}" target="_blank"><converse-icon
class="fa fa-circle-question"
size="1em"
title="${i18nHelp}"
></converse-icon></a>
<button class="livechat-converse-muc-app-close" @click=${el.toggleApp} title="${__('Close')}">
<converse-icon class="fa fa-times" size="1em"></converse-icon>
</button>
</div>
<div class="livechat-converse-muc-app-body">
<livechat-converse-muc-task-lists .model=${mucModel.tasklists}></livechat-converse-muc-task-lists>
</div>`
return tplMUCApp(
el,
i18nTasks,
helpUrl,
i18nHelp,
html`<livechat-converse-muc-task-lists .model=${mucModel.tasklists}></livechat-converse-muc-task-lists>`
)
}

View File

@ -16,17 +16,17 @@ export default function tplMucTaskList (el, tasklist) {
// eslint-disable-next-line no-undef
const i18nTaskListName = __(LOC_task_list_name)
return html`
<div class="task-list-line">
<div class="task-list-line draggables-line">
${el.collapsed
? html`
<button @click=${el.toggleTasks} class="task-list-toggle-tasks">
<button type="button" @click=${el.toggleTasks} class="task-list-toggle-tasks">
<converse-icon
color="var(--muc-toolbar-btn-color)"
class="fa fa-angle-right"
size="1em"></converse-icon>
</button>`
: html`
<button @click=${el.toggleTasks} class="task-list-toggle-tasks">
<button type="button" @click=${el.toggleTasks} class="task-list-toggle-tasks">
<converse-icon
color="var(--muc-toolbar-btn-color)"
class="fa fa-angle-down"
@ -38,15 +38,15 @@ export default function tplMucTaskList (el, tasklist) {
<div class="task-list-name">
<a @click=${el.toggleTasks}>${tasklist.get('name')}</a>
</div>
<button class="task-list-action" title="${i18nCreateTask}" @click=${el.openAddTaskForm}>
<button type="button" class="task-list-action" title="${i18nCreateTask}" @click=${el.openAddTaskForm}>
<converse-icon class="fa fa-plus" size="1em"></converse-icon>
</button>
<button class="task-list-action" title="${__('Edit')}"
<button type="button" class="task-list-action" title="${__('Edit')}"
@click=${el.toggleEdit}
>
<converse-icon class="fa fa-edit" size="1em"></converse-icon>
</button>
<button class="task-list-action" title="${i18nDelete}"
<button type="button" class="task-list-action" title="${i18nDelete}"
@click=${el.deleteTaskList}
>
<converse-icon class="fa fa-trash-alt" size="1em"></converse-icon>

View File

@ -24,7 +24,7 @@ export default function tplMucTaskLists (el, tasklists) {
})
}
<form class="converse-form" @submit=${el.submitCreateTaskList}>
<div class="form-group">
<fieldset>
<label>
${i18nCreateTaskList}
<input type="text" value="" class="form-control" name="name" placeholder="${i18nTaskListName}" />
@ -34,6 +34,6 @@ export default function tplMucTaskLists (el, tasklists) {
? ''
: html`<div class="invalid-feedback d-block">${el.create_tasklist_error_message}</div>`
}
</div>
</fieldset>
</form>`
}

View File

@ -13,7 +13,7 @@ export function tplMucTask (el, task) {
const doneId = 'livechat-task-done-id-' + task.get('id')
return !el.edit
? html`
<div draggable="true" class="task-line" ?task-is-done=${done}>
<div draggable="true" class="task-line draggables-line" ?task-is-done=${done}>
<div class="form-check">
<input
id="${doneId}"
@ -30,22 +30,22 @@ export function tplMucTask (el, task) {
</label>
</div>
<div class="task-description">${task.get('description') ?? ''}</div>
<button class="task-action" title="${__('Edit')}"
<button type="button" class="task-action" title="${__('Edit')}"
@click=${el.toggleEdit}
>
<converse-icon class="fa fa-edit" size="1em"></converse-icon>
</button>
<button class="task-action" title="${i18nDelete}"
<button type="button" class="task-action" title="${i18nDelete}"
@click=${el.deleteTask}
>
<converse-icon class="fa fa-trash-alt" size="1em"></converse-icon>
</button>
</div>`
: html`
<div class="task-line">
<div class="task-line draggables-line">
<form class="converse-form" @submit=${el.saveTask}>
${_tplTaskForm(task)}
<fieldset class="form-group">
<fieldset>
<input type="submit" class="btn btn-primary" value="${__('Ok')}" />
<input type="button" class="btn btn-secondary button-cancel"
value="${__('Cancel')}" @click=${el.toggleEdit}
@ -61,7 +61,7 @@ function _tplTaskForm (task) {
// eslint-disable-next-line no-undef
const i18nTaskDesc = __(LOC_task_description)
return html`<fieldset class="form-group">
return html`<fieldset>
<input type="text" name="name"
class="form-control" value="${task ? task.get('name') : ''}"
placeholder="${i18nTaskName}"
@ -80,7 +80,7 @@ export function tplMucAddTaskForm (tasklistEl, _tasklist) {
return html`
<form class="task-list-add-task converse-form" @submit=${tasklistEl.submitAddTask}>
${_tplTaskForm(undefined)}
<fieldset class="form-group">
<fieldset>
<input type="submit" class="btn btn-primary" value="${i18nOk}" />
<input type="button" class="btn btn-secondary button-cancel"
value="${i18nCancel}" @click=${tasklistEl.closeAddTaskForm}

View File

@ -4,12 +4,12 @@
import { XMLNS_TASKLIST, XMLNS_TASK } from './constants.js'
import { PubSubManager } from '../../shared/lib/pubsub-manager.js'
import { converse, _converse, api } from '../../../src/headless/core.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.CHATROOMS_TYPE) {
if (muc.get('type') !== _converse.constants.CHATROOMS_TYPE) {
// only on MUC.
return buttons
}
@ -74,8 +74,8 @@ function _initChatRoomTaskLists (mucModel) {
return
}
mucModel.tasklists = new _converse.ChatRoomTaskLists(undefined, { chatroom: mucModel })
mucModel.tasks = new _converse.ChatRoomTasks(undefined, { chatroom: mucModel })
mucModel.tasklists = new _converse.exports.ChatRoomTaskLists(undefined, { chatroom: mucModel })
mucModel.tasks = new _converse.exports.ChatRoomTasks(undefined, { chatroom: mucModel })
mucModel.taskManager = new PubSubManager(
mucModel.get('jid'),
@ -127,7 +127,7 @@ function _destroyChatRoomTaskLists (mucModel) {
}
export function initOrDestroyChatRoomTaskLists (mucModel) {
if (mucModel.get('type') !== _converse.CHATROOMS_TYPE) {
if (mucModel.get('type') !== _converse.constants.CHATROOMS_TYPE) {
// only on MUC.
return _destroyChatRoomTaskLists(mucModel)
}

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 { html } from 'lit'
import { __ } from 'i18n'

View File

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
import { converse, api } from '../../../src/headless/core.js'
import { converse, api } from '../../../src/headless/index.js'
import './components/muc-terms.js'
const { sizzle } = converse.env

View File

@ -0,0 +1,193 @@
// 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 './styles/draggables.scss'
/**
* This is the base class for custom elements that contains draggable items.
*/
export class DraggablesCustomElement extends CustomElement {
currentDragged = null
/**
* The tag name for draggable elements.
* Example: livechat-converse-muc-note.
* Must be set in derived class.
*/
draggableTagName = 'invalid-tag-name'
/**
* The tag names on which we can drop the element.
* Examples: livechat-converse-muc-note, livechat-converse-muc-task, livechat-converse-muc-task-list.
* Must be set in derived class.
*/
droppableTagNames = []
/**
* Tag names for which we will always drop to bottom (for example: task lists)
*/
droppableAlwaysBottomTagNames = []
initialize () {
this._handleDragStartBinded = this._handleDragStart.bind(this)
this._handleDragOverBinded = this._handleDragOver.bind(this)
this._handleDragLeaveBinded = this._handleDragLeave.bind(this)
this._handleDragEndBinded = this._handleDragEnd.bind(this)
this._handleDropBinded = this._handleDrop.bind(this)
return super.initialize()
}
connectedCallback () {
super.connectedCallback()
this.currentDragged = null
this.addEventListener('dragstart', this._handleDragStartBinded)
this.addEventListener('dragover', this._handleDragOverBinded)
this.addEventListener('dragleave', this._handleDragLeaveBinded)
this.addEventListener('dragend', this._handleDragEndBinded)
this.addEventListener('drop', this._handleDropBinded)
}
disconnectedCallback () {
super.disconnectedCallback()
this.currentDragged = null
this.removeEventListener('dragstart', this._handleDragStartBinded)
this.removeEventListener('dragover', this._handleDragOverBinded)
this.removeEventListener('dragleave', this._handleDragLeaveBinded)
this.removeEventListener('dragend', this._handleDragEndBinded)
this.removeEventListener('drop', this._handleDropBinded)
}
_isADraggableEl (target) {
return target.nodeName?.toLowerCase() === this.draggableTagName
}
_getParentDroppableEl (target) {
return target.closest?.(this.droppableTagNames.join(','))
}
_isOnTopHalf (ev, el) {
const y = ev.clientY
const bounding = el.getBoundingClientRect()
return (y <= bounding.y + (bounding.height / 2))
}
_resetDropOver () {
document.querySelectorAll('.livechat-drag-bottom-half, .livechat-drag-top-half').forEach(
el => el.classList.remove('livechat-drag-bottom-half', 'livechat-drag-top-half')
)
}
_handleDragStart (ev) {
// The draggable=true is on a child bode
const possibleEl = ev.target.parentElement
if (!this._isADraggableEl(possibleEl)) { return }
console.log('[livechat drag&drop] Starting to drag a ' + this.draggableTagName + '...')
this.currentDragged = possibleEl
this._resetDropOver()
}
_handleDragOver (ev) {
if (!this.currentDragged) { return }
const droppableEl = this._getParentDroppableEl(ev.target)
if (!droppableEl) { return }
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drop_event says we should preventDefault
ev.preventDefault()
// Are we on the top or bottom part of the droppableEl?
let topHalf = false
if (!this.droppableAlwaysBottomTagNames.includes(droppableEl.nodeName.toLowerCase())) {
topHalf = this._isOnTopHalf(ev, droppableEl)
}
droppableEl.classList.add(topHalf ? 'livechat-drag-top-half' : 'livechat-drag-bottom-half')
droppableEl.classList.remove(topHalf ? 'livechat-drag-bottom-half' : 'livechat-drag-top-half')
}
_handleDragLeave (ev) {
if (!this.currentDragged) { return }
const el = this._getParentDroppableEl(ev.target)
if (!el) { return }
el.classList.remove('livechat-drag-bottom-half', 'livechat-drag-top-half')
}
_handleDragEnd (_ev) {
this.currentDragged = null
this._resetDropOver()
}
_handleDrop (_ev) {
if (!this.currentDragged) { return }
let droppedOnEl = document.querySelector('.livechat-drag-bottom-half, .livechat-drag-top-half')
droppedOnEl = this._getParentDroppableEl(droppedOnEl)
if (!droppedOnEl) { return }
console.log('[livechat drag&drop] ' + this.draggableTagName + ' dropped...')
try {
this._dropDone(this.currentDragged, droppedOnEl, droppedOnEl.classList.contains('livechat-drag-top-half'))
} catch (err) {
console.error(err)
}
this._resetDropOver()
}
/**
* The callback when a valid drop occurs.
* Must be overloaded.
*/
_dropDone (draggedEl, droppedOnEl, onTopHalf) {
console.debug('[livechat drag&drop] Drop done:', draggedEl, droppedOnEl, onTopHalf)
}
/**
* This method can be called from _dropDone to save the new objects orders.
* For it to work, models must respect following constraints:
* * be a Model
* * have the order attribute
* * have an id attribute (for logging)
* * have get, set and saveItem methods
*/
_saveOrders (models, currentModel, newOrder) {
if (typeof newOrder !== 'number' || isNaN(newOrder)) {
console.error('[livechat drag&drop] Computed new order is not a number, aborting.')
return
}
console.log('[livechat drag&drop] Reordering models... Model new order will be ' + newOrder)
let currentOrder = newOrder + 1
for (const m of models) {
if (m === currentModel) {
console.log('[livechat drag&drop] Skipping the currently moved model')
continue
}
let order = m.get('order') ?? 0
if (typeof order !== 'number' || isNaN(order)) {
console.error('[livechat drag&drop] Found a model with an invalid order, fixing it.')
order = currentOrder // this will cause the code bellow to increment model order
}
if (order < newOrder) { continue }
currentOrder++
if (order > currentOrder) {
console.log(
`Object "${m.get('id')}" as already on order greater than ${currentOrder.toString()}, stoping.`
)
break
}
console.log(`Changing order of model "${m.get('id')}" to ${currentOrder}`)
m.set('order', currentOrder)
m.saveItem() // TODO: handle errors?
}
console.log('[livechat drag&drop] Setting new order on the moved model')
currentModel.set('order', newOrder)
currentModel.saveItem() // TODO: handle errors?
}
}

View File

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
// FIXME: the use of ">" only works if the draggables-lines is a direct
// child of the element.
// We should find a better way to do this (and that will not break for nested
// elements, like task in tast-list).
.livechat-drag-bottom-half > .draggables-line {
border-bottom: 4px solid blue;
}
.livechat-drag-top-half > .draggables-line {
border-top: 4px solid blue;
}
}

View File

@ -4,7 +4,7 @@
/* eslint-disable max-len */
import { html } from 'lit'
import tplIcons from '../../../src/shared/templates/icons.js'
import tplIcons from '../../../src/shared/components/templates/icons.js'
export default () => {
// Here we are adding some additonal icons to ConverseJS defaults
@ -28,6 +28,16 @@ export default () => {
<symbol id="icon-square-poll-horizontal" viewBox="0 0 448 512">
<path d="M448 96c0-35.3-28.7-64-64-64L64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-320zM256 160c0 17.7-14.3 32-32 32l-96 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32zm64 64c17.7 0 32 14.3 32 32s-14.3 32-32 32l-192 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l192 0zM192 352c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32z"/>
</symbol>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<symbol id="icon-note-sticky" viewBox="0 0 448 512">
<path d="M64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l224 0 0-80c0-17.7 14.3-32 32-32l80 0 0-224c0-8.8-7.2-16-16-16L64 80zM288 480L64 480c-35.3 0-64-28.7-64-64L0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 224 0 5.5c0 17-6.7 33.3-18.7 45.3l-90.5 90.5c-12 12-28.3 18.7-45.3 18.7l-5.5 0z"/>
</symbol>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<symbol id="icon-magnifying-glass" viewBox="0 0 512 512">
<path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/>
</symbol>
</svg>
`
}

View File

@ -0,0 +1,95 @@
// 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, _converse } from '@converse/headless'
import './styles/muc-app.scss'
/**
* Base class for MUC App custom elements (task app, notes app, ...).
* This is an abstract class, should not be called directly.
*/
export class MUCApp extends CustomElement {
restoreSettingName = undefined // must be overloaded
sessionStorageRestoreKey = undefined // must be overloaded
static get properties () {
return {
model: { type: Object, attribute: true }, // mucModel
show: { type: Boolean, attribute: false }
}
}
async initialize () {
this.classList.add('livechat-converse-muc-app')
this.show = this.restoreSettingName &&
api.settings.get(this.restoreSettingName) &&
this.sessionStorageRestoreKey &&
(window.sessionStorage?.getItem?.(this.sessionStorageRestoreKey) === '1')
// we listen for livechatSizeChanged event,
// and close all apps except the first if small or medium width.
// Note: this will also be triggered when we first open the page
this.listenTo(_converse, 'livechatSizeChanged', () => {
if (!this.show || !api.livechat_size?.width_is(['small', 'medium'])) {
return
}
// are we the first opened app?
for (const el of document.querySelectorAll('.livechat-converse-muc-app')) {
if (el === this) { break }
if (!el.show) { continue }
console.debug('The livechat size is small or medium, there is already an opened app, so closing myself', this)
// ok, there is already an opened app.
this.toggleApp() // we know we are open
break
}
})
}
render () { // must be overloaded.
return ''
}
updated () {
if (this.innerText.trim() === '') {
this.classList.add('hidden') // we must do this, otherwise will have CSS side effects
} else {
this.classList.remove('hidden')
}
super.updated()
}
toggleApp () {
this.show = !this.show
if (this.sessionStorageRestoreKey) {
window.sessionStorage?.setItem?.(this.sessionStorageRestoreKey, this.show ? '1' : '')
}
if (
this.show &&
api.livechat_size?.width_is(['small', 'medium'])
) {
// When showing an App, if the screen width is small or medium, we hide the others.
this._closeOtherApps()
}
}
showApp () {
if (!this.show) { return this.toggleApp() }
}
hideApp () {
if (this.show) { return this.toggleApp() }
}
_closeOtherApps () {
document.querySelectorAll('.livechat-converse-muc-app').forEach((el) => {
if (el !== this && el.show) {
console.debug('Closing another app, because livechat width is small or medium', el)
el.toggleApp()
}
})
}
}

View File

@ -5,7 +5,7 @@
*/
.conversejs {
livechat-converse-muc-task-app {
.livechat-converse-muc-app {
border: var(--occupants-border-left);
display: flex;
flex-flow: column nowrap;
@ -42,8 +42,8 @@
&[livechat-converse-root-width="small"],
&[livechat-converse-root-width="medium"] {
converse-muc-chatarea livechat-converse-muc-task-app:not(.hidden) ~ * {
// on small and medium width, we hide all subsequent siblings of the task app
converse-muc-chatarea .livechat-converse-muc-app:not(.hidden) ~ * {
// on small and medium width, we hide all subsequent siblings of the app
// (when app is not hidden)
display: none !important;
}

View File

@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { html } from 'lit'
import { __ } from 'i18n'
export function tplMUCApp (el, i18nTitle, helpUrl, i18nHelp, content) {
return html`
<div class="livechat-converse-muc-app-header">
<h5>${i18nTitle}</h5>
<a href="${helpUrl}" target="_blank"><converse-icon
class="fa fa-circle-question"
size="1em"
title="${i18nHelp}"
></converse-icon></a>
<button type="button" class="livechat-converse-muc-app-close" @click=${el.toggleApp} title="${__('Close')}">
<converse-icon class="fa fa-times" size="1em"></converse-icon>
</button>
</div>
<div class="livechat-converse-muc-app-body">
${content}
</div>`
}

View File

@ -2,7 +2,7 @@
//
// 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'
const { $build, Strophe, $iq, sizzle } = converse.env
/**
@ -50,7 +50,7 @@ export class PubSubManager {
async start () {
// FIXME: handle errors. Find a way to display to user that this failed.
this.stanzaHandler = _converse.connection.addHandler(
this.stanzaHandler = api.connection.get().addHandler(
(message) => {
try {
this._handleMessage(message)
@ -79,7 +79,7 @@ export class PubSubManager {
// Note: no need to unsubscribe from the pubsub node, the backend will do when users leave the room.
if (this.stanzaHandler) {
_converse.connection.deleteHandler(this.stanzaHandler)
api.connection.get().deleteHandler(this.stanzaHandler)
this.stanzaHandler = undefined
}
}
@ -123,6 +123,7 @@ export class PubSubManager {
if (v === undefined) { continue }
data[field] = v
}
this._additionalModelToData(item, data)
console.log('Saving item...')
await this._save(type, data, id)
@ -178,6 +179,8 @@ export class PubSubManager {
item.c(fieldName).t(data[fieldName]).up()
}
this._additionalDataToItemNode(data, item)
await api.pubsub.publish(this.roomJID, this.node, item)
}
@ -336,6 +339,7 @@ export class PubSubManager {
}
}
}
this._additionalParseItemNode(itemNode, type, data)
return data
}
@ -351,4 +355,19 @@ export class PubSubManager {
_typeFromCollection (collection) {
return Object.values(this.types).find(type => type.collection === collection)
}
/**
* Overload to add some custom code for model to data conversion.
*/
_additionalModelToData (_item, _data) {}
/**
* Overload to add some custom code for data to stanza conversion.
*/
_additionalDataToItemNode (_data, _item) {}
/**
* Overload to add some custom code item parsing.
*/
_additionalParseItemNode (_itemNode, _type, _data) {}
}

View File

@ -4,7 +4,7 @@
import { __ } from 'i18n'
import BaseModal from 'plugins/modal/modal.js'
import { api } from '@converse/headless/core'
import { api } from '@converse/headless'
import { html } from 'lit'
import 'livechat-external-login-content.js'
@ -20,8 +20,8 @@ class ExternalLoginModal extends BaseModal {
return __(LOC_login_using_external_account)
}
onHide () {
super.onHide()
close () {
super.close()
// kill the externalAuthGetResult handler if still there
try {
if (window.externalAuthGetResult) { window.externalAuthGetResult() }

View File

@ -8,7 +8,7 @@
.dropdown-menu {
// Fixing all dropdown colors
--text-color: #212529; // default bootstrap color for dropdown-items
--text-color-lighten-15-percent: #8c8c8c; // default ConverseJS theme color
--inverse-link-color: #8c8c8c; // default ConverseJS theme color
background-color: #fff; // this is the default bootstrap color, used by ConverseJS
@ -27,6 +27,7 @@
border: 1px dashed var(--peertube-menu-background);
color: var(--peertube-main-foreground);
background-color: var(--peertube-main-background);
margin: 0 5px;
.livechat-hide-slow-mode-info-box {
cursor: pointer;

View File

@ -34,12 +34,16 @@ body.converse-fullscreen.theme-peertube,
body.converse-embedded converse-root.theme-peertube {
--foreground: var(--peertube-main-foreground);
--background: var(--peertube-main-background);
--badge-color: var(--background);
--button-hover-text-color: var(--background);
--subdued-color: #a8aba1;
--muc-color: var(--peertube-button-background);
--green: #3aa569; // only in this file
--redder-orange: #e77051; // only in this file
--orange: #e7a151; // only in this file
--light-blue: #578ea9; // only in this file
--lighter-blue: #85b47b; // only in this file
--chat-color: var(--green); // FIXME: copied from Converse. Is there side effects?
--chat-status-online: var(--green);
--chat-status-busy: var(--redder-orange);
--chat-status-away: var(--orange);
@ -55,7 +59,6 @@ body.converse-embedded converse-root.theme-peertube {
--text-shadow-color: var(--peertube-main-background); // FIXME: should be a little different from background
--text-color: var(--peertube-input-foreground);
--controlbox-text-color: var(--peertube-input-foreground); // Note: controlbox is not used
--text-color-lighten-15-percent: var(--peertube-input-foreground);
--message-text-color: var(--peertube-input-foreground);
--message-receipt-color: var(--green);
--save-button-color: var(--green);
@ -73,7 +76,6 @@ body.converse-embedded converse-root.theme-peertube {
--chat-correcting-color: var(--peertube-grey-background);
--chat-head-color-dark: #1e9652; // should not be used in this plugin
--chat-head-color-darker: #0e763b; // should not be used in this plugin
--chat-head-color-lighten-50-percent: #e7f7ee; // should not be used in this plugin
--chat-head-color: var(--green);
--chat-head-text-color: var(--peertube-input-foreground);
--chat-toolbar-btn-color: var(--peertube-button-background);
@ -106,7 +108,6 @@ body.converse-embedded converse-root.theme-peertube {
--controlbox-pane-background-color: #333;
--controlbox-pane-bg-hover-color: #464646;
--panel-divider-color: #333;
--chat-gutter: 0.5em;
--minimized-chats-width: 130px;
--mobile-chat-width: 100%;
--mobile-chat-height: 400px;
@ -119,9 +120,10 @@ body.converse-embedded converse-root.theme-peertube {
--chatroom-badge-color: var(--peertube-button-background);
--chatroom-badge-hover-color: var(--peertube-button-background);
--chatroom-correcting-color: var(--peertube-grey-background);
--chatroom-head-bg-color-dark: #d24e2b;
--chatroom-head-bg-color-dark: var(--peertube-button-background);
--chatroom-head-bg-color: var(--peertube-menu-background);
--chatroom-head-border-bottom: 1px solid var(--peertube-grey-foreground);
--chatroom-head-border-bottom: 0.15em solid var(--peertube-grey-foreground);
--chatroom-head-fg-color: var(--subdued-color);
--chatroom-head-button-color: #999;
--chatroom-head-color: var(--peertube-menu-foreground);
--chatroom-head-description-border-left: 1px solid #ddd;
@ -163,6 +165,7 @@ body.converse-embedded converse-root.theme-peertube {
--fullpage-chat-width: 100%;
--fullpage-emoji-picker-height: 300px;
--fullpage-max-chat-textarea-height: 15em;
--overlayed-chat-gutter: 1em;
--overlayed-chat-head-height: 55px;
--overlayed-chat-height: 450px;
--overlayed-chat-width: 300px;

View File

@ -60,7 +60,7 @@ body.livechat-readonly.livechat-noscroll {
}
}
// Viewer mode
// Viewer mode (before the user has chosen its nickname)
.livechat-viewer-mode-content {
display: none;
@ -73,7 +73,7 @@ body.livechat-readonly.livechat-noscroll {
gap: 0.5em 10px;
align-items: baseline;
.form-group,
fieldset,
label {
margin-bottom: 0 !important; // replaced by the gap on .livechat-viewer-mode-content
}
@ -171,7 +171,8 @@ body.converse-embedded {
#peertube-plugin-livechat-container {
converse-muc-message-form {
// For an unknown reason, message field in truncated... so adding a bottom margin.
margin-bottom: 6px;
// We also add left and right margin, as Converse v11 adds a g-0 class on converse-muc-chatarea
margin: 0 1px 6px 5px;
}
}
@ -187,4 +188,44 @@ body.converse-embedded {
// So we must revert appearance:
appearance: revert !important;
}
.toolbar-buttons {
// Converse v11 removed the toggle_occupant button on the right.
// To add it back, we must ensure that this toolbar takes all the width, and
// that the toggle-occupants button is on the right.
flex-grow: 2;
.toggle-occupants {
// Cancelling the flex-grow from btn-group
flex-grow: 0 !important;
// This margin-left trick is to align the button on the right.
margin-left: auto !important;
order: 99;
}
}
}
/* stylelint-disable-next-line no-descending-specificity */
#conversejs { // here we use the id have gretter priority
// These CSS are tricks: Converse v11 tries to hide the MUC when screen width is under 768px.
// We don't want that, so we cancel the d-none.
// FIXME: these hacks should be temporary, waiting for some improvement on Converse.
converse-muc-chatarea {
.chat-area.d-none {
display: flex !important;
}
/* stylelint-disable-next-line no-descending-specificity */
converse-muc-sidebar {
// we must not use !important for flex, it would break resizing.
// That's why we use #conversejs insteand of .conversejs for this block.
flex: 0 0 min(400px, 50%);
min-width: min(200px, 50%) !important;
.occupants {
width: 100%;
}
}
}
}

View File

@ -5,7 +5,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { html } from 'lit'
import { api } from '@converse/headless/core.js'
import { api } from '@converse/headless/index.js'
export default () => html`
<div class="inner-content converse-brand row">

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