// SPDX-FileCopyrightText: 2024 John Livingston // // SPDX-License-Identifier: AGPL-3.0-only import type { Video } from '@peertube/peertube-types' import type { InitConverseJSParams } from 'shared/lib/types' import { getPtContext, LiveChatSettings } from '../lib/contexts/peertube' import { videoHasWebchat, videoHasRemoteWebchat } from 'shared/lib/video' import { displayButton, displayButtonOptions } from './button' import { closeSVG, openBlankChatSVG, openChatSVG, shareChatUrlSVG, helpButtonSVG, promoteSVG } from './buttons' import { shareChatUrl } from './share' import { isAnonymousUser, guessIsMine, guessIamIModerator } from '../../utils/user' 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 */ async function initChat (video: Video): Promise { const ptContext = getPtContext() const logger = ptContext.logger savedMyPluginFlexGrow = undefined if (!video) { logger.error('No video provided') return } const placeholder = document.getElementById('plugin-placeholder-player-next') if (!placeholder) { logger.error('The required placeholder div is not present in the DOM.') return } let container = placeholder.querySelector('#peertube-plugin-livechat-container') if (container) { logger.log('The chat seems already initialized...') return } container = document.createElement('div') container.setAttribute('id', 'peertube-plugin-livechat-container') container.setAttribute('peertube-plugin-livechat-state', 'initializing') container.setAttribute('peertube-plugin-livechat-current-url', window.location.href) placeholder.append(container) try { const settings = await ptContext.getSettings() logger.log('Checking if this video should have a chat...') if (settings['chat-no-anonymous'] === true && isAnonymousUser(ptContext.ptOptions)) { logger.log('No chat for anonymous users') return } if (!videoHasWebchat(settings, video) && !videoHasRemoteWebchat(settings, video)) { logger.log('This video has no webchat') return } 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') { showShareUrlButton = true } else if (chatShareUrl === 'owner') { showShareUrlButton = guessIsMine(ptContext.ptOptions, video) } else if (chatShareUrl === 'owner+moderators') { showShareUrlButton = guessIsMine(ptContext.ptOptions, video) || guessIamIModerator(ptContext.ptOptions) } if (guessIamIModerator(ptContext.ptOptions)) { showPromote = true } } await _insertChatDom( container as HTMLElement, video, settings, !!settings['chat-open-blank'], showShareUrlButton, showPromote ) if (settings['chat-auto-display']) { await _openChat(video) } else if (container) { container.setAttribute('peertube-plugin-livechat-state', 'closed') } } catch (err) { logger.error('initChat has failed') logger.error(err as string) } } /** * Insert the chat in the DOM. * @param container * @param video * @param settings * @param showOpenBlank * @param showShareUrlButton * @param showPromote */ async function _insertChatDom ( container: HTMLElement, video: Video, settings: LiveChatSettings, showOpenBlank: boolean, showShareUrlButton: boolean, showPromote: boolean ): Promise { const ptContext = getPtContext() const peertubeHelpers = ptContext.ptOptions.peertubeHelpers const logger = ptContext.logger logger.log('Adding livechat in the DOM...') const viewersDocumentationHelpUrl = await localizedHelpUrl(ptContext.ptOptions, { page: 'documentation/user/viewers' }) const p = new Promise((resolve) => { // eslint-disable-next-line @typescript-eslint/no-floating-promises Promise.all([ peertubeHelpers.translate(LOC_OPEN_CHAT), 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_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') container.append(buttonContainer) // Here are buttons that are magically merged const groupButtons: displayButtonOptions[] = [] groupButtons.push({ buttonContainer, name: 'open', label: labelOpen, callback: () => { _openChat(video).then(() => {}, () => {}) }, icon: openChatSVG, additionalClasses: [] }) if (showOpenBlank) { groupButtons.push({ buttonContainer, name: 'openblank', label: labelOpenBlank, callback: () => { _closeChat() window.open('/p/livechat/room?room=' + encodeURIComponent(video.uuid)) }, icon: openBlankChatSVG, additionalClasses: [] }) } if (showShareUrlButton) { groupButtons.push({ buttonContainer, name: 'shareurl', label: labelShareUrl, callback: () => { shareChatUrl(ptContext.ptOptions, settings, video).then(() => {}, () => {}) }, icon: shareChatUrlSVG, 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(ptContext.ptOptions) + '/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(ptContext.ptOptions) + '/api/promote/' + encodeURIComponent(roomJIDLocalPart), { method: 'PUT', headers: peertubeHelpers.getAuthHeader() } ) } catch (err) { logger.error(err) } }, icon: promoteSVG, additionalClasses: [] }) } groupButtons.push({ buttonContainer, name: 'help', label: labelHelp, href: viewersDocumentationHelpUrl, targetBlank: true, icon: helpButtonSVG, additionalClasses: [] }) // If more than one groupButtons: // - the first must have class 'peertube-plugin-livechat-multi-button-main' // - middle ones must have 'peertube-plugin-livechat-multi-button-secondary' // - the last must have 'peertube-plugin-livechat-multi-button-last-secondary' if (groupButtons.length > 1) { groupButtons[0].additionalClasses?.push('peertube-plugin-livechat-multi-button-main') for (let i = 1; i < groupButtons.length - 1; i++) { // middle groupButtons[i].additionalClasses?.push('peertube-plugin-livechat-multi-button-secondary') } groupButtons[groupButtons.length - 1] .additionalClasses?.push('peertube-plugin-livechat-multi-button-last-secondary') } for (const button of groupButtons) { displayButton(button) } displayButton({ buttonContainer, name: 'close', label: labelClose, callback: () => _closeChat(), icon: closeSVG }) resolve() }) }) return p } async function _openChat (video: Video): Promise { const ptContext = getPtContext() const peertubeHelpers = ptContext.ptOptions.peertubeHelpers const logger = ptContext.logger const settings = await ptContext.getSettings() if (!video) { logger.log('No video.') return false } logger.info(`Trying to load the chat for video ${video.uuid}.`) // here the room key is always the video uuid, a backend API will translate to channel id if relevant. const roomkey = video.uuid if (!roomkey) { logger.error('Can\'t get room xmpp addr') return false } const additionalStyles = settings['chat-style'] ?? '' logger.info('Opening the chat...') const container = document.getElementById('peertube-plugin-livechat-container') try { if (!container) { logger.error('Cant found the livechat container.') return false } if (container.getElementsByTagName('converse-root').length) { logger.error('Seems that there is already a ConverseJS in the container.') return false } if (additionalStyles) { container.setAttribute('style', additionalStyles) } container.setAttribute('peertube-plugin-livechat-state', 'open') // Hacking styles... _hackStyles(true) // Loading converseJS... await displayConverseJS(ptContext.ptOptions, container, roomkey, 'peertube-video', false) } catch (err) { // Displaying an error page. if (container) { const message = document.createElement('div') message.classList.add('peertube-plugin-livechat-error-message') message.innerText = await peertubeHelpers.translate(LOC_CHATROOM_NOT_ACCESSIBLE) container.append(message) container.querySelectorAll( '.livechat-spinner, converse-root' ).forEach(dom => dom.remove()) } _hackStyles(false) } } function _closeChat (): void { const logger = getPtContext().logger const container = document.getElementById('peertube-plugin-livechat-container') if (!container) { logger.error('Can\'t close livechat, container not found.') return } // Disconnecting ConverseJS if (window.converse?.livechatDisconnect) { window.converse.livechatDisconnect() } // Removing from the DOM container.querySelectorAll( 'converse-root, .livechat-spinner, .peertube-plugin-livechat-error-message' ).forEach(dom => dom.remove()) container.setAttribute('peertube-plugin-livechat-state', 'closed') // Un-Hacking styles... _hackStyles(false) } function _hackStyles (on: boolean): void { try { document.querySelectorAll('.peertube-plugin-livechat-buttons').forEach(buttons => { if (on) { buttons.classList.add('peertube-plugin-livechat-buttons-open') } else { 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}'`) } } export { initChat }