Changing defaults MUC affiliation (#385):

* For Peertube moderators/admins, we add a button "Promote". Clicking on it will promote them as MUC owner.
This commit is contained in:
John Livingston 2024-05-17 15:17:36 +02:00
parent 5745e8c8a3
commit da75765bdb
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
12 changed files with 324 additions and 9 deletions

View File

@ -8,7 +8,6 @@ TODO: tag custom ConverseJS, and update build-conversejs.sh.
* #177: streamer's task/to-do lists: streamers, and their room's moderators, can handle task lists directly. This can be used to handle viewers questions, moderation actions, ... More info in the [tasks documentation](https://livingston.frama.io/peertube-plugin-livechat/fr/documentation/user/streamers/tasks/). * #177: streamer's task/to-do lists: streamers, and their room's moderators, can handle task lists directly. This can be used to handle viewers questions, moderation actions, ... More info in the [tasks documentation](https://livingston.frama.io/peertube-plugin-livechat/fr/documentation/user/streamers/tasks/).
* #385: new way of managing chat access rights. Now streamers are owner of their chat rooms. Peertube admins/moderators are not by default, so that their identities are not leaking. But they have a button to promote as chat room owner, if they need to take action. Please note that there is a migration script that will remove all Peertube admins/moderators affiliations (unless they are video/channel's owner). They can get this access back using the button. * #385: new way of managing chat access rights. Now streamers are owner of their chat rooms. Peertube admins/moderators are not by default, so that their identities are not leaking. But they have a button to promote as chat room owner, if they need to take action. Please note that there is a migration script that will remove all Peertube admins/moderators affiliations (unless they are video/channel's owner). They can get this access back using the button.
* #385: the slow mode duration on the channel option page is now a default value for new rooms. Streamers can change the value room per room in the room's configuration.
### Minor changes and fixes ### Minor changes and fixes

147
assets/images/moderator.svg Normal file
View File

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
viewBox="0 0 4.2333332 4.2333334"
version="1.1"
id="svg1428"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="moderator.svg"
inkscape:export-xdpi="9.6000004"
inkscape:export-ydpi="9.6000004"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb">
<defs
id="defs1422">
<linearGradient
id="linearGradient5158"
osb:paint="solid">
<stop
style="stop-color:#7d7d7d;stop-opacity:1;"
offset="0"
id="stop5156" />
</linearGradient>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter1150">
<feFlood
flood-opacity="1"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood1140" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite1142" />
<feGaussianBlur
in="composite1"
stdDeviation="0.2"
result="blur"
id="feGaussianBlur1144" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset1146" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite1148" />
</filter>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter1174">
<feFlood
flood-opacity="1"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood1164" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite1166" />
<feGaussianBlur
in="composite1"
stdDeviation="0.2"
result="blur"
id="feGaussianBlur1168" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset1170" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite1172" />
</filter>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="11.203223"
inkscape:cy="12.683728"
inkscape:document-units="px"
inkscape:current-layer="g1910"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="1842"
inkscape:window-height="1011"
inkscape:window-x="1920"
inkscape:window-y="1082"
inkscape:window-maximized="1"
units="px"
showguides="true"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata1425">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1910"
transform="matrix(0.45207703,0,0,0.45207703,-0.52645128,1.334609)"
style="stroke-width:1.00021;stroke-miterlimit:4;stroke-dasharray:none">
<path
id="path1141"
style="opacity:0.998;fill:#deddda;fill-opacity:1;stroke:#deddda;stroke-width:1.17052;stroke-linecap:butt;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
inkscape:label="rect1876-6"
d="m 2.4029499,0.58273862 0,-0.77222952 c 0,-0.5219815 0.4202233,-0.9422049 0.9422049,-0.9422049 l 1.9614175,-0.023585 1.9614174,0.010033 c 0.5219816,0 0.9422049,0.42022334 0.9422049,0.94220484 v 0.77222958 c 0,0 -1.6246328,4.32927978 -2.9036223,4.34980548 C 4.0275827,4.939518 2.4029499,0.58273862 2.4029499,0.58273862 Z"
sodipodi:nodetypes="cscccsczc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -53,8 +53,11 @@
display: none; display: none;
} }
[peertube-plugin-livechat-state="closed"] .peertube-plugin-livechat-button-close { [peertube-plugin-livechat-state="closed"] {
display: none; .peertube-plugin-livechat-button-promote,
.peertube-plugin-livechat-button-close {
display: none;
}
} }
[peertube-plugin-livechat-state]:not([peertube-plugin-livechat-state="open"]) { [peertube-plugin-livechat-state]:not([peertube-plugin-livechat-state="open"]) {

View File

@ -77,3 +77,5 @@ declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FOR_MORE_INFO: string
declare const LOC_INVALID_VALUE: string declare const LOC_INVALID_VALUE: string
declare const LOC_CHATROOM_NOT_ACCESSIBLE: string declare const LOC_CHATROOM_NOT_ACCESSIBLE: string
declare const LOC_PROMOTE: string

View File

@ -121,6 +121,7 @@ function register (clientOptions: RegisterClientOptions): void {
titleChannelConfiguration.textContent = labels.channelConfiguration titleChannelConfiguration.textContent = labels.channelConfiguration
titleLineEl.append(titleChannelConfiguration) titleLineEl.append(titleChannelConfiguration)
} }
titleLineEl.append(document.createElement('th'))
table.append(titleLineEl) table.append(titleLineEl)
rooms.forEach(room => { rooms.forEach(room => {
const localpart = room.localpart const localpart = room.localpart
@ -137,6 +138,35 @@ function register (clientOptions: RegisterClientOptions): void {
const date = new Date(room.lasttimestamp * 1000) const date = new Date(room.lasttimestamp * 1000)
lastActivityEl.textContent = date.toLocaleDateString() + ' ' + date.toLocaleTimeString() lastActivityEl.textContent = date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
} }
const promoteButton = document.createElement('a')
promoteButton.classList.add('orange-button', 'peertube-button-link')
promoteButton.style.margin = '5px'
promoteButton.onclick = async () => {
await fetch(
getBaseRoute(clientOptions) + '/api/promote/' + encodeURIComponent(room.jid.replace(/@.*$/, '')),
{
method: 'PUT',
headers: peertubeHelpers.getAuthHeader()
}
)
}
// FIXME: we can use promoteSVG, which is in client scope...
promoteButton.innerHTML = `<svg
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 4.233 4.233"
>
<g style="stroke-width:1.00021;stroke-miterlimit:4;stroke-dasharray:none">
<path
style="opacity:.998;fill:currentColor;fill-opacity:1;stroke:currentColor;stroke-width:1.17052;
stroke-linecap:butt;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M2.403.583V-.19a.94.94 0 0 1 .942-.943l1.962-.023 1.961.01a.94.94 0 0 1
.942.942v.772S6.586 4.9 5.307 4.92C4.027 4.939 2.403.583 2.403.583Z"
transform="matrix(.45208 0 0 .45208 -.526 1.335)"
/>
</g>
</svg>`
const promoteEl = document.createElement('td')
promoteEl.append(promoteButton)
const channelConfigurationEl = document.createElement('td') const channelConfigurationEl = document.createElement('td')
nameEl.append(aEl) nameEl.append(aEl)
lineEl.append(nameEl) lineEl.append(nameEl)
@ -146,6 +176,7 @@ function register (clientOptions: RegisterClientOptions): void {
if (useChannelConfiguration) { if (useChannelConfiguration) {
lineEl.append(channelConfigurationEl) // else the element will just be dropped. lineEl.append(channelConfigurationEl) // else the element will just be dropped.
} }
lineEl.append(promoteEl)
table.append(lineEl) table.append(lineEl)
const writeChannelConfigurationLink = (channelId: number | string): void => { const writeChannelConfigurationLink = (channelId: number | string): void => {

View File

@ -1,12 +1,16 @@
import type { Video } from '@peertube/peertube-types' import type { Video } from '@peertube/peertube-types'
import type { RegisterClientOptions } from '@peertube/peertube-types/client' import type { RegisterClientOptions } from '@peertube/peertube-types/client'
import type { InitConverseJSParams } from 'shared/lib/types'
import { videoHasWebchat, videoHasRemoteWebchat } from 'shared/lib/video' import { videoHasWebchat, videoHasRemoteWebchat } from 'shared/lib/video'
import { localizedHelpUrl } from './utils/help' import { localizedHelpUrl } from './utils/help'
import { logger } from './utils/logger' import { logger } from './utils/logger'
import { closeSVG, openBlankChatSVG, openChatSVG, shareChatUrlSVG, helpButtonSVG } from './videowatch/buttons' import {
closeSVG, openBlankChatSVG, openChatSVG, shareChatUrlSVG, helpButtonSVG, promoteSVG
} from './videowatch/buttons'
import { displayButton, displayButtonOptions } from './videowatch/button' import { displayButton, displayButtonOptions } from './videowatch/button'
import { shareChatUrl } from './videowatch/share' import { shareChatUrl } from './videowatch/share'
import { displayConverseJS } from './utils/conversejs' import { displayConverseJS } from './utils/conversejs'
import { getBaseRoute } from './utils/uri'
interface VideoWatchLoadedHookOptions { interface VideoWatchLoadedHookOptions {
videojs: any videojs: any
@ -71,7 +75,11 @@ function register (registerOptions: RegisterClientOptions): void {
let settings: any = {} // will be loaded later let settings: any = {} // will be loaded later
async function insertChatDom ( async function insertChatDom (
container: HTMLElement, video: Video, showOpenBlank: boolean, showShareUrlButton: boolean container: HTMLElement,
video: Video,
showOpenBlank: boolean,
showShareUrlButton: boolean,
showPromote: boolean
): Promise<void> { ): Promise<void> {
logger.log('Adding livechat in the DOM...') logger.log('Adding livechat in the DOM...')
const viewersDocumentationHelpUrl = await localizedHelpUrl(registerOptions, { const viewersDocumentationHelpUrl = await localizedHelpUrl(registerOptions, {
@ -84,13 +92,15 @@ function register (registerOptions: RegisterClientOptions): void {
peertubeHelpers.translate(LOC_OPEN_CHAT_NEW_WINDOW), peertubeHelpers.translate(LOC_OPEN_CHAT_NEW_WINDOW),
peertubeHelpers.translate(LOC_CLOSE_CHAT), peertubeHelpers.translate(LOC_CLOSE_CHAT),
peertubeHelpers.translate(LOC_SHARE_CHAT_LINK), peertubeHelpers.translate(LOC_SHARE_CHAT_LINK),
peertubeHelpers.translate(LOC_ONLINE_HELP) peertubeHelpers.translate(LOC_ONLINE_HELP),
peertubeHelpers.translate(LOC_PROMOTE)
]).then(labels => { ]).then(labels => {
const labelOpen = labels[0] const labelOpen = labels[0]
const labelOpenBlank = labels[1] const labelOpenBlank = labels[1]
const labelClose = labels[2] const labelClose = labels[2]
const labelShareUrl = labels[3] const labelShareUrl = labels[3]
const labelHelp = labels[4] const labelHelp = labels[4]
const labelPromote = labels[5]
const buttonContainer = document.createElement('div') const buttonContainer = document.createElement('div')
buttonContainer.classList.add('peertube-plugin-livechat-buttons') buttonContainer.classList.add('peertube-plugin-livechat-buttons')
@ -133,6 +143,45 @@ function register (registerOptions: RegisterClientOptions): void {
additionalClasses: [] additionalClasses: []
}) })
} }
if (showPromote) {
groupButtons.push({
buttonContainer,
name: 'promote',
label: labelPromote,
callback: async () => {
try {
// First we must get the room JID (can be video.uuid@ or channel.id@)
const response = await fetch(
getBaseRoute(registerOptions) + '/api/configuration/room/' +
encodeURIComponent(video.uuid),
{
method: 'GET',
headers: peertubeHelpers.getAuthHeader()
}
)
const converseJSParams: InitConverseJSParams = await (response).json()
if (converseJSParams.isRemoteChat) {
throw new Error('Cant promote on remote chat.')
}
const roomJIDLocalPart = converseJSParams.room.replace(/@.*$/, '')
await fetch(
getBaseRoute(registerOptions) + '/api/promote/' + encodeURIComponent(roomJIDLocalPart),
{
method: 'PUT',
headers: peertubeHelpers.getAuthHeader()
}
)
} catch (err) {
console.error(err)
}
},
icon: promoteSVG,
additionalClasses: []
})
}
groupButtons.push({ groupButtons.push({
buttonContainer, buttonContainer,
name: 'help', name: 'help',
@ -287,6 +336,7 @@ function register (registerOptions: RegisterClientOptions): void {
} }
let showShareUrlButton: boolean = false let showShareUrlButton: boolean = false
let showPromote: boolean = false
if (video.isLocal) { // No need for shareButton on remote chats. if (video.isLocal) { // No need for shareButton on remote chats.
const chatShareUrl = settings['chat-share-url'] ?? '' const chatShareUrl = settings['chat-share-url'] ?? ''
if (chatShareUrl === 'everyone') { if (chatShareUrl === 'everyone') {
@ -296,9 +346,19 @@ function register (registerOptions: RegisterClientOptions): void {
} else if (chatShareUrl === 'owner+moderators') { } else if (chatShareUrl === 'owner+moderators') {
showShareUrlButton = guessIsMine(registerOptions, video) || guessIamIModerator(registerOptions) showShareUrlButton = guessIsMine(registerOptions, video) || guessIamIModerator(registerOptions)
} }
if (guessIamIModerator(registerOptions)) {
showPromote = true
}
} }
await insertChatDom(container as HTMLElement, video, !!settings['chat-open-blank'], showShareUrlButton) await insertChatDom(
container as HTMLElement,
video,
!!settings['chat-open-blank'],
showShareUrlButton,
showPromote
)
if (settings['chat-auto-display']) { if (settings['chat-auto-display']) {
await openChat(video) await openChat(video)
} else if (container) { } else if (container) {

View File

@ -10,7 +10,7 @@ interface displayButtonOptionsBase {
} }
interface displayButtonOptionsCallback extends displayButtonOptionsBase { interface displayButtonOptionsCallback extends displayButtonOptionsBase {
callback: () => void | boolean callback: () => void | boolean | Promise<void>
} }
interface displayButtonOptionsHref extends displayButtonOptionsBase { interface displayButtonOptionsHref extends displayButtonOptionsBase {

View File

@ -111,12 +111,26 @@ const helpButtonSVG: SVGButton = () => {
` `
} }
const promoteSVG: SVGButton = () => {
// This content comes from the file assets/images/moderator.svg, after svgo cleaning.
// To get the formated content, you can do:
// xmllint dist/client/images/moderator.svg --format
// Then replace the color by `currentColor`
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 4.233 4.233">
<g style="stroke-width:1.00021;stroke-miterlimit:4;stroke-dasharray:none">
<path style="opacity:.998;fill:currentColor;fill-opacity:1;stroke:currentColor;stroke-width:1.17052;stroke-linecap:butt;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="M2.403.583V-.19a.94.94 0 0 1 .942-.943l1.962-.023 1.961.01a.94.94 0 0 1 .942.942v.772S6.586 4.9 5.307 4.92C4.027 4.939 2.403.583 2.403.583Z" transform="matrix(.45208 0 0 .45208 -.526 1.335)"/>
</g>
</svg>
`
}
export { export {
closeSVG, closeSVG,
openChatSVG, openChatSVG,
openBlankChatSVG, openBlankChatSVG,
shareChatUrlSVG, shareChatUrlSVG,
helpButtonSVG helpButtonSVG,
promoteSVG
} }
export type { export type {
SVGButton SVGButton

View File

@ -452,3 +452,5 @@ task_list_pick_message: |
Once you have chosen a task list, a new task will be created. Once you have chosen a task list, a new task will be created.
To see the task, open the task application using the top menu. To see the task, open the task application using the top menu.
More information in the livechat plugin documentation. More information in the livechat plugin documentation.
promote: 'Become moderator'

View File

@ -376,6 +376,8 @@ async function _localRoomJID (
} }
room = room.replace(/{{CHANNEL_ID}}/g, `${channelId}`) room = room.replace(/{{CHANNEL_ID}}/g, `${channelId}`)
if (room.includes('{{CHANNEL_NAME}}')) { if (room.includes('{{CHANNEL_NAME}}')) {
// FIXME: this should no more exists, since we removed options to include other chat server.
// So we should remove this code. (and simplify the above code)
const channelName = await getChannelNameById(options, channelId) const channelName = await getChannelNameById(options, channelId)
if (channelName === null) { if (channelName === null) {
throw new Error('Channel not found') throw new Error('Channel not found')

View File

@ -8,6 +8,7 @@ import { initRoomApiRouter } from './api/room'
import { initAuthApiRouter, initUserAuthApiRouter } from './api/auth' import { initAuthApiRouter, initUserAuthApiRouter } from './api/auth'
import { initFederationServerInfosApiRouter } from './api/federation-server-infos' import { initFederationServerInfosApiRouter } from './api/federation-server-infos'
import { initConfigurationApiRouter } from './api/configuration' import { initConfigurationApiRouter } from './api/configuration'
import { initPromoteApiRouter } from './api/promote'
/** /**
* Initiate API routes * Initiate API routes
@ -36,6 +37,7 @@ async function initApiRouter (options: RegisterServerOptions): Promise<Router> {
await initFederationServerInfosApiRouter(options, router) await initFederationServerInfosApiRouter(options, router)
await initConfigurationApiRouter(options, router) await initConfigurationApiRouter(options, router)
await initPromoteApiRouter(options, router)
if (isDebugMode(options)) { if (isDebugMode(options)) {
// Only add this route if the debug mode is enabled at time of the server launch. // Only add this route if the debug mode is enabled at time of the server launch.

View File

@ -0,0 +1,53 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Router, Request, Response, NextFunction } from 'express'
import type { Affiliations } from '../../prosody/config/affiliations'
import { asyncMiddleware } from '../../middlewares/async'
import { isUserAdminOrModerator } from '../../helpers'
import { getProsodyDomain } from '../../prosody/config/domain'
import { updateProsodyRoom } from '../../prosody/api/manage-rooms'
async function initPromoteApiRouter (options: RegisterServerOptions, router: Router): Promise<void> {
const logger = options.peertubeHelpers.logger
router.put('/promote/:roomJID', asyncMiddleware(
async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const roomJIDLocalPart = req.params.roomJID
const user = await options.peertubeHelpers.user.getAuthUser(res)
if (!user || !await isUserAdminOrModerator(options, res)) {
logger.warn('Current user tries to access the promote API for which he has no right.')
res.sendStatus(403)
return
}
if (!/^(channel\.\d+|(\w|-)+)$/.test(roomJIDLocalPart)) { // just check if it looks alright.
logger.warn('Current user tries to access the promote API using an invalid room key.')
res.sendStatus(400)
return
}
const normalizedUsername = user.username.toLowerCase()
const prosodyDomain = await getProsodyDomain(options)
const jid = normalizedUsername + '@' + prosodyDomain
const mucJID = roomJIDLocalPart + '@' + 'room.' + prosodyDomain
logger.info('We must give owner affiliation to ' + jid + ' on ' + mucJID)
const addAffiliations: Affiliations = {}
addAffiliations[jid] = 'owner'
await updateProsodyRoom(options, mucJID, {
addAffiliations
})
res.sendStatus(200)
} catch (err) {
logger.error(err)
res.sendStatus(500)
}
}
))
}
export {
initPromoteApiRouter
}