From 6f56a026bbbe9b535abecf0e1a9a430a50537f07 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 10 Jun 2024 19:07:55 +0200 Subject: [PATCH] Custom emojis: import/export functions. --- assets/styles/configuration.scss | 4 + client/@types/global.d.ts | 4 + .../configuration/elements/channel-emojis.ts | 159 ++++++++++++++++++ .../common/lib/elements/image-file-input.ts | 2 +- languages/en.yml | 4 + 5 files changed, 172 insertions(+), 1 deletion(-) diff --git a/assets/styles/configuration.scss b/assets/styles/configuration.scss index eb02ed1b..3a647c8c 100644 --- a/assets/styles/configuration.scss +++ b/assets/styles/configuration.scss @@ -220,6 +220,10 @@ $small-view: 800px; .peertube-livechat-emojis-col-file { width: 150px; } + + .peertube-plugin-livechat-configuration-actions { + text-align: right; + } } livechat-dynamic-table-form { diff --git a/client/@types/global.d.ts b/client/@types/global.d.ts index a5fa876e..8cd07a65 100644 --- a/client/@types/global.d.ts +++ b/client/@types/global.d.ts @@ -100,3 +100,7 @@ declare const LOC_LIVECHAT_EMOJIS_SHORTNAME: string declare const LOC_LIVECHAT_EMOJIS_SHORTNAME_DESC: string declare const LOC_LIVECHAT_EMOJIS_FILE: string declare const LOC_LIVECHAT_EMOJIS_FILE_DESC: string + +declare const LOC_ACTION_IMPORT: string +declare const LOC_ACTION_EXPORT: string +declare const LOC_ACTION_IMPORT_EMOJIS_INFO: string diff --git a/client/common/configuration/elements/channel-emojis.ts b/client/common/configuration/elements/channel-emojis.ts index 6fd8774a..4421fd0e 100644 --- a/client/common/configuration/elements/channel-emojis.ts +++ b/client/common/configuration/elements/channel-emojis.ts @@ -81,6 +81,23 @@ export class ChannelEmojisElement extends LivechatElement {

+
+ ${ + this._channelEmojisConfiguration?.emojis?.customEmojis?.length + ? html`` + : '' + } + ${ + (this._channelEmojisConfiguration?.emojis?.customEmojis?.length ?? 0) < maxEmojisPerChannel + ? html`` + : '' + } +
+
{ + ev.preventDefault() + const b: HTMLElement | null = ev.target && 'tagName' in ev.target ? ev.target as HTMLElement : null + b?.setAttribute('disabled', '') + try { + // download a json file: + const file = await new Promise((resolve, reject) => { + const input = document.createElement('input') + input.setAttribute('type', 'file') + input.setAttribute('accept', 'application/json') + input.onchange = (e) => { + e.preventDefault() + e.stopImmediatePropagation() + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) { + reject(new Error('Missing file')) + return + } + resolve(file) + } + input.click() + input.remove() + }) + + const content = await new Promise((resolve, reject) => { + const fileReader = new FileReader() + fileReader.onerror = reject + fileReader.onload = () => { + if (fileReader.result === null) { + reject(new Error('Empty result')) + return + } + if (fileReader.result instanceof ArrayBuffer) { + reject(new Error('Result is an ArrayBuffer, this was not intended')) + } else { + resolve(fileReader.result) + } + } + fileReader.readAsText(file) + }) + + const json = JSON.parse(content) + if (!Array.isArray(json)) { + throw new Error('Invalid data, an array was expected') + } + for (const entry of json) { + if (typeof entry !== 'object') { + throw new Error('Invalid data') + } + if (!entry.sn || !entry.url || (typeof entry.sn !== 'string') || (typeof entry.url !== 'string')) { + throw new Error('Invalid data') + } + + const url = await this._convertImageToDataUrl(entry.url) + let sn = entry.sn as string + if (!sn.startsWith(':')) { sn = ':' + sn } + if (!sn.endsWith(':')) { sn += ':' } + + const item: ChannelEmojisConfiguration['emojis']['customEmojis'][0] = { + sn, + url + } + if (entry.isCategoryEmoji === true) { + item.isCategoryEmoji = true + } + this._channelEmojisConfiguration?.emojis.customEmojis.push(item) + } + + this.requestUpdate('_channelEmojisConfiguration') + + this.registerClientOptions?.peertubeHelpers.notifier.info( + await this.registerClientOptions?.peertubeHelpers.translate(LOC_ACTION_IMPORT_EMOJIS_INFO) + ) + } catch (err: any) { + this.registerClientOptions?.peertubeHelpers.notifier.error(err.toString()) + } finally { + b?.removeAttribute('disabled') + } + } + + private async _exportEmojis (ev: Event): Promise { + ev.preventDefault() + const b: HTMLElement | null = ev.target && 'tagName' in ev.target ? ev.target as HTMLElement : null + b?.setAttribute('disabled', '') + try { + const result: ChannelEmojisConfiguration['emojis']['customEmojis'] = [] + for (const ed of this._channelEmojisConfiguration?.emojis?.customEmojis ?? []) { + if (!ed.sn || !ed.url) { continue } + // Here url can be: + // * the dataUrl representation of a newly uploaded file + // * or the url of an already saved image file + // In both cases, we want to export a dataUrl version. + const url = await this._convertImageToDataUrl(ed.url) + + const item: typeof result[0] = { + sn: ed.sn, + url + } + if (ed.isCategoryEmoji === true) { item.isCategoryEmoji = ed.isCategoryEmoji } + result.push(item) + } + + // Make the browser download the JSON file: + const dataUrl = 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(result)) + const a = document.createElement('a') + a.setAttribute('href', dataUrl) + a.setAttribute('download', 'emojis.json') + a.click() + a.remove() + } catch (err: any) { + console.error(err) + this.registerClientOptions?.peertubeHelpers.notifier.error(err.toString()) + } finally { + b?.removeAttribute('disabled') + } + } + + private async _convertImageToDataUrl (url: string): Promise { + if (url.startsWith('data:')) { return url } + // There is a trick to convert img to dataUrl: using a canvas. + // But we can't use it here... as it won't work with animated GIF. + // So we just fetch each url, and do the work. + const blob = await (await fetch(url)).blob() + const base64 = await new Promise((resolve, reject) => { + const fileReader = new FileReader() + fileReader.onload = () => { + if (fileReader.result === null) { + reject(new Error('Empty result')) + return + } + if (fileReader.result instanceof ArrayBuffer) { + reject(new Error('Result is an ArrayBuffer, this was not intended')) + } else { + resolve(fileReader.result) + } + } + fileReader.onerror = reject + fileReader.readAsDataURL(blob) + }) + return base64 + } } diff --git a/client/common/lib/elements/image-file-input.ts b/client/common/lib/elements/image-file-input.ts index 95720eec..c08d3625 100644 --- a/client/common/lib/elements/image-file-input.ts +++ b/client/common/lib/elements/image-file-input.ts @@ -80,7 +80,6 @@ export class ImageFileInputElement extends LivechatElement { try { const base64 = await new Promise((resolve, reject) => { const fileReader = new FileReader() - fileReader.readAsDataURL(file) fileReader.onload = () => { if (fileReader.result === null) { reject(new Error('Empty result')) @@ -93,6 +92,7 @@ export class ImageFileInputElement extends LivechatElement { } } fileReader.onerror = reject + fileReader.readAsDataURL(file) }) this.value = base64 diff --git a/languages/en.yml b/languages/en.yml index 25b5491d..22824053 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -507,3 +507,7 @@ livechat_emojis_shortname_desc: | livechat_emojis_file: 'File' livechat_emojis_file_desc: | The emoji file. + +action_import: Import +action_export: Export +action_import_emojis_info: If imported data are okay, don't forgot to save the form.