From dad29a941fa8bf06b88ea3761bc8cd5d22e3af3f Mon Sep 17 00:00:00 2001 From: John Livingston Date: Tue, 28 May 2024 17:56:24 +0200 Subject: [PATCH] Custom channel emoticons WIP (#130) --- .reuse/dep5 | 4 + CHANGELOG.md | 10 +-- .../configuration/elements/channel-emojis.ts | 56 ++++++++++++ client/common/configuration/elements/index.js | 4 +- client/common/configuration/register.ts | 14 +++ .../configuration/services/channel-details.ts | 22 ++++- conversejs/build-conversejs.sh | 4 +- conversejs/builtin.ts | 5 ++ conversejs/lib/converse-params.ts | 33 ++++++- conversejs/lib/plugins/livechat-emojis.ts | 89 +++++++++++++++++++ conversejs/lib/plugins/livechat-specific.ts | 39 +++++++- server/lib/conversejs/params.ts | 19 +++- .../lib/middlewares/configuration/channel.ts | 32 ++++--- server/lib/routers/api/configuration.ts | 26 +++++- shared/lib/types.ts | 15 +++- 15 files changed, 341 insertions(+), 31 deletions(-) create mode 100644 client/common/configuration/elements/channel-emojis.ts create mode 100644 conversejs/lib/plugins/livechat-emojis.ts diff --git a/.reuse/dep5 b/.reuse/dep5 index 5f56e4fc..68795aa2 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -9,6 +9,10 @@ Source: https://github.com/JohnXLivingston/peertube-plugin-livechat/ # Copyright: $YEAR $NAME <$CONTACT> # License: ... +Files: CHANGELOG.md +Copyright: 2024 John Livingston +License: AGPL-3.0-only + Files: languages/* Copyright: 2024 John Livingston License: AGPL-3.0-only diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd19317..1f3f0b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,5 @@ - +TODO: tag conversejs livechat branch, and replace commit ID in build-converse.js +TODO: handle custom emojis url for remote video. # Changelog @@ -10,8 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only ### New features -* Overhauled configuration page, with more broadly customizable lists of parameters by @Murazaki ([See pull request #352](https://github.com/JohnXLivingston/peertube-plugin-livechat/pull/352)) +* Overhauled configuration page, with more broadly customizable lists of parameters by @Murazaki ([See pull request #352](https://github.com/JohnXLivingston/peertube-plugin-livechat/pull/352)). * #377: new setting to listen C2S connection on non-localhost interfaces. +* #130: custom channel emoticons. ## 10.0.2 diff --git a/client/common/configuration/elements/channel-emojis.ts b/client/common/configuration/elements/channel-emojis.ts new file mode 100644 index 00000000..917bf935 --- /dev/null +++ b/client/common/configuration/elements/channel-emojis.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import type { RegisterClientOptions } from '@peertube/peertube-types/client' +import { LivechatElement } from '../../lib/elements/livechat' +import { registerClientOptionsContext } from '../../lib/contexts/peertube' +import { ChannelDetailsService } from '../services/channel-details' +import { channelDetailsServiceContext } from '../contexts/channel' +import { ChannelEmojis } from 'shared/lib/types' +// import { ptTr } from '../../lib/directives/translation' +import { Task } from '@lit/task' +import { customElement, property } from 'lit/decorators.js' +import { provide } from '@lit/context' + +/** + * Channel emojis configuration page. + */ +@customElement('livechat-channel-emojis') +export class ChannelEmojisElement extends LivechatElement { + @provide({ context: registerClientOptionsContext }) + @property({ attribute: false }) + public registerClientOptions?: RegisterClientOptions + + @property({ attribute: false }) + public channelId?: number + + private _channelEmojis?: ChannelEmojis + + @provide({ context: channelDetailsServiceContext }) + private _channelDetailsService?: ChannelDetailsService + + protected override render = (): void => { + return this._asyncTaskRender.render({ + pending: () => {}, + complete: () => {}, + error: (err: any) => { + this.registerClientOptions?.peertubeHelpers.notifier.error(err.toString()) + } + }) + } + + private readonly _asyncTaskRender = new Task(this, { + task: async () => { + if (!this.registerClientOptions) { + throw new Error('Missing client options') + } + if (!this.channelId) { + throw new Error('Missing channelId') + } + this._channelDetailsService = new ChannelDetailsService(this.registerClientOptions) + this._channelEmojis = await this._channelDetailsService.fetchEmojis(this.channelId) + }, + args: () => [] + }) +} diff --git a/client/common/configuration/elements/index.js b/client/common/configuration/elements/index.js index 7b570639..a98fb421 100644 --- a/client/common/configuration/elements/index.js +++ b/client/common/configuration/elements/index.js @@ -1,7 +1,9 @@ +// SPDX-FileCopyrightText: 2024 John Livingston // SPDX-FileCopyrightText: 2024 Mehdi Benadel // // SPDX-License-Identifier: AGPL-3.0-only // Add here all your elements, the main JS file will import them all. -import './channel-configuration' import './channel-home' +import './channel-configuration' +import './channel-emojis' diff --git a/client/common/configuration/register.ts b/client/common/configuration/register.ts index 3470ea42..04d7fa70 100644 --- a/client/common/configuration/register.ts +++ b/client/common/configuration/register.ts @@ -37,6 +37,20 @@ async function registerConfiguration (clientOptions: RegisterClientOptions): Pro } }) + registerClientRoute({ + route: 'livechat/configuration/emojis', + onMount: async ({ rootEl }) => { + const urlParams = new URLSearchParams(window.location.search) + const channelId = urlParams.get('channelId') ?? '' + render(html` + + `, rootEl) + } + }) + registerHook({ target: 'filter:left-menu.links.create.result', handler: async (links: any) => { diff --git a/client/common/configuration/services/channel-details.ts b/client/common/configuration/services/channel-details.ts index 75ba4b2b..15aee4f4 100644 --- a/client/common/configuration/services/channel-details.ts +++ b/client/common/configuration/services/channel-details.ts @@ -4,7 +4,9 @@ import type { RegisterClientOptions } from '@peertube/peertube-types/client' import type { ValidationError } from '../../lib/models/validation' -import type { ChannelLiveChatInfos, ChannelConfiguration, ChannelConfigurationOptions } from 'shared/lib/types' +import type { + ChannelLiveChatInfos, ChannelConfiguration, ChannelConfigurationOptions, ChannelEmojis +} from 'shared/lib/types' import { ValidationErrorType } from '../../lib/models/validation' import { getBaseRoute } from '../../../utils/uri' @@ -158,4 +160,22 @@ export class ChannelDetailsService { return response.json() } + + fetchEmojis = async (channelId: number): Promise => { + const response = await fetch( + getBaseRoute(this._registerClientOptions) + + '/api/configuration/channel/emojis/' + + encodeURIComponent(channelId), + { + method: 'GET', + headers: this._headers + } + ) + + if (!response.ok) { + throw new Error('Can\'t get channel emojis options.') + } + + return response.json() + } } diff --git a/conversejs/build-conversejs.sh b/conversejs/build-conversejs.sh index 1e7beb44..3d77c9eb 100644 --- a/conversejs/build-conversejs.sh +++ b/conversejs/build-conversejs.sh @@ -36,8 +36,10 @@ CONVERSE_COMMIT="" # - 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.0.0" -# CONVERSE_COMMIT="821b41e189b25316b9a044cb41ecc9b3f1910172" +CONVERSE_COMMIT="4402fcc3fc60f6c9334f86528c33a0b463371d12" CONVERSE_REPO="https://github.com/JohnXLivingston/converse.js" rootdir="$(pwd)" diff --git a/conversejs/builtin.ts b/conversejs/builtin.ts index cb9f7fed..00bd9215 100644 --- a/conversejs/builtin.ts +++ b/conversejs/builtin.ts @@ -19,6 +19,7 @@ import { windowTitlePlugin } from './lib/plugins/window-title' import { livechatSpecificsPlugin } from './lib/plugins/livechat-specific' import { livechatViewerModePlugin } from './lib/plugins/livechat-viewer-mode' import { livechatMiniMucHeadPlugin } from './lib/plugins/livechat-mini-muc-head' +import { livechatEmojisPlugin } from './lib/plugins/livechat-emojis' declare global { interface Window { @@ -27,6 +28,7 @@ declare global { plugins: { add: (name: string, plugin: any) => void } + emojis: any livechatDisconnect?: Function } initConversePlugins: typeof initConversePlugins @@ -54,6 +56,9 @@ function initConversePlugins (peertubeEmbedded: boolean): void { // livechatSpecifics plugins add some customization for the livechat plugin. converse.plugins.add('livechatSpecifics', livechatSpecificsPlugin) + // Channels emojis. + converse.plugins.add('livechatEmojis', livechatEmojisPlugin) + if (peertubeEmbedded) { // This plugins handles some buttons that are generated by Peertube, to include them in the MUC menu. converse.plugins.add('livechatMiniMucHeadPlugin', livechatMiniMucHeadPlugin) diff --git a/conversejs/lib/converse-params.ts b/conversejs/lib/converse-params.ts index f9bd8327..742be895 100644 --- a/conversejs/lib/converse-params.ts +++ b/conversejs/lib/converse-params.ts @@ -14,7 +14,8 @@ import type { AuthentInfos } from './auth' function defaultConverseParams ( { forceReadonly, theme, assetsPath, room, forceDefaultHideMucParticipants, autofocus, - peertubeVideoOriginalUrl, peertubeVideoUUID + peertubeVideoOriginalUrl, peertubeVideoUUID, + customEmojisUrl }: InitConverseJSParams ): any { const mucShowInfoMessages = forceReadonly @@ -84,7 +85,8 @@ function defaultConverseParams ( 'livechatMiniMucHeadPlugin', 'livechatViewerModePlugin', 'livechatDisconnectOnUnloadPlugin', - 'converse-slow-mode' + 'converse-slow-mode', + 'livechatEmojis' ], show_retraction_warning: false, // No need to use this warning (except if we open to external clients?) muc_show_info_messages: mucShowInfoMessages, @@ -98,7 +100,9 @@ function defaultConverseParams ( livechat_load_all_vcards: !!forceReadonly, livechat_peertube_video_original_url: peertubeVideoOriginalUrl, - livechat_peertube_video_uuid: peertubeVideoUUID + livechat_peertube_video_uuid: peertubeVideoUUID, + + livechat_custom_emojis_url: customEmojisUrl } // TODO: params.clear_messages_on_reconnection = true when muc_mam will be available. @@ -110,6 +114,29 @@ function defaultConverseParams ( params.clear_cache_on_logout = true params.allow_user_trust_override = false + // We must enable custom emoji category, that ConverseJS disables by default. + // We put it last by default, but first if there are any custom emojis for this chat. + params.emoji_categories = Object.assign( + {}, + customEmojisUrl + ? { custom: ':xmpp:' } // TODO: put here the default custom emoji + : {}, + { + smileys: ':grinning:', + people: ':thumbsup:', + activity: ':soccer:', + travel: ':motorcycle:', + objects: ':bomb:', + nature: ':rainbow:', + food: ':hotdog:', + symbols: ':musical_note:', + flags: ':flag_ac:' + }, + !customEmojisUrl + ? { custom: ':converse:' } + : {} + ) + return params } diff --git a/conversejs/lib/plugins/livechat-emojis.ts b/conversejs/lib/plugins/livechat-emojis.ts new file mode 100644 index 00000000..ee7eeee6 --- /dev/null +++ b/conversejs/lib/plugins/livechat-emojis.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2024 John Livingston +// +// SPDX-License-Identifier: AGPL-3.0-only + +import type { CustomEmojiDefinition, ChannelEmojis } from 'shared/lib/types' + +/** + * livechat emojis ConverseJS plugin: + * this plugin handles custom channel emojis (if enabled). + */ +export const livechatEmojisPlugin = { + dependencies: ['converse-emoji'], + initialize: function (this: any) { + const _converse = this._converse + + _converse.api.settings.extend({ + livechat_custom_emojis_url: false + }) + + _converse.api.listen.on('loadEmojis', async (_context: Object, json: any) => { + const url = _converse.api.settings.get('livechat_custom_emojis_url') + if (!url) { + return json + } + + let customs + try { + customs = await loadCustomEmojis(url) + } catch (err) { + console.error(err) + } + if (customs === undefined || !customs?.length) { + return json + } + + // We will put default emojis at the end + const defaultCustom = json.custom ?? {} + json.custom = {} + + let defaultDef: CustomEmojiDefinition | undefined + + for (const def of customs) { + json.custom[def.sn] = { + sn: def.sn, + url: def.url, + c: 'custom' + } + if (def.isCategoryEmoji) { + defaultDef ??= def + } + } + + for (const key in defaultCustom) { + if (key in json.custom) { + // Was overriden by the backend, skipping. + continue + } + json.custom[key] = defaultCustom[key] + } + + // And if there was a default definition, using it for the custom cat icon. + if (defaultDef) { + const cat = _converse.api.settings.get('emoji_categories') + cat.custom = defaultDef.sn + } + return json + }) + } +} + +async function loadCustomEmojis (url: string): Promise { + const response = await fetch( + url, + { + method: 'GET', + // Note: no need to be authenticated here, this is a public API + headers: { + 'content-type': 'application/json;charset=UTF-8' + } + } + ) + + if (!response.ok) { + throw new Error('Can\'t get channel emojis options.') + } + + const customEmojis = (await response.json()) as ChannelEmojis + return customEmojis.customEmojis +} diff --git a/conversejs/lib/plugins/livechat-specific.ts b/conversejs/lib/plugins/livechat-specific.ts index 2728ef96..5b337646 100644 --- a/conversejs/lib/plugins/livechat-specific.ts +++ b/conversejs/lib/plugins/livechat-specific.ts @@ -91,11 +91,19 @@ export const livechatSpecificsPlugin = { 'livechat_mini_muc_head', 'livechat_specific_external_authent', 'livechat_task_app_enabled', - 'livechat_task_app_restore' + 'livechat_task_app_restore', + 'livechat_custom_emojis_url', + 'emoji_categories' ]) { _converse.api.settings.set(k, params[k]) } + // We also unload emojis, in case there are custom emojis. + window.converse.emojis = { + initialized: false, + initialized_promise: getOpenPromise() + } + // Then login. _converse.api.user.login() } @@ -144,3 +152,32 @@ export const livechatSpecificsPlugin = { } } } + +// FIXME: this function is copied from @converse. Should not do so. +function getOpenPromise (): any { + const wrapper: any = { + isResolved: false, + isPending: true, + isRejected: false + } + const promise: any = new Promise((resolve, reject) => { + wrapper.resolve = resolve + wrapper.reject = reject + }) + Object.assign(promise, wrapper) + promise.then( + function (v: any) { + promise.isResolved = true + promise.isPending = false + promise.isRejected = false + return v + }, + function (e: any) { + promise.isResolved = false + promise.isPending = false + promise.isRejected = true + throw (e) + } + ) + return promise +} diff --git a/server/lib/conversejs/params.ts b/server/lib/conversejs/params.ts index 1c32bfe5..1914297d 100644 --- a/server/lib/conversejs/params.ts +++ b/server/lib/conversejs/params.ts @@ -43,7 +43,8 @@ async function getConverseJSParams ( 'converse-theme', 'federation-no-remote-chat', 'prosody-room-allow-s2s', - 'chat-no-anonymous' + 'chat-no-anonymous', + 'disable-channel-configuration' ]) if (settings['chat-no-anonymous'] && userIsConnected === false) { @@ -133,7 +134,8 @@ async function getConverseJSParams ( // forceDefaultHideMucParticipants is for testing purpose // (so we can stress test with the muc participant list hidden by default) forceDefaultHideMucParticipants: params.forceDefaultHideMucParticipants, - externalAuthOIDC + externalAuthOIDC, + customEmojisUrl: connectionInfos.customEmojisUrl } } @@ -237,6 +239,7 @@ async function _connectionInfos ( localWsUri: string | null remoteConnectionInfos: WCRemoteConnectionInfos | undefined roomJID: string + customEmojisUrl?: string } | InitConverseJSParamsError> { const { video, remoteChatInfos, channelId, roomKey } = roomInfos @@ -249,6 +252,7 @@ async function _connectionInfos ( let remoteConnectionInfos: WCRemoteConnectionInfos | undefined let roomJID: string + let customEmojisUrl: string | undefined if (video?.remote) { const canWebsocketS2S = !settings['federation-no-remote-chat'] && !settings['disable-websocket'] const canDirectS2S = !settings['federation-no-remote-chat'] && !!settings['prosody-room-allow-s2s'] @@ -266,6 +270,8 @@ async function _connectionInfos ( } } roomJID = remoteConnectionInfos.roomJID + + // TODO: fill customEmojisUrl (how to get the info? is the remote livechat compatible?) } else { try { roomJID = await _localRoomJID( @@ -277,6 +283,12 @@ async function _connectionInfos ( channelId, params.forcetype ?? false ) + + if (!settings['disable-channel-configuration'] && video?.channelId) { + customEmojisUrl = getBaseRouterRoute(options) + + 'api/configuration/channel/emojis/' + + encodeURIComponent(video.channelId) + } } catch (err) { options.peertubeHelpers.logger.error(err) return { @@ -293,7 +305,8 @@ async function _connectionInfos ( localBoshUri, localWsUri, remoteConnectionInfos, - roomJID + roomJID, + customEmojisUrl } } diff --git a/server/lib/middlewares/configuration/channel.ts b/server/lib/middlewares/configuration/channel.ts index 7f400750..8a29e8a3 100644 --- a/server/lib/middlewares/configuration/channel.ts +++ b/server/lib/middlewares/configuration/channel.ts @@ -12,9 +12,13 @@ import { isUserAdminOrModerator } from '../../helpers' * Returns a middleware handler to get the channelInfos from the channel parameter. * This is used in api related to channel configuration options. * @param options Peertube server options + * @param publicPage If true, the page is considered public, and we don't test user rights. * @returns middleware function */ -function getCheckConfigurationChannelMiddleware (options: RegisterServerOptions): RequestPromiseHandler { +function getCheckConfigurationChannelMiddleware ( + options: RegisterServerOptions, + publicPage?: boolean +): RequestPromiseHandler { return async (req: Request, res: Response, next: NextFunction) => { const logger = options.peertubeHelpers.logger const channelId = req.params.channelId @@ -32,18 +36,20 @@ function getCheckConfigurationChannelMiddleware (options: RegisterServerOptions) return } - // To access this page, you must either be: - // - the channel owner, - // - an instance modo/admin - // - TODO: a channel chat moderator, as defined in this page. - if (channelInfos.ownerAccountId === currentUser.Account.id) { - logger.debug('Current user is the channel owner') - } else if (await isUserAdminOrModerator(options, res)) { - logger.debug('Current user is an instance moderator or admin') - } else { - logger.warn('Current user tries to access a channel for which he has no right.') - res.sendStatus(403) - return + if (!publicPage) { + // To access this page, you must either be: + // - the channel owner, + // - an instance modo/admin + // - TODO: a channel chat moderator, as defined in this page. + if (channelInfos.ownerAccountId === currentUser.Account.id) { + logger.debug('Current user is the channel owner') + } else if (await isUserAdminOrModerator(options, res)) { + logger.debug('Current user is an instance moderator or admin') + } else { + logger.warn('Current user tries to access a channel for which he has no right.') + res.sendStatus(403) + return + } } logger.debug('User can access the configuration channel api.') diff --git a/server/lib/routers/api/configuration.ts b/server/lib/routers/api/configuration.ts index cbe37244..15900d02 100644 --- a/server/lib/routers/api/configuration.ts +++ b/server/lib/routers/api/configuration.ts @@ -4,7 +4,7 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' import type { Router, Request, Response, NextFunction } from 'express' -import type { ChannelInfos } from '../../../../shared/lib/types' +import type { ChannelInfos, ChannelEmojis } from '../../../../shared/lib/types' import { asyncMiddleware } from '../../middlewares/async' import { getCheckConfigurationChannelMiddleware } from '../../middlewares/configuration/channel' import { checkConfigurationEnabledMiddleware } from '../../middlewares/configuration/configuration' @@ -110,6 +110,30 @@ async function initConfigurationApiRouter (options: RegisterServerOptions, route res.json(result) } ])) + + router.get('/configuration/channel/emojis/:channelId', asyncMiddleware([ + checkConfigurationEnabledMiddleware(options), + getCheckConfigurationChannelMiddleware(options, true), + async (req: Request, res: Response, _next: NextFunction): Promise => { + if (!res.locals.channelInfos) { + logger.error('Missing channelInfos in res.locals, should not happen') + res.sendStatus(500) + return + } + // const channelInfos = res.locals.channelInfos as ChannelInfos + + const channelEmojis: ChannelEmojis = { + customEmojis: [{ + sn: ':test:', + url: '/dist/images/custom_emojis/xmpp.png', + isCategoryEmoji: true + }] + } + + res.status(200) + res.json(channelEmojis) + } + ])) } export { diff --git a/shared/lib/types.ts b/shared/lib/types.ts index ed1de52f..e7f2ff24 100644 --- a/shared/lib/types.ts +++ b/shared/lib/types.ts @@ -42,6 +42,7 @@ interface InitConverseJSParams { buttonLabel: string url: string }> + customEmojisUrl?: string | null } interface InitConverseJSParamsError { @@ -150,6 +151,16 @@ type ExternalAuthResult = ExternalAuthResultError | ExternalAuthResultOk type ExternalAuthOIDCType = 'custom' | 'google' | 'facebook' +interface CustomEmojiDefinition { + sn: string + url: string + isCategoryEmoji?: boolean +} + +interface ChannelEmojis { + customEmojis: CustomEmojiDefinition[] +} + export type { ConverseJSTheme, InitConverseJSParams, @@ -165,5 +176,7 @@ export type { ExternalAuthResultError, ExternalAuthResultOk, ExternalAuthResult, - ExternalAuthOIDCType + ExternalAuthOIDCType, + CustomEmojiDefinition, + ChannelEmojis }