From da75765bdb159df17f222133455ab62600156aca Mon Sep 17 00:00:00 2001 From: John Livingston Date: Fri, 17 May 2024 15:17:36 +0200 Subject: [PATCH] Changing defaults MUC affiliation (#385): * For Peertube moderators/admins, we add a button "Promote". Clicking on it will promote them as MUC owner. --- CHANGELOG.md | 1 - assets/images/moderator.svg | 147 +++++++++++++++++++++++++++ assets/styles/style.scss | 7 +- client/@types/global.d.ts | 2 + client/admin-plugin-client-plugin.ts | 31 ++++++ client/videowatch-client-plugin.ts | 68 ++++++++++++- client/videowatch/button.ts | 2 +- client/videowatch/buttons.ts | 16 ++- languages/en.yml | 2 + server/lib/conversejs/params.ts | 2 + server/lib/routers/api.ts | 2 + server/lib/routers/api/promote.ts | 53 ++++++++++ 12 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 assets/images/moderator.svg create mode 100644 server/lib/routers/api/promote.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ba6ac7..b19157e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). * #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 diff --git a/assets/images/moderator.svg b/assets/images/moderator.svg new file mode 100644 index 00000000..c88e229b --- /dev/null +++ b/assets/images/moderator.svg @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/styles/style.scss b/assets/styles/style.scss index f17b80f8..877438e6 100644 --- a/assets/styles/style.scss +++ b/assets/styles/style.scss @@ -53,8 +53,11 @@ display: none; } -[peertube-plugin-livechat-state="closed"] .peertube-plugin-livechat-button-close { - display: none; +[peertube-plugin-livechat-state="closed"] { + .peertube-plugin-livechat-button-promote, + .peertube-plugin-livechat-button-close { + display: none; + } } [peertube-plugin-livechat-state]:not([peertube-plugin-livechat-state="open"]) { diff --git a/client/@types/global.d.ts b/client/@types/global.d.ts index 1a5cc358..48961d30 100644 --- a/client/@types/global.d.ts +++ b/client/@types/global.d.ts @@ -77,3 +77,5 @@ declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FOR_MORE_INFO: string declare const LOC_INVALID_VALUE: string declare const LOC_CHATROOM_NOT_ACCESSIBLE: string + +declare const LOC_PROMOTE: string diff --git a/client/admin-plugin-client-plugin.ts b/client/admin-plugin-client-plugin.ts index faf8cd0f..81b1b708 100644 --- a/client/admin-plugin-client-plugin.ts +++ b/client/admin-plugin-client-plugin.ts @@ -121,6 +121,7 @@ function register (clientOptions: RegisterClientOptions): void { titleChannelConfiguration.textContent = labels.channelConfiguration titleLineEl.append(titleChannelConfiguration) } + titleLineEl.append(document.createElement('th')) table.append(titleLineEl) rooms.forEach(room => { const localpart = room.localpart @@ -137,6 +138,35 @@ function register (clientOptions: RegisterClientOptions): void { const date = new Date(room.lasttimestamp * 1000) 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 = ` + + + + ` + const promoteEl = document.createElement('td') + promoteEl.append(promoteButton) + const channelConfigurationEl = document.createElement('td') nameEl.append(aEl) lineEl.append(nameEl) @@ -146,6 +176,7 @@ function register (clientOptions: RegisterClientOptions): void { if (useChannelConfiguration) { lineEl.append(channelConfigurationEl) // else the element will just be dropped. } + lineEl.append(promoteEl) table.append(lineEl) const writeChannelConfigurationLink = (channelId: number | string): void => { diff --git a/client/videowatch-client-plugin.ts b/client/videowatch-client-plugin.ts index e84d26a2..7ffca1e5 100644 --- a/client/videowatch-client-plugin.ts +++ b/client/videowatch-client-plugin.ts @@ -1,12 +1,16 @@ import type { Video } from '@peertube/peertube-types' import type { RegisterClientOptions } from '@peertube/peertube-types/client' +import type { InitConverseJSParams } from 'shared/lib/types' import { videoHasWebchat, videoHasRemoteWebchat } from 'shared/lib/video' import { localizedHelpUrl } from './utils/help' 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 { shareChatUrl } from './videowatch/share' import { displayConverseJS } from './utils/conversejs' +import { getBaseRoute } from './utils/uri' interface VideoWatchLoadedHookOptions { videojs: any @@ -71,7 +75,11 @@ function register (registerOptions: RegisterClientOptions): void { let settings: any = {} // will be loaded later async function insertChatDom ( - container: HTMLElement, video: Video, showOpenBlank: boolean, showShareUrlButton: boolean + container: HTMLElement, + video: Video, + showOpenBlank: boolean, + showShareUrlButton: boolean, + showPromote: boolean ): Promise { logger.log('Adding livechat in the DOM...') const viewersDocumentationHelpUrl = await localizedHelpUrl(registerOptions, { @@ -84,13 +92,15 @@ function register (registerOptions: RegisterClientOptions): void { peertubeHelpers.translate(LOC_OPEN_CHAT_NEW_WINDOW), peertubeHelpers.translate(LOC_CLOSE_CHAT), peertubeHelpers.translate(LOC_SHARE_CHAT_LINK), - peertubeHelpers.translate(LOC_ONLINE_HELP) + peertubeHelpers.translate(LOC_ONLINE_HELP), + peertubeHelpers.translate(LOC_PROMOTE) ]).then(labels => { const labelOpen = labels[0] const labelOpenBlank = labels[1] const labelClose = labels[2] const labelShareUrl = labels[3] const labelHelp = labels[4] + const labelPromote = labels[5] const buttonContainer = document.createElement('div') buttonContainer.classList.add('peertube-plugin-livechat-buttons') @@ -133,6 +143,45 @@ function register (registerOptions: RegisterClientOptions): void { 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({ buttonContainer, name: 'help', @@ -287,6 +336,7 @@ function register (registerOptions: RegisterClientOptions): void { } let showShareUrlButton: boolean = false + let showPromote: boolean = false if (video.isLocal) { // No need for shareButton on remote chats. const chatShareUrl = settings['chat-share-url'] ?? '' if (chatShareUrl === 'everyone') { @@ -296,9 +346,19 @@ function register (registerOptions: RegisterClientOptions): void { } else if (chatShareUrl === 'owner+moderators') { 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']) { await openChat(video) } else if (container) { diff --git a/client/videowatch/button.ts b/client/videowatch/button.ts index 0d431b09..70c3091e 100644 --- a/client/videowatch/button.ts +++ b/client/videowatch/button.ts @@ -10,7 +10,7 @@ interface displayButtonOptionsBase { } interface displayButtonOptionsCallback extends displayButtonOptionsBase { - callback: () => void | boolean + callback: () => void | boolean | Promise } interface displayButtonOptionsHref extends displayButtonOptionsBase { diff --git a/client/videowatch/buttons.ts b/client/videowatch/buttons.ts index dae35ca5..0c40a343 100644 --- a/client/videowatch/buttons.ts +++ b/client/videowatch/buttons.ts @@ -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 ` + + + + +` +} + export { closeSVG, openChatSVG, openBlankChatSVG, shareChatUrlSVG, - helpButtonSVG + helpButtonSVG, + promoteSVG } export type { SVGButton diff --git a/languages/en.yml b/languages/en.yml index a8e8b333..7c5f924f 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -452,3 +452,5 @@ task_list_pick_message: | 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. More information in the livechat plugin documentation. + +promote: 'Become moderator' diff --git a/server/lib/conversejs/params.ts b/server/lib/conversejs/params.ts index 758cafd7..784e88d9 100644 --- a/server/lib/conversejs/params.ts +++ b/server/lib/conversejs/params.ts @@ -376,6 +376,8 @@ async function _localRoomJID ( } room = room.replace(/{{CHANNEL_ID}}/g, `${channelId}`) 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) if (channelName === null) { throw new Error('Channel not found') diff --git a/server/lib/routers/api.ts b/server/lib/routers/api.ts index 5ce0257b..ebd0c8e3 100644 --- a/server/lib/routers/api.ts +++ b/server/lib/routers/api.ts @@ -8,6 +8,7 @@ import { initRoomApiRouter } from './api/room' import { initAuthApiRouter, initUserAuthApiRouter } from './api/auth' import { initFederationServerInfosApiRouter } from './api/federation-server-infos' import { initConfigurationApiRouter } from './api/configuration' +import { initPromoteApiRouter } from './api/promote' /** * Initiate API routes @@ -36,6 +37,7 @@ async function initApiRouter (options: RegisterServerOptions): Promise { await initFederationServerInfosApiRouter(options, router) await initConfigurationApiRouter(options, router) + await initPromoteApiRouter(options, router) if (isDebugMode(options)) { // Only add this route if the debug mode is enabled at time of the server launch. diff --git a/server/lib/routers/api/promote.ts b/server/lib/routers/api/promote.ts new file mode 100644 index 00000000..6f2f0fef --- /dev/null +++ b/server/lib/routers/api/promote.ts @@ -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 { + const logger = options.peertubeHelpers.logger + + router.put('/promote/:roomJID', asyncMiddleware( + async (req: Request, res: Response, _next: NextFunction): Promise => { + 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 +}