refactor: use Svelte for ACP interface

This commit is contained in:
Peter Jaszkowiak 2021-02-22 19:13:03 -07:00
parent 87b122fe57
commit 310ed52a98
39 changed files with 1349 additions and 1221 deletions

View file

@ -1,8 +1,32 @@
/* eslint-disable @typescript-eslint/no-var-requires, import/no-extraneous-dependencies */
const typescript = require('typescript');

module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
plugins: [
'svelte3',
'@typescript-eslint',
],
overrides: [
{
files: ['*.svelte'],
processor: 'svelte3/svelte3',
rules: {
'@typescript-eslint/indent': 'off',
'no-label-var': 'off',
'import/first': 'off',
'import/no-cycle': 'off',
'import/no-mutable-exports': 'off',
},
},
],
settings: {
'svelte3/typescript': typescript,
},
extends: ['airbnb-base', 'plugin:@typescript-eslint/recommended'],
rules: {
camelcase: 'off',
'no-undef': 'off',
'prefer-destructuring': 'off',
'no-param-reassign': 'warn',
'comma-dangle': ['error', {
@ -10,7 +34,7 @@ module.exports = {
objects: 'always-multiline',
imports: 'always-multiline',
exports: 'always-multiline',
functions: 'never'
functions: 'never',
}],
'import/no-unresolved': 'off',
'import/prefer-default-export': 'off',
@ -20,11 +44,12 @@ module.exports = {
'object-curly-newline': ['error', {
multiline: true,
minProperties: 5,
consistent: true
consistent: true,
}],
'arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
'@typescript-eslint/indent': ['error', 2],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/explicit-module-boundary-types': ['error', { allowArgumentsExplicitlyTypedAsAny: true }],
},
};

5
acp/.eslintrc.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
rules: {
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
},
};

1
acp/admin.less Normal file
View file

@ -0,0 +1 @@
@import (inline) "../build/acp/admin.css";

61
acp/rollup.config.js Normal file
View file

@ -0,0 +1,61 @@
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import livereload from 'rollup-plugin-livereload';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';
import css from 'rollup-plugin-css-only';
import virtual from '@rollup/plugin-virtual';

const production = !process.env.ROLLUP_WATCH;

export default {
input: 'src/admin.ts',
external: ['translator', 'jquery', 'api', 'emoji'],
output: {
sourcemap: !production,
format: 'amd',
file: '../build/acp/admin.js',
},
plugins: [
virtual({
ajaxify: 'export default ajaxify',
app: 'export default app',
config: 'export default config',
textcomplete: 'export default Textcomplete',
utils: 'export default utils',
}),
svelte({
preprocess: sveltePreprocess({ sourceMap: !production }),
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
},
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: 'admin.css' }),

// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte'],
}),
commonjs(),
typescript({
sourceMap: !production,
inlineSources: !production,
}),

// Watch the `src` directory and refresh the
// browser on changes when not in production
!production && livereload(),
],
watch: {
clearScreen: false,
},
};

284
acp/src/Adjunct.svelte Normal file
View file

@ -0,0 +1,284 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import Textcomplete from 'textcomplete';
import { buildEmoji, strategy, table } from 'emoji';

export let item: CustomAdjunct;
export let id: number;

function deepEquals(a: unknown, b: unknown) {
if (a === b) {
return true;
}
if (typeof a !== typeof b) {
return false;
}
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
return a.every((value, index) => deepEquals(value, b[index]));
}
if (Array.isArray(a) || Array.isArray(b)) {
return false;
}
if (typeof a === 'object') {
const keys = Object.keys(a);
if (Object.keys(b).length !== keys.length) {
return false;
}
return keys.every(key => deepEquals(a[key], b[key]));
}
return false;
}

let name = item.name;
let aliases = item.aliases.slice();
let ascii = item.ascii.slice();

export function reset(): void {
name = item.name;
aliases = item.aliases.slice();
ascii = item.ascii.slice();
}

const empty = !item.name && !item.aliases?.length && !item.ascii?.length;

let newAlias = '';
function removeAlias(a: string) {
aliases = aliases.filter(x => x !== a);
}
function addAlias() {
if (!newAlias || aliases.includes(newAlias)) {
return;
}

aliases = [...aliases, newAlias];
newAlias = '';
}

let newAscii = '';
function removeAscii(a: string) {
ascii = ascii.filter(x => x !== a);
}
function addAscii() {
if (!newAscii || ascii.includes(newAscii)) {
return;
}

ascii = [...ascii, newAscii];
newAscii = '';
}

$: emoji = name && table[name];
$: editing = !deepEquals({
name,
aliases,
ascii,
}, item);

interface Failures {
nameRequired: boolean,
nameInvalid: boolean,
aliasInvalid: boolean,
noChange: boolean,
any: boolean,
}
const failures: Failures = {
nameRequired: false,
nameInvalid: false,
aliasInvalid: false,
noChange: false,
any: false,
};
const pattern = /[^a-z\-.+0-9_]/i;
$: failures.nameRequired = !name;
$: failures.nameInvalid = !table[name];
$: failures.aliasInvalid = pattern.test(newAlias);
$: failures.noChange = !aliases.length && !ascii.length;
$: failures.any = (
failures.nameRequired ||
failures.nameInvalid ||
failures.aliasInvalid ||
failures.noChange
);

$: canSave = editing && !failures.any;

let nameInput: HTMLInputElement;
onMount(() => {
const { Textarea } = Textcomplete.editors;

const editor = new Textarea(nameInput);
const completer = new Textcomplete(editor, {
dropdown: {
style: { zIndex: 20000 },
},
});

completer.register([{
...strategy,
replace: (data: StoredEmoji) => data.name,
match: /^(.+)$/,
}]);

completer.on('selected', () => {
name = nameInput.value;
});
});

const dispatch = createEventDispatcher();
function onSave() {
dispatch('save', {
id,
item: {
name,
aliases,
ascii,
},
});
}

let deleting = false;
let deleted = false;

function onDelete() {
deleting = true;
}
function confirmDelete() {
deleting = false;
deleted = true;

setTimeout(() => {
dispatch('delete', {
id,
});
}, 250);
}
function cancelDelete() {
deleting = false;
}
</script>

<tr class:fadeout={deleted}>
<td>
<input
type="text"
class="form-control emoji-name"
bind:value={name}
bind:this={nameInput}
/>
</td>
<td>
{#if emoji}
{@html buildEmoji(emoji)}
{/if}
</td>
<td>
<div class="input-group">
<input
type="text"
class="form-control"
bind:value={newAlias}
/>
<div class="input-group-addon"><button class="btn btn-default" on:click={addAlias}>+</button></div>
</div>
<span>
{#each aliases as a}
<button class="btn btn-info btn-xs" on:click={() => removeAlias(a)}>{a} x</button>
{/each}
</span>
</td>
<td>
<div class="input-group">
<input
type="text"
class="form-control"
bind:value={newAscii}
/>
<div class="input-group-addon"><button class="btn btn-default" on:click={addAscii}>+</button></div>
</div>
<span>
{#each ascii as a}
<button class="btn btn-info btn-xs" on:click={() => removeAscii(a)}>{a} x</button>
{/each}
</span>
</td>
<td>
{#if editing || empty}
<button
class="btn btn-success"
type="button"
on:click={onSave}
disabled={!canSave}
>
<i class="fa fa-check"></i>
</button>
{:else}
<button
class="btn btn-warning"
type="button"
on:click={onDelete}
disabled={deleting || deleted}
>
<i class="fa fa-trash"></i>
</button>
{/if}
</td>
</tr>

{#if deleting || deleted}
<tr class:fadeout={deleted}>
<td>
<button class="btn btn-default" type="button" disabled={deleted} on:click={cancelDelete}>Cancel</button>
</td>
<td colSpan={3}>
<span class="help-block">Are you sure you want to delete this extension?</span>
</td>
<td>
<button class="btn btn-danger" type="button" disabled={deleted} on:click={confirmDelete}>Yes</button>
</td>
</tr>
{/if}

{#if editing && failures.nameRequired}
<tr class="text-danger">
<td colSpan={5}>
<span><strong>Name</strong> is required</span>
</td>
</tr>
{/if}

{#if editing && failures.nameInvalid}
<tr class="text-danger">
<td colSpan={5}>
<span><strong>Name</strong> must be an existing emoji</span>
</td>
</tr>
{/if}

{#if editing && failures.aliasInvalid}
<tr class="text-danger">
<td colSpan={5}>
<span><strong>Aliases</strong> can only contain letters, numbers, and <code>_-+.</code></span>
</td>
</tr>
{/if}

{#if editing && failures.noChange}
<tr class="text-danger">
<td colSpan={5}>
<span>Must provide at least one <strong>Alias</strong> or <strong>ASCII Pattern</strong>.</span>
</td>
</tr>
{/if}

<style>
tr {
opacity: 1;
transition: opacity 200ms ease-in-out;
}
tr.fadeout {
opacity: 0;
}
input.emoji-name {
max-width: 125px;
}
</style>

10
acp/src/App.svelte Normal file
View file

@ -0,0 +1,10 @@
<script lang="ts">
import Settings from './Settings.svelte';
</script>

<div class="admin">
<nav id="header" class="header"></nav>
<div id="content" class="container">
<Settings/>
</div>
</div>

49
acp/src/Customize.svelte Normal file
View file

@ -0,0 +1,49 @@
<script lang="ts">
import jQuery from 'jquery';
import config from 'config';
import EmojiList from './EmojiList.svelte';
import ItemList from './ItemList.svelte';

export let data: Customizations;

let modal: HTMLElement;

export function show(): void {
jQuery(modal).modal('show');
}
</script>

<div class="modal fade" bind:this={modal} tabindex="-1" role="dialog" aria-labelledby="editModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title" id="editModalLabel">Customize Emoji</h4>
</div>
<div class="modal-body">
<p>
Below you can add custom emoji, and also add new aliases
and ASCII patterns for existing emoji. While this list is
edited live, you must still <strong>Build Emoji Assets </strong>
to actually use these customizations.
</p>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Custom Emoji</h3>
</div>
<EmojiList emojis={data.emojis} />
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Custom Extensions</h3>
</div>
<ItemList record={data.adjuncts} />
</div>
</div>
</div>
</div>
</div>

<svelte:head>
<link rel="stylesheet" href={`${config.relative_path}/plugins/nodebb-plugin-emoji/emoji/styles.css?${config['cache-buster']}`} />
</svelte:head>

330
acp/src/Emoji.svelte Normal file
View file

@ -0,0 +1,330 @@
<script lang="ts">
import app from 'app';
import config from 'config';
import utils from 'utils';
import { createEventDispatcher } from 'svelte';

export let emoji: CustomEmoji;
export let id: number;

import { buildEmoji } from 'emoji';

function deepEquals(a: unknown, b: unknown) {
if (a === b) {
return true;
}
if (typeof a !== typeof b) {
return false;
}
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
return a.every((value, index) => deepEquals(value, b[index]));
}
if (Array.isArray(a) || Array.isArray(b)) {
return false;
}
if (typeof a === 'object') {
const keys = Object.keys(a);
if (Object.keys(b).length !== keys.length) {
return false;
}
return keys.every(key => deepEquals(a[key], b[key]));
}
return false;
}

interface Failures {
nameRequired: boolean,
imageRequired: boolean,
nameInvalid: boolean,
aliasInvalid: boolean,
any: boolean,
}

let name = emoji.name;
let image = emoji.image;
let aliases = emoji.aliases.slice();
let ascii = emoji.ascii.slice();

export function reset(): void {
name = emoji.name;
image = emoji.image;
aliases = emoji.aliases.slice();
ascii = emoji.ascii.slice();
}

let imageForm: HTMLFormElement;
let imageInput: HTMLInputElement;
let fileNameInput: HTMLInputElement;

let newAlias = '';
function removeAlias(a: string) {
aliases = aliases.filter(x => x !== a);
}
function addAlias() {
if (!newAlias || aliases.includes(newAlias)) {
return;
}

aliases = [...aliases, newAlias];
newAlias = '';
}

let newAscii = '';
function removeAscii(a: string) {
ascii = ascii.filter(x => x !== a);
}
function addAscii() {
if (!newAscii || ascii.includes(newAscii)) {
return;
}

ascii = [...ascii, newAscii];
newAscii = '';
}

const empty = !emoji.name && !emoji.image && !emoji.aliases?.length && !emoji.ascii?.length;
let editing = false;
$: editing = !deepEquals({
name,
image,
aliases,
ascii,
}, emoji);

const failures: Failures = {
nameRequired: false,
imageRequired: false,
nameInvalid: false,
aliasInvalid: false,
any: false,
};
const pattern = /[^a-z\-.+0-9_]/i;
$: failures.nameRequired = !name;
$: failures.imageRequired = !image;
$: failures.nameInvalid = pattern.test(name);
$: failures.aliasInvalid = pattern.test(newAlias);
$: failures.any = (
failures.nameRequired ||
failures.imageRequired ||
failures.nameInvalid ||
failures.aliasInvalid
);

let canSave = false;
$: canSave = editing && !failures.any;

function editImage() {
imageInput.click();

jQuery(imageInput).one('change', () => {
if (!imageInput.files.length) {
return;
}

const fileName = `${utils.generateUUID()}-${imageInput.files[0].name}`;
fileNameInput.value = fileName;

jQuery(imageForm).ajaxSubmit({
headers: {
'x-csrf-token': config.csrf_token,
},
success: () => {
image = fileName;
imageInput.value = '';
},
error: () => {
const err = Error('Failed to upload file');
console.error(err);
app.alertError(err);
imageInput.value = '';
},
});
});
}

const dispatch = createEventDispatcher();
function onSave() {
dispatch('save', {
id,
emoji: {
name,
image,
aliases,
ascii,
},
});
}

let deleting = false;
let deleted = false;

function onDelete() {
deleting = true;
}
function confirmDelete() {
deleting = false;
deleted = true;

setTimeout(() => {
dispatch('delete', {
id,
});
}, 250);
}
function cancelDelete() {
deleting = false;
}
</script>

<tr class:fadeout={deleted}>
<td>
<input
type="text"
class="form-control emoji-name"
bind:value={name}
/>
</td>
<td>
<button
type="button"
class="btn btn-default"
on:click={editImage}
>{@html buildEmoji({
character: '',
pack: 'customizations',
keywords: [],
name,
aliases,
image,
})}</button>
<form
action={`${config.relative_path}/api/admin/plugins/emoji/upload`}
method="post"
encType="multipart/form-data"
style="display: none;"
bind:this={imageForm}
>
<input
type="file"
name="emojiImage"
accept="image/*"
bind:this={imageInput}
/>
<input
type="hidden"
name="fileName"
bind:this={fileNameInput}
/>
</form>
</td>
<td>
<div class="input-group">
<input
type="text"
class="form-control"
bind:value={newAlias}
/>
<div class="input-group-addon"><button class="btn btn-default" on:click={addAlias}>+</button></div>
</div>
<span>
{#each aliases as a}
<button class="btn btn-info btn-xs" on:click={() => removeAlias(a)}>{a} x</button>
{/each}
</span>
</td>
<td>
<div class="input-group">
<input
type="text"
class="form-control"
bind:value={newAscii}
/>
<div class="input-group-addon"><button class="btn btn-default" on:click={addAscii}>+</button></div>
</div>
<span>
{#each ascii as a}
<button class="btn btn-info btn-xs" on:click={() => removeAscii(a)}>{a} x</button>
{/each}
</span>
</td>
<td>
{#if editing || empty}
<button
class="btn btn-success"
type="button"
on:click={onSave}
disabled={!canSave}
>
<i class="fa fa-check"></i>
</button>
{:else}
<button
class="btn btn-warning"
type="button"
on:click={onDelete}
disabled={deleting || deleted}
>
<i class="fa fa-trash"></i>
</button>
{/if}
</td>
</tr>

{#if deleting || deleted}
<tr class:fadeout={deleted}>
<td>
<button class="btn btn-default" type="button" disabled={deleted} on:click={cancelDelete}>Cancel</button>
</td>
<td colSpan={3}>
<span class="help-block">Are you sure you want to delete this emoji?</span>
</td>
<td>
<button class="btn btn-danger" type="button" disabled={deleted} on:click={confirmDelete}>Yes</button>
</td>
</tr>
{/if}

{#if editing && failures.nameRequired}
<tr class="text-danger">
<td colSpan={5}>
<span><strong>Name</strong> is required</span>
</td>
</tr>
{/if}

{#if editing && failures.imageRequired}
<tr class="text-danger">
<td colSpan={5}>
<span><strong>Image</strong> is required</span>
</td>
</tr>
{/if}

{#if editing && failures.nameInvalid}
<tr class="text-danger">
<td colSpan={5}>
<span><strong>Name</strong> can only contain letters, numbers, and <code>_-+.</code></span>
</td>
</tr>
{/if}

{#if editing && failures.aliasInvalid}
<tr class="text-danger">
<td colSpan={5}>
<span><strong>Aliases</strong> can only contain letters, numbers, and <code>_-+.</code></span>
</td>
</tr>
{/if}

<style>
tr {
opacity: 1;
transition: opacity 200ms ease-in-out;
}
tr.fadeout {
opacity: 0;
}
input.emoji-name {
max-width: 125px;
}
</style>

68
acp/src/EmojiList.svelte Normal file
View file

@ -0,0 +1,68 @@
<script lang="ts">
import api from 'api';
import app from 'app';
import Emoji from './Emoji.svelte';

export let emojis: {
[id: number]: CustomEmoji,
};

let emojiList: { id: number, emoji: CustomEmoji }[];
$: {
emojiList = Object.keys(emojis).map(key => ({ id: parseInt(key, 10), emoji: emojis[key] }));
}

function onEdit(event: CustomEvent<{ id: number, emoji: CustomEmoji }>) {
const { id, emoji } = event.detail;
api.put(`/admin/plugins/emoji/customizations/emoji/${id}`, { item: emoji }).then(() => {
emojis = {
...emojis,
[id]: emoji,
};
}, () => app.alertError());
}
function onDelete(event: CustomEvent<{ id: number }>) {
const { id } = event.detail;
api.del(`/admin/plugins/emoji/customizations/emoji/${id}`, {}).then(() => {
delete emojis[id];
emojis = { ...emojis };
}, () => app.alertError());
}

const blank = {
name: '',
image: '',
aliases: [],
ascii: [],
};

let resetNew: () => void;
let newEmoji = { ...blank };
function onAdd(event: CustomEvent<{ id: -1, emoji: CustomEmoji }>) {
const { emoji } = event.detail;

api.post('/admin/plugins/emoji/customizations/emoji', { item: emoji }).then(({ id }) => {
emojis = {
...emojis,
[id]: emoji,
};

newEmoji = { ...blank };
resetNew();
}, () => app.alertError());
}
</script>

<table class="table">
<thead>
<tr><th>Name</th><th>Image</th><th>Aliases</th><th>ASCII patterns</th><th></th></tr>
</thead>
<tbody>
{#each emojiList as item (item.id)}
<Emoji {...item} on:save={onEdit} on:delete={onDelete} />
{/each}
</tbody>
<tfoot>
<Emoji bind:reset={resetNew} id={-1} emoji={newEmoji} on:save={onAdd} />
</tfoot>
</table>

69
acp/src/ItemList.svelte Normal file
View file

@ -0,0 +1,69 @@
<script lang="ts">
import api from 'api';
import app from 'app';
import Adjunct from './Adjunct.svelte';

type ItemType = CustomAdjunct;
export let type = 'adjunct';
export let record: {
[id: number]: ItemType,
};

let list: { id: number, item: ItemType }[];
$: {
list = Object.keys(record).map(key => ({ id: parseInt(key, 10), item: record[key] }));
}

function onEdit(event: CustomEvent<{ id: number, item: CustomAdjunct }>) {
const { id, item } = event.detail;
api.put(`/admin/plugins/emoji/customizations/${type}/${id}`, { item }).then(() => {
record = {
...record,
[id]: item,
};
}, () => app.alertError());
}
function onDelete(event: CustomEvent<{ id: number }>) {
const { id } = event.detail;
api.del(`/admin/plugins/emoji/customizations/${type}/${id}`, {}).then(() => {
delete record[id];
record = { ...record };
}, () => app.alertError());
}

const blank = {
name: '',
aliases: [],
ascii: [],
};

let resetNew: () => void;
let newItem = { ...blank };
function onAdd(event: CustomEvent<{ id: -1, item: CustomAdjunct }>) {
const { item } = event.detail;

api.post(`/admin/plugins/emoji/customizations/${type}`, { item }).then(({ id }) => {
record = {
...record,
[id]: item,
};

newItem = { ...blank };
resetNew();
}, () => app.alertError());
}
</script>

<table class="table">
<thead>
<tr><th>Name</th><th>Image</th><th>Aliases</th><th>ASCII patterns</th><th></th></tr>
</thead>
<tbody>
{#each list as item (item.id)}
<Adjunct {...item} on:save={onEdit} on:delete={onDelete} />
{/each}
</tbody>
<tfoot>
<Adjunct bind:reset={resetNew} id={-1} item={newItem} on:save={onAdd} />
</tfoot>
</table>

105
acp/src/Settings.svelte Normal file
View file

@ -0,0 +1,105 @@
<script lang="ts">
import api from 'api';
import app from 'app';
import { init as initEmoji } from 'emoji';

import Customize from './Customize.svelte';
import Translate from './Translate.svelte';

export let settings: Settings;

function updateSettings() {
api.put('/admin/plugins/emoji/settings', settings).then(
() => app.alertSuccess(),
err => app.alertError(err)
);
}

function buildAssets() {
api.put('/admin/plugins/emoji/build', {}).then(
() => app.alertSuccess(),
err => app.alertError(err)
);
}

let openCustomize: () => void;

interface CustomizationsData {
emojis: {
[id: number]: CustomEmoji
};
adjuncts: {
[id: number]: CustomAdjunct
}
}

let customizationsData: Promise<CustomizationsData>;
function getCustomizations(): Promise<CustomizationsData> {
return api.get('/admin/plugins/emoji/customizations', {});
}
function showCustomize() {
customizationsData = customizationsData || Promise.all([
getCustomizations(),
initEmoji(),
]).then(([data]) => data);
customizationsData.then(() => setTimeout(() => openCustomize(), 0));
}
</script>

<form id="emoji-settings">
<div class="panel panel-default">
<div class="panel-body">
<div class="form-group">
<label for="emoji-parseAscii">
<input id="emoji-parseAscii" type="checkbox" bind:checked={settings.parseAscii} />
<Translate src="[[admin/plugins/emoji:settings.parseAscii]]"/>
</label>
</div>

<div class="form-group">
<label for="emoji-parseNative">
<input id="emoji-parseNative" type="checkbox" bind:checked={settings.parseNative} />
<Translate src="[[admin/plugins/emoji:settings.parseNative]]"/>
</label>
</div>

<div class="form-group">
<label for="emoji-customFirst">
<input id="emoji-customFirst" type="checkbox" bind:checked={settings.customFirst} />
<Translate src="[[admin/plugins/emoji:settings.customFirst]]"/>
</label>
</div>
</div>

<div class="panel-footer">
<div class="form-group">
<button type="button" on:click={buildAssets} class="btn btn-primary" aria-describedby="emoji-build_description"><Translate src="[[admin/plugins/emoji:build]]"/></button>
<p id="emoji-build_description" class="help-block">
<Translate src="[[admin/plugins/emoji:build_description]]"/>
</p>
</div>
</div>
</div>
</form>

{#if customizationsData}
{#await customizationsData then customizations}
<Customize bind:show={openCustomize} data={customizations} />
{/await}
{/if}

<button on:click={updateSettings} class="floating-button mdl-button mdl-js-button mdl-button--fab mdl-js-ripple-effect mdl-button--colored">
<i class="material-icons">save</i>
</button>

<button on:click={showCustomize} class="edit floating-button mdl-button mdl-js-button mdl-button--fab mdl-js-ripple-effect mdl-button--colored">
<i class="material-icons">edit</i>
</button>

<style>
button.floating-button.edit {
left: 30px;
margin-left: 0;
background: #ff4081 !important;
}
</style>

11
acp/src/Translate.svelte Normal file
View file

@ -0,0 +1,11 @@
<script lang="ts">
import { Translator } from 'translator';

const translator = Translator.create();

export let src: string;
</script>

{#await translator.translate(src) then translated}
{@html translated}
{/await}

16
acp/src/admin.ts Normal file
View file

@ -0,0 +1,16 @@
import jQuery from 'jquery';
import ajaxify from 'ajaxify';

import Settings from './Settings.svelte';

jQuery(window).on('action:ajaxify.end', () => {
if (ajaxify.data.template['admin/plugins/emoji']) {
// eslint-disable-next-line no-new
new Settings({
target: document.getElementById('content'),
props: {
settings: ajaxify.data.settings,
},
});
}
});

64
acp/src/modules.d.ts vendored Normal file
View file

@ -0,0 +1,64 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

interface JQuery {
ajaxSubmit: any;
draggable: any;
modal: any;
}

module 'ajaxify' {
const ajaxify: {
data: any;
};
export default ajaxify;
}

module 'textcomplete' {
const Textcomplete: any;
export default Textcomplete;
}

module 'config' {
const config: {
relative_path: string;
'cache-buster': string;
emojiCustomFirst: boolean;
csrf_token: string;
};
export default config;
}

declare module 'app' {
const app: {
alertSuccess(message?: string): void;
alertError(message?: string): void;
alertError(error?: Error): void;
};
export default app;
}

declare module 'api' {
const api: {
get(route: string, payload: NonNullable<unknown>): Promise<any>;
head(route: string, payload: NonNullable<unknown>): Promise<any>;
post(route: string, payload: NonNullable<unknown>): Promise<any>;
put(route: string, payload: NonNullable<unknown>): Promise<any>;
del(route: string, payload: NonNullable<unknown>): Promise<any>;
};
export default api;
}

declare module 'translator' {
export class Translator {
public static create(lang?: string): Translator;

public translate(input: string): Promise<string>;
}
}

declare module 'utils' {
const utils: {
generateUUID(): string;
};
export default utils;
}

12
acp/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",

"include": ["src/**/*", "../lib/types.d.ts"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"],

"compilerOptions": {
"paths": {
"emoji": ["../public/lib/emoji"]
}
},
}

View file

@ -10,7 +10,7 @@ import { uniq } from 'lodash';

import * as cssBuilders from './css-builders';
import { clearCache } from './parse';
import { getCustomizations } from './customizations';
import { getAll as getCustomizations } from './customizations';

const nconf = require.main.require('nconf');
const winston = require.main.require('winston');
@ -30,7 +30,7 @@ export const charactersFile = join(assetsDir, 'characters.json');
export const categoriesFile = join(assetsDir, 'categories.json');
export const packsFile = join(assetsDir, 'packs.json');

export default async function build() {
export default async function build(): Promise<void> {
winston.verbose('[emoji] Building emoji assets');

// fetch the emoji definitions
@ -116,7 +116,7 @@ export default async function build() {
categoriesInfo[category] = uniq(categoriesInfo[category]);
});

customizations.emojis.forEach((emoji) => {
Object.values(customizations.emojis).forEach((emoji) => {
const name = emoji.name.toLowerCase();

table[name] = {
@ -144,7 +144,7 @@ export default async function build() {
categoriesInfo.custom = categoriesInfo.custom || [];
categoriesInfo.custom.push(name);
});
customizations.adjuncts.forEach((adjunct) => {
Object.values(customizations.adjuncts).forEach((adjunct) => {
const name = adjunct.name;
if (!table[name]) { return; }


View file

@ -5,16 +5,19 @@ import multer from 'multer';

import * as settings from './settings';
import { build } from './pubsub';
import * as customizations from './customizations';

const nconf = require.main.require('nconf');
const { setupApiRoute } = require.main.require('./src/routes/helpers');
const { formatApiResponse } = require.main.require('./src/controllers/helpers');

// eslint-disable-next-line import/no-dynamic-require
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires
const version: string = require(join(__dirname, '../../package.json')).version;

export default function controllers({ router, middleware }: {
router: Router;
middleware: { admin: { [key: string]: RequestHandler } };
}) {
middleware: { authenticate: RequestHandler; admin: { [key: string]: RequestHandler } };
}): void {
const renderAdmin: RequestHandler = (req, res, next) => {
settings.get().then(sets => setImmediate(() => {
res.render('admin/plugins/emoji', {
@ -44,6 +47,66 @@ export default function controllers({ router, middleware }: {
};
router.get('/api/admin/plugins/emoji/build', adminBuild);

const updateSettings: RequestHandler = async (req, res) => {
const data = req.body;
await settings.set({
parseAscii: !!data.parseAscii,
parseNative: !!data.parseNative,
customFirst: !!data.customFirst,
});
formatApiResponse(200, res);
};
setupApiRoute(router, 'put', '/api/v3/admin/plugins/emoji/settings', [middleware.authenticate, middleware.admin.checkPrivileges], updateSettings);

const buildAssets: RequestHandler = async (req, res) => {
await build();
formatApiResponse(200, res);
};
setupApiRoute(router, 'put', '/api/v3/admin/plugins/emoji/build', [middleware.authenticate, middleware.admin.checkPrivileges], buildAssets);

const provideCustomizations: RequestHandler = async (req, res) => {
const data = await customizations.getAll();
formatApiResponse(200, res, data);
};
setupApiRoute(router, 'get', '/api/v3/admin/plugins/emoji/customizations', [middleware.authenticate, middleware.admin.checkPrivileges], provideCustomizations);

const addCustomization: RequestHandler = async (req, res) => {
const type = req.params.type;
const item = req.body.item;
if (!['emoji', 'adjunct'].includes(type)) {
formatApiResponse(400, res);
return;
}
const id = await customizations.add({ type, item });
formatApiResponse(200, res, { id });
};
setupApiRoute(router, 'post', '/api/v3/admin/plugins/emoji/customizations/:type', [middleware.authenticate, middleware.admin.checkPrivileges], addCustomization);

const editCustomization: RequestHandler = async (req, res) => {
const id = parseInt(req.params.id, 10);
const type = req.params.type;
const item = req.body.item;
if (!['emoji', 'adjunct'].includes(type)) {
formatApiResponse(400, res);
return;
}
await customizations.edit({ type, id, item });
formatApiResponse(200, res);
};
setupApiRoute(router, 'put', '/api/v3/admin/plugins/emoji/customizations/:type/:id', [middleware.authenticate, middleware.admin.checkPrivileges], editCustomization);

const deleteCustomization: RequestHandler = async (req, res) => {
const id = parseInt(req.params.id, 10);
const type = req.params.type;
if (!['emoji', 'adjunct'].includes(type)) {
formatApiResponse(400, res);
return;
}
await customizations.remove({ type, id });
formatApiResponse(200, res);
};
setupApiRoute(router, 'delete', '/api/v3/admin/plugins/emoji/customizations/:type/:id', [middleware.authenticate, middleware.admin.checkPrivileges], deleteCustomization);

const uploadEmoji: RequestHandler = (req, res, next) => {
if (!req.file) {
res.sendStatus(400);

View file

@ -4,7 +4,7 @@ const buster = require.main.require('./src/meta').config['cache-buster'];
const nconf = require.main.require('nconf');
const url = nconf.get('url');

export function images(pack: EmojiDefinition) {
export function images(pack: EmojiDefinition): string {
return `.emoji-${pack.id} {` +
'display: inline-block;' +
'height: 23px;' +

View file

@ -1,6 +1,3 @@
import hash from 'string-hash';

const adminSockets = require.main.require('./src/socket.io/admin');
const db = require.main.require('./src/database');

const emojisKey = 'emoji:customizations:emojis';
@ -10,52 +7,38 @@ interface SortedResult {
value: string;
score: number;
}
export const getCustomizations = async (): Promise<Customizations> => {
export async function getAll(): Promise<Customizations> {
const [emojis, adjuncts]: [SortedResult[], SortedResult[]] = await Promise.all([
db.getSortedSetRangeWithScores(emojisKey, 0, -1),
db.getSortedSetRangeWithScores(adjunctsKey, 0, -1),
]);

const emojisParsed: CustomEmoji[] = emojis.map(emoji => JSON.parse(emoji.value));
const adjunctsParsed: CustomAdjunct[] = adjuncts.map(adjunct => JSON.parse(adjunct.value));

return {
emojis: emojisParsed,
adjuncts: adjunctsParsed,
emojis: Object.fromEntries(emojis.map(({ value, score }) => [score, JSON.parse(value)])),
adjuncts: Object.fromEntries(adjuncts.map(({ value, score }) => [score, JSON.parse(value)])),
};
};
}

const editThing = async (key: string, name: string, thing: CustomEmoji | CustomAdjunct) => {
const num = hash(name);
await db.sortedSetsRemoveRangeByScore([key], num, num);
export async function add({ type, item }: { type: string, item: unknown }): Promise<string> {
const key = type === 'emoji' ? emojisKey : adjunctsKey;
// get maximum score from set
const [result] = await db.getSortedSetRevRangeWithScores(key, 0, 1);
const lastId = (result && result.score) || 1;
const id = lastId + 1;
await db.sortedSetAdd(key, id, JSON.stringify(item));
return id;
}

const thingNum = hash(thing.name);
await db.sortedSetAdd(key, thingNum, JSON.stringify(thing));
};
export async function edit({ type, id, item }: {
type: string,
id: number,
item: unknown,
}): Promise<void> {
const key = type === 'emoji' ? emojisKey : adjunctsKey;
await db.sortedSetsRemoveRangeByScore([key], id, id);
await db.sortedSetAdd(key, id, JSON.stringify(item));
}

const deleteThing = async (key: string, name: string) => {
const num = hash(name);
await db.sortedSetsRemoveRangeByScore([key], num, num);
};

const emojiSockets = {
getCustomizations,
editEmoji: async (
socket: SocketIO.Socket,
[name, emoji]: [string, CustomEmoji]
) => editThing(emojisKey, name, emoji),
deleteEmoji: async (
socket: SocketIO.Socket,
name: string
) => deleteThing(emojisKey, name),
editAdjunct: async (
socket: SocketIO.Socket,
[name, adjunct]: [string, CustomAdjunct]
) => editThing(adjunctsKey, name, adjunct),
deleteAdjunct: async (
socket: SocketIO.Socket,
name: string
) => deleteThing(adjunctsKey, name),
};

adminSockets.plugins.emoji = emojiSockets;
export async function remove({ type, id }: { type: string, id: number }): Promise<void> {
const key = type === 'emoji' ? emojisKey : adjunctsKey;
await db.sortedSetsRemoveRangeByScore([key], id, id);
}

View file

@ -1,17 +1,15 @@
import { access } from 'fs-extra';

import * as settings from './settings';
import * as plugins from './plugins';
import * as parse from './parse';
import { tableFile } from './build';
import { build } from './pubsub';
import controllers from './controllers';
import './customizations';

const nconf = require.main.require('nconf');
const buster = require.main.require('./src/meta').config['cache-buster'];

const init = async (params: any): Promise<void> => {
export async function init(params: any): Promise<void> {
controllers(params);

const sets = await settings.get();
@ -39,35 +37,35 @@ const init = async (params: any): Promise<void> => {
if (shouldBuild) {
await build();
}
};
}

const adminMenu = (header: {
export async function adminMenu<Payload extends {
plugins: { route: string; icon: string; name: string }[];
}, callback: NodeBack) => {
}>(header: Payload): Promise<Payload> {
header.plugins.push({
route: '/plugins/emoji',
icon: 'fa-smile-o',
name: 'Emoji',
});
callback(null, header);
};
return header;
}

const composerFormatting = (data: {
export async function composerFormatting<Payload extends {
options: { name: string; className: string; title: string }[];
}, callback: NodeBack) => {
}>(data: Payload): Promise<Payload> {
data.options.push({
name: 'emoji-add-emoji',
className: 'fa fa-smile-o emoji-add-emoji',
title: '[[emoji:composer.title]]',
});
callback(null, data);
};
return data;
}

const addStylesheet = (data: {
export async function addStylesheet<Payload extends {
links: {
rel: string; type?: string; href: string;
}[];
}, callback: NodeBack) => {
}>(data: Payload): Promise<Payload> {
const rel = nconf.get('relative_path');

data.links.push({
@ -75,22 +73,16 @@ const addStylesheet = (data: {
href: `${rel}/plugins/nodebb-plugin-emoji/emoji/styles.css?${buster}`,
});

callback(null, data);
};
return data;
}

const configGet = async (config: any): Promise<any> => {
export async function configGet(config: any): Promise<any> {
const customFirst = await settings.getOne('customFirst');
// eslint-disable-next-line no-param-reassign
config.emojiCustomFirst = customFirst;
return config;
};
}

export {
init,
adminMenu,
composerFormatting,
plugins,
parse,
addStylesheet,
configGet,
};

View file

@ -15,7 +15,7 @@ let metaCache: {
characters: MetaData.Characters;
charPattern: RegExp;
} = null;
export function clearCache() {
export function clearCache(): void {
metaCache = null;
}

@ -72,7 +72,7 @@ const outsideCode = /(^|<\/code>)([^<]*|<(?!code[^>]*>))*(<code[^>]*>|$)/g;
const outsideElements = /(<[^>]*>)?([^<>]*)/g;
const emojiPattern = /:([a-z\-.+0-9_]+):/g;

export const buildEmoji = (emoji: StoredEmoji, whole: string) => {
export const buildEmoji = (emoji: StoredEmoji, whole: string): string => {
if (emoji.image) {
const route = `${url}/plugins/nodebb-plugin-emoji/emoji/${emoji.pack}`;
return `<img
@ -124,7 +124,7 @@ const options: ParseOptions = {
native: false,
};

export function setOptions(newOptions: ParseOptions) {
export function setOptions(newOptions: ParseOptions): void {
Object.assign(options, newOptions);
}


View file

@ -1,34 +0,0 @@
import { readFile } from 'fs-extra';
import { join } from 'path';

import { build } from './pubsub';

const nconf = require.main.require('nconf');
const baseDir = nconf.get('base_dir');

// build when a plugin is (de)activated if that plugin is an emoji pack
const toggle = async ({ id }: { id: string }) => {
let file;
try {
file = await readFile(join(baseDir, 'node_modules', id, 'plugin.json'), 'utf8');
} catch (err) {
if (err && err.code !== 'ENOENT') {
throw err;
}
return;
}

const plugin = JSON.parse(file);

const hasHook = plugin.hooks && plugin.hooks
.some((hook: { hook: string }) => hook.hook === 'filter:emoji.packs');

if (hasHook) {
await build();
}
};

export {
toggle as activation,
toggle as deactivation,
};

View file

@ -8,7 +8,7 @@ const pubsub = require.main.require('./src/pubsub');

const primary = nconf.get('isPrimary') === 'true' || nconf.get('isPrimary') === true;

export async function build() {
export async function build(): Promise<void> {
if (pubsub.pubClient) {
pubsub.publish('emoji:build', {
hostname: hostname(),

View file

@ -1,74 +1,54 @@
const settings: {
get(key: string): Promise<{ [key: string]: any }>;
set(key: string, value: any): Promise<void>;
getOne(key: string, field: string): Promise<any>;
setOne(key: string, field: string, value: any): Promise<void>;
get(key: string): Promise<{ [key: string]: unknown } | null>;
set(key: string, value: unknown): Promise<void>;
getOne(key: string, field: string): Promise<unknown>;
setOne(key: string, field: string, value: unknown): Promise<void>;
} = require.main.require('./src/meta').settings;

interface Settings {
parseNative: boolean;
parseAscii: boolean;
customFirst: boolean;
}

const defaults: Settings = {
parseNative: true,
parseAscii: true,
customFirst: false,
};

const get = async (): Promise<{ [key: string]: any }> => {
const data = await settings.get('emoji');
const sets: Partial<Settings> = {};

Object.keys(defaults).forEach((key: keyof Settings) => {
const defaultVal = defaults[key];
const str = data[key];

if (typeof str !== 'string') {
sets[key] = defaultVal;
return;
}

const val = JSON.parse(str);
if (typeof val !== typeof defaultVal) {
sets[key] = defaultVal;
return;
}

sets[key] = val;
});

return sets;
};
const set = async (data: {
[key: string]: any;
}) => {
const sets: Partial<Record<keyof Settings, string>> = {};
Object.keys(data).forEach((key: keyof Settings) => {
sets[key] = JSON.stringify(data[key]);
});

await settings.set('emoji', sets);
};
const getOne = async (field: keyof Settings): Promise<any> => {
const str = await settings.getOne('emoji', field);

const defaultVal = defaults[field];
let val = JSON.parse(str);
if (typeof val !== typeof defaultVal) {
val = defaultVal;
function fromStore<
K extends keyof Settings
>(key: K, x: unknown): Settings[K] {
if (typeof x === typeof defaults[key]) {
return x as Settings[K];
}
if (typeof x === 'string') {
try {
return JSON.parse(x) ?? defaults[key];
} catch {
return defaults[key];
}
}
return defaults[key];
}

return val;
};
const setOne = async (field: string, value: any) => {
export async function get(): Promise<Settings> {
const data = await settings.get('emoji');

return {
parseNative: fromStore('parseNative', data?.parseNative),
parseAscii: fromStore('parseAscii', data?.parseAscii),
customFirst: fromStore('customFirst', data?.customFirst),
};
}
export async function set(data: Settings): Promise<void> {
await settings.set('emoji', {
parseNative: JSON.stringify(data.parseNative),
parseAscii: JSON.stringify(data.parseAscii),
customFirst: JSON.stringify(data.customFirst),
});
}
export async function getOne<K extends keyof Settings>(field: K): Promise<Settings[K]> {
const val = await settings.getOne('emoji', field);
return fromStore(field, val);
}
export async function setOne<
K extends keyof Settings
>(field: K, value: Settings[K]): Promise<void> {
await settings.setOne('emoji', field, JSON.stringify(value));
};

export {
get,
set,
getOne,
setOne,
};
}

15
lib/types.d.ts vendored
View file

@ -191,11 +191,16 @@ interface CustomAdjunct {
}

interface Customizations {
emojis: CustomEmoji[];
adjuncts: CustomAdjunct[];
emojis: {
[id: number]: CustomEmoji
};
adjuncts: {
[id: number]: CustomAdjunct
}
}

declare module 'string-hash' {
const hash: (str: string) => number;
export = hash;
interface Settings {
parseNative: boolean;
parseAscii: boolean;
customFirst: boolean;
}

View file

@ -11,7 +11,7 @@
"url": "https://github.com/NodeBB/nodebb-plugin-emoji.git"
},
"nbbpm": {
"compatibility": "^1.17.0"
"compatibility": "~1.16.0 || ~1.17.0"
},
"keywords": [
"nodebb",
@ -21,33 +21,46 @@
],
"dependencies": {
"fs-extra": "^9.1.0",
"lodash": "^4.17.20",
"multer": "^1.4.2",
"preact": "^10.5.12",
"string-hash": "^1.1.3"
"lodash": "^4.17.21",
"multer": "^1.4.2"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.1.0",
"@rollup/plugin-node-resolve": "^11.2.0",
"@rollup/plugin-typescript": "^8.2.0",
"@rollup/plugin-virtual": "^2.0.3",
"@tsconfig/svelte": "^1.0.10",
"@types/bootstrap": "^3.4.0",
"@types/express": "^4.17.11",
"@types/fs-extra": "^9.0.6",
"@types/fs-extra": "^9.0.7",
"@types/lodash": "^4.14.168",
"@types/multer": "^1.4.5",
"@types/nconf": "^0.10.0",
"@types/node": "^14.14.24",
"@types/node": "^14.14.31",
"@types/semver": "^7.3.4",
"@types/socket.io": "^2.1.13",
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"eslint": "^7.19.0",
"eslint-config-airbnb-base": "^14.2.1",
"@typescript-eslint/eslint-plugin": "^4.15.1",
"@typescript-eslint/parser": "^4.15.1",
"eslint": "^7.20.0",
"eslint-config-airbnb-base": "14.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-svelte3": "^3.1.1",
"rollup": "^2.39.0",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.0",
"semver": "^7.3.4",
"typescript": "^4.1.3"
"svelte": "^3.23.3",
"svelte-check": "^1.1.35",
"svelte-preprocess": "^4.6.9",
"tslib": "^2.1.0",
"typescript": "^4.1.5"
},
"scripts": {
"lint": "eslint lib/* public/**/*.ts public/**/*.js public/**/*.tsx",
"lint": "eslint . && svelte-check",
"compile": "tsc -p . && tsc -p public",
"pretest": "npm run lint && npm run compile",
"dev": "npm run compile && cd acp && rollup -c -w",
"build": "npm run compile && cd acp && rollup -c",
"pretest": "npm run lint && npm run build",
"test": "node build/lib/tests.js",
"prepare": "rm -r build; npm run test && mkdir -p build/emoji && touch build/emoji/avoid_npm_ignore"
}

View file

@ -1,5 +1,5 @@
module.exports = {
rules: {
'@typescript-eslint/no-var-requires': ['off']
}
};
'@typescript-eslint/no-var-requires': ['off'],
},
};

View file

@ -3,12 +3,13 @@
"less": [
"public/style.less"
],
"acpLess": [
"acp/admin.less"
],
"modules": {
"emoji.js": "build/public/lib/emoji.js",
"emoji-dialog.js": "build/public/lib/emoji-dialog.js",
"preact.js": "node_modules/preact/dist/preact.umd.js",
"preact/devtools.js": "node_modules/preact/devtools/dist/devtools.umd.js",
"custom-emoji.js": "build/public/lib/admin/custom-emoji.js"
"../admin/plugins/emoji.js": "build/acp/admin.js"
},
"staticDirs": {
"emoji": "build/emoji"
@ -17,7 +18,6 @@
"public/emoji-setup.js"
],
"acpScripts": [
"public/admin.js",
"public/emoji-setup.js"
],
"languages": "public/language",
@ -29,8 +29,6 @@
{ "hook": "filter:composer.formatting", "method": "composerFormatting", "priority": 19 },
{ "hook": "filter:parse.raw", "method": "parse.raw", "priority": 9 },
{ "hook": "filter:parse.post", "method": "parse.post", "priority": 9 },
{ "hook": "action:plugin.activate", "method": "plugins.activation" },
{ "hook": "action:plugin.deactivate", "method": "plugins.deactivation" },
{ "hook": "filter:meta.getLinkTags", "method": "addStylesheet" },
{ "hook": "filter:config.get", "method": "configGet" }
]

View file

@ -10,8 +10,6 @@ module.exports = {
app: true,
config: true,
Textcomplete: true,
},
rules: {
'max-classes-per-file': 'off',
ajaxify: true,
},
};

View file

@ -1,50 +0,0 @@
/* eslint-disable prefer-arrow-callback, func-names, strict, no-var */
/* eslint-disable vars-on-top, no-plusplus, no-bitwise, no-multi-assign */
/* eslint-disable no-nested-ternary, no-labels, no-restricted-syntax */
/* eslint-disable no-continue, import/no-amd, import/no-dynamic-require */
/* eslint-disable prefer-template, global-require */

define('admin/plugins/emoji', [], function () {
$('#save').on('click', function () {
var settings = {
parseAscii: !!$('#emoji-parseAscii').prop('checked'),
parseNative: !!$('#emoji-parseNative').prop('checked'),
customFirst: !!$('#emoji-customFirst').prop('checked'),
};
$.get(window.config.relative_path + '/api/admin/plugins/emoji/save', { settings: JSON.stringify(settings) }, function () {
window.app.alertSuccess();
});
});

$('#build').on('click', function () {
$.get(window.config.relative_path + '/api/admin/plugins/emoji/build', function () {
window.app.alertSuccess();
});
});

require.config({
shim: {
preact: {
exports: 'preact',
},
},
});

var addedStyle = false;
$('#edit').click(function () {
require(['custom-emoji'], function (customEmoji) {
customEmoji.init(document.getElementById('editModalBody'), function () {
$('#editModal').modal({
backdrop: false,
show: true,
});
});
});
if (!addedStyle) {
addedStyle = true;
$('head').append(
'<style>@import "' + window.config.relative_path + '/plugins/nodebb-plugin-emoji/emoji/styles.css";</style>'
);
}
});
});

View file

@ -4,5 +4,5 @@
"settings.parseNative": "Replace native unicode emoji characters with emoji provided by emoji packs",
"settings.customFirst": "Place your custom emoji first in the dialog",
"build": "Build Emoji Assets",
"build_description": "Compile the necessary metadata and static assets from all emoji packs. <br> This is usually unnecessary to do yourself, but if emojis aren't showing up, try this first."
"build_description": "Compile the necessary metadata and static assets from all emoji packs. <br> After installing and activating an emoji pack, restart NodeBB, Build Emoji Assets, then Restart and Rebuild NodeBB."
}

View file

@ -4,5 +4,5 @@
"settings.parseNative": "Замените нативные символы смайликов в юникоде на смайлики, предоставляемые наборами смайликов",
"settings.customFirst": "Поместите свой собственный смайлик первым в диалоге",
"build": "Сборка эмодзи",
"build_description": "Компиляция необходимых метаданных и статических ресурсов из всех пакетов Emoji. <br> Обычно это не нужно делать самостоятельно, но если эмодзи не появляются, можно попробовать."
"build_description": "Компиляция необходимых метаданных и статических ресурсов из всех пакетов Emoji."
}

View file

@ -1,857 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */

import {
h,
Component,
FunctionalComponent,
render,
JSX,
} from 'preact';
import { strategy, table, buildEmoji, init as initEmoji } from 'emoji';

// import 'preact/devtools';

const setsEqual = (arr1: string[], arr2: string[]) => {
if (arr1.length !== arr2.length) {
return false;
}

const h1: { [val: string]: boolean } = {};
arr1.forEach((val) => { h1[val] = true; });

return arr2.every(val => h1[val]);
};

interface EmojiProps {
editing?: boolean;
canSave?: boolean;
emoji: CustomEmoji;
onSave: Callback;
onDelete: Callback;
onEditName: Callback<string>;
onEditImage: Callback<string>;
onEditAliases: Callback<string[]>;
onEditAscii: Callback<string[]>;
}
const Emoji: FunctionalComponent<EmojiProps> = ({
editing,
canSave,
emoji,
onSave,
onDelete,
onEditName,
onEditImage,
onEditAliases,
onEditAscii,
}) => {
let imageForm: HTMLFormElement;
let imageInput: HTMLInputElement;
let fileNameInput: HTMLInputElement;

const editImage = () => {
imageInput.click();

$(imageInput).one('change', () => {
if (!imageInput.files.length) {
return;
}

const fileName = `${utils.generateUUID()}-${imageInput.files[0].name}`;
fileNameInput.value = fileName;

$(imageForm).ajaxSubmit({
headers: {
'x-csrf-token': config.csrf_token,
},
success: () => {
onEditImage(fileName);
imageInput.value = '';
},
error: () => {
const err = Error('Failed to upload file');
console.error(err);
app.alertError(err);
imageInput.value = '';
},
});
});
};

return (
<tr>
<td>
<input
type="text"
className="form-control"
value={emoji.name}
onInput={(e: Event) => onEditName((e.target as HTMLInputElement).value)}
/>
</td>
<td>
<button
type="button"
className="btn btn-default"
onClick={editImage}
dangerouslySetInnerHTML={{
__html: buildEmoji({
character: '',
pack: 'customizations',
keywords: [],
name: emoji.name,
aliases: emoji.aliases,
image: emoji.image,
}),
}}
></button>
<form
action={`${config.relative_path}/api/admin/plugins/emoji/upload`}
method="post"
encType="multipart/form-data"
style={{ display: 'none' }}
ref={(form) => { imageForm = form as HTMLFormElement; }}
>
<input
type="file"
name="emojiImage"
accept="image/*"
ref={(input) => { imageInput = input as HTMLInputElement; }}
/>
<input
type="hidden"
name="fileName"
ref={(input) => { fileNameInput = input as HTMLInputElement; }}
/>
</form>
</td>
<td>
<input
type="text"
className="form-control"
value={emoji.aliases.join(',')}
onInput={(e: Event) => onEditAliases(
(e.target as HTMLInputElement).value.split(',')
)}
/>
</td>
<td>
<input
type="text"
className="form-control"
value={emoji.ascii.join(',')}
onInput={(e: Event) => onEditAscii(
(e.target as HTMLInputElement).value.split(',')
)}
/>
</td>
<td>
{
editing ? (
<button
className="btn btn-success"
type="button"
onClick={() => onSave(null)}
disabled={!canSave}
>
<i className="fa fa-check"></i>
</button>
) : (
<button
className="btn btn-warning"
type="button"
onClick={() => onDelete(null)}
>
<i className="fa fa-trash"></i>
</button>
)
}
</td>
</tr>
);
};

interface EmojiListProps {
onEdit: Callback<[string, CustomEmoji]>;
onDelete: Callback<string>;
emojis: CustomEmoji[];
}
interface EmojiListState {
/** The previous state before a save or deletion */
previous: CustomEmoji[];
emojis: CustomEmoji[];
messages: JSX.Element[];
newEmoji: CustomEmoji;
newEmojiMessage: JSX.Element;
}

const blankEmoji: CustomEmoji = {
name: '',
image: '',
aliases: [],
ascii: [],
};
class EmojiList extends Component<EmojiListProps, EmojiListState> {
private static equal(a: CustomEmoji, b: CustomEmoji) {
if (a === b) {
return true;
}

return (a.name === b.name) &&
(a.image === b.image) &&
setsEqual(a.aliases, b.aliases) &&
setsEqual(a.ascii, b.ascii);
}

private static validate(all: CustomEmoji[], emoji: CustomEmoji) {
const pattern = /^[a-z\-.+0-9_]*$/i;

const validations: {
fn: () => boolean;
message: JSX.Element;
}[] = [
{
fn: () => !!emoji.name,
message: (
<span><strong>Name</strong> is required</span>
),
},
{
fn: () => !!emoji.image,
message: (
<span><strong>Image</strong> is required</span>
),
},
{
fn: () => pattern.test(emoji.name),
message: (
<span><strong>Name</strong> can only contain letters,
numbers, and <code>_-+.</code></span>
),
},
{
fn: () => emoji.aliases.every(alias => pattern.test(alias)),
message: (
<span><strong>Aliases</strong> can only contain letters,
numbers, and <code>_-+.</code> (comma-separated)</span>
),
},
{
fn: () => all.every(({ name }) => emoji.name !== name),
message: (
<span>Multiple custom emojis cannot have the same <strong>Name</strong></span>
),
},
];

return validations.filter(validation => !validation.fn());
}

public constructor({ emojis }: EmojiListProps) {
super();

this.setState({
previous: emojis.slice(),
emojis: emojis.slice(),
messages: [],
newEmoji: blankEmoji,
newEmojiMessage: null,
});
}

private onAdd() {
const emojis = this.state.emojis.slice();
const previous = this.state.previous.slice();
const emoji = this.state.newEmoji;

emoji.aliases = emoji.aliases.filter(Boolean);
emoji.ascii = emoji.ascii.filter(Boolean);

emojis.push(emoji);
previous.push(emoji);

this.setState({
previous,
emojis,
newEmoji: blankEmoji,
});

this.props.onEdit([emoji.name, emoji]);
}

private onSave(i: number) {
const emojis = this.state.emojis.slice();
const previous = this.state.previous.slice();
const emoji = this.state.emojis[i];

emoji.aliases = emoji.aliases.filter(Boolean);
emoji.ascii = emoji.ascii.filter(Boolean);

const [old] = previous.splice(i, 1, emoji);
emojis.splice(i, 1, emoji);

this.setState({
previous,
emojis,
});

this.props.onEdit([old.name, emoji]);
}

private onDelete(i: number) {
const confirm = () => this.onConfirmDelete(i);
const nope = () => {
const messages = this.state.messages.slice();
messages[i] = null;

this.setState({
messages,
});
};

const messages = this.state.messages.slice();

messages[i] = (
<tr>
<td>
<button className="btn btn-default" type="button" onClick={nope}>Cancel</button>
</td>
<td colSpan={3}>
<span class="help-block">Are you sure you want to delete this emoji?</span>
</td>
<td>
<button className="btn btn-danger" type="button" onClick={confirm}>Yes</button>
</td>
</tr>
);
this.setState({
messages,
});
}

private onConfirmDelete(i: number) {
const emojis = this.state.emojis.slice();
const previous = this.state.previous.slice();
const [old] = previous.splice(i, 1);
emojis.splice(i, 1);

this.setState({
emojis,
previous,
});

this.props.onDelete(old.name);
}

private onEdit(i: number, emoji: CustomEmoji) {
const emojis = this.state.emojis.slice();
emojis.splice(i, 1, emoji);
this.setState({
emojis,
});
}

public render(_: EmojiListProps, {
previous,
emojis,
messages,
newEmoji,
newEmojiMessage,
}: EmojiListState) {
const rows: JSX.Element[] = [];
emojis.forEach((emoji, i) => {
const all = previous.slice();
all.splice(i, 1);

const failures = EmojiList.validate(all, emoji);

const props: EmojiProps = {
emoji,
onSave: () => this.onSave(i),
onDelete: () => this.onDelete(i),
onEditName: name => this.onEdit(i, { ...emoji, name }),
onEditImage: image => this.onEdit(i, { ...emoji, image }),
onEditAliases: aliases => this.onEdit(i, { ...emoji, aliases }),
onEditAscii: ascii => this.onEdit(i, { ...emoji, ascii }),
editing: !EmojiList.equal(emoji, previous[i]),
canSave: !failures.length,
};
rows.push(<Emoji {...props} key={i} />);
rows.push(...failures.map(({ message }) => (
<tr className="text-danger">
<td colSpan={5}>{message}</td>
</tr>
)));
if (messages[i]) {
rows.push(messages[i]);
}
});

const newEmojiFailures = EmojiList.validate(previous, newEmoji);

return (
<table className="table">
<thead>
<tr><th>Name</th><th>Image</th><th>Aliases</th><th>ASCII patterns</th><th></th></tr>
</thead>
<tbody>
{rows}
</tbody>
<tfoot>
<Emoji
emoji={newEmoji}
onSave={() => this.onAdd()}
onDelete={() => {}}
onEditName={name => this.setState({ newEmoji: { ...newEmoji, name } })}
onEditImage={image => this.setState({ newEmoji: { ...newEmoji, image } })}
onEditAliases={aliases => this.setState({ newEmoji: { ...newEmoji, aliases } })}
onEditAscii={ascii => this.setState({ newEmoji: { ...newEmoji, ascii } })}
editing
canSave={!newEmojiFailures.length}
/>
{EmojiList.equal(newEmoji, blankEmoji) ? null : newEmojiFailures.map(({ message }) => (
<tr className="text-danger">
<td colSpan={5}>{message}</td>
</tr>
))}
{newEmojiMessage}
</tfoot>
</table>
);
}
}

interface AdjunctProps {
editing?: boolean;
canSave?: boolean;
adjunct: CustomAdjunct;
onSave: Callback;
onDelete: Callback;
onEditName: Callback<string>;
onEditAliases: Callback<string[]>;
onEditAscii: Callback<string[]>;
}
class Adjunct extends Component<AdjunctProps, {}> {
private nameInput: Element;

public render({
editing,
canSave,
adjunct,
onSave,
onDelete,
onEditName,
onEditAliases,
onEditAscii,
}: AdjunctProps) {
const emoji = adjunct.name && table[adjunct.name];
return (
<tr>
<td>
<input
type="text"
className="form-control"
value={adjunct.name}
onInput={(e: Event) => onEditName((e.target as HTMLInputElement).value)}
ref={(input) => { this.nameInput = input; }}
/>
</td>
<td dangerouslySetInnerHTML={{ __html: emoji ? buildEmoji(emoji) : '' }}></td>
<td>
<input
type="text"
className="form-control"
value={adjunct.aliases.join(',')}
onInput={(e: Event) => onEditAliases(
(e.target as HTMLInputElement).value.split(',')
)}
/>
</td>
<td>
<input
type="text"
className="form-control"
value={adjunct.ascii.join(',')}
onInput={(e: Event) => onEditAscii(
(e.target as HTMLInputElement).value.split(',')
)}
/>
</td>
<td>
{
editing ? (
<button
className="btn btn-success"
type="button"
onClick={() => onSave(null)}
disabled={!canSave}
>
<i className="fa fa-check"></i>
</button>
) : (
<button
className="btn btn-warning"
type="button"
onClick={() => onDelete(null)}
>
<i className="fa fa-trash"></i>
</button>
)
}
</td>
</tr>
);
}

public componentDidMount() {
const { Textarea } = Textcomplete.editors;

const editor = new Textarea(this.nameInput);
const completer = new Textcomplete(editor, {
dropdown: {
style: { zIndex: 20000 },
},
});

completer.register([{
...strategy,
replace: (emoji: StoredEmoji) => emoji.name,
match: /^(.+)$/,
}]);

completer.on('selected', () => {
this.props.onEditName((this.nameInput as HTMLInputElement).value);
});
}
}

interface AdjunctListProps {
onEdit: Callback<[string, CustomAdjunct]>;
onDelete: Callback<string>;
adjuncts: CustomAdjunct[];
}
interface AdjunctListState {
/** the previous state before a save or deletion */
previous: CustomAdjunct[];
adjuncts: CustomAdjunct[];
messages: JSX.Element[];
newAdjunct: CustomAdjunct;
newAdjunctMessage: JSX.Element;
}

const blankAdjunct: CustomAdjunct = {
name: '',
aliases: [],
ascii: [],
};
class AdjunctList extends Component<AdjunctListProps, AdjunctListState> {
private static equal(a: CustomAdjunct, b: CustomAdjunct) {
if (a === b) {
return true;
}

return (a.name === b.name) &&
setsEqual(a.aliases, b.aliases) &&
setsEqual(a.ascii, b.ascii);
}

private static validate(all: CustomAdjunct[], emoji: CustomAdjunct) {
const pattern = /^[a-z\-.+0-9_]*$/i;

const validations: {
fn: () => boolean;
message: string;
}[] = [
{
fn: () => !!emoji.name,
message: '<strong>Name</strong> is required',
},
{
fn: () => !!table[emoji.name],
message: '<strong>Name</strong> must be an existing emoji',
},
{
fn: () => emoji.aliases.every(alias => pattern.test(alias)),
message: '<strong>Aliases</strong> can only contain ' +
'letters, numbers, and <code>_-+.</code> (comma-separated)',
},
{
fn: () => all.every(({ name }) => emoji.name !== name),
message: 'Multiple custom extensions cannot have the same <strong>Name</strong>',
},
];

return validations.filter(validation => !validation.fn());
}

public constructor({ adjuncts }: AdjunctListProps) {
super();

this.setState({
previous: adjuncts.slice(),
adjuncts: adjuncts.slice(),
messages: [],
newAdjunct: blankAdjunct,
newAdjunctMessage: null,
});
}

private onAdd() {
const adjuncts = this.state.adjuncts.slice();
const previous = this.state.previous.slice();
const adjunct = this.state.newAdjunct;

adjunct.aliases = adjunct.aliases.filter(Boolean);
adjunct.ascii = adjunct.ascii.filter(Boolean);

adjuncts.push(adjunct);
previous.push(adjunct);

this.setState({
previous,
adjuncts,
newAdjunct: blankAdjunct,
});

this.props.onEdit([adjunct.name, adjunct]);
}

private onSave(i: number) {
const adjuncts = this.state.adjuncts.slice();
const previous = this.state.previous.slice();
const adjunct = this.state.adjuncts[i];

adjunct.aliases = adjunct.aliases.filter(Boolean);
adjunct.ascii = adjunct.ascii.filter(Boolean);

const [old] = previous.splice(i, 1, adjunct);
adjuncts.splice(i, 1, adjunct);

this.setState({
previous,
adjuncts,
});

this.props.onEdit([old.name, adjunct]);
}

private onDelete(i: number) {
const confirm = () => this.onConfirmDelete(i);
const nope = () => {
const messages = this.state.messages.slice();
messages[i] = null;

this.setState({
messages,
});
};

const messages = this.state.messages.slice();

messages[i] = (
<tr>
<td>
<button className="btn btn-default" type="button" onClick={nope}>Cancel</button>
</td>
<td colSpan={3}>
<span class="help-block">Are you sure you want to delete this extension?</span>
</td>
<td>
<button className="btn btn-danger" type="button" onClick={confirm}>Yes</button>
</td>
</tr>
);
this.setState({
messages,
});
}

private onConfirmDelete(i: number) {
const adjuncts = this.state.adjuncts.slice();
const previous = this.state.previous.slice();
const [old] = previous.splice(i, 1);
adjuncts.splice(i, 1);

this.setState({
adjuncts,
previous,
});

this.props.onDelete(old.name);
}

private onEdit(i: number, emoji: CustomAdjunct) {
const adjuncts = this.state.adjuncts.slice();
adjuncts.splice(i, 1, emoji);
this.setState({
adjuncts,
});
}

public render(_: AdjunctListProps, {
previous,
adjuncts,
messages,
newAdjunct,
newAdjunctMessage,
}: AdjunctListState) {
const rows: JSX.Element[] = [];
adjuncts.forEach((adjunct, i) => {
const all = previous.slice();
all.splice(i, 1);

const failures = AdjunctList.validate(all, adjunct);

const props: AdjunctProps = {
adjunct,
onSave: () => this.onSave(i),
onDelete: () => this.onDelete(i),
onEditName: name => this.onEdit(i, { ...adjunct, name }),
onEditAliases: aliases => this.onEdit(i, { ...adjunct, aliases }),
onEditAscii: ascii => this.onEdit(i, { ...adjunct, ascii }),
editing: !AdjunctList.equal(adjunct, previous[i]),
canSave: !failures.length,
};
rows.push(<Adjunct {...props} key={i} />);
rows.push(...failures.map(({ message }) => (
<tr className="text-danger">
<td colSpan={5} dangerouslySetInnerHTML={{ __html: message }}></td>
</tr>
)));
if (messages[i]) {
rows.push(messages[i]);
}
});

const newAdjunctFailures = AdjunctList.validate(previous, newAdjunct);

return (
<table className="table">
<thead>
<tr><th>Name</th><th>Emoji</th><th>Aliases</th><th>ASCII patterns</th><th></th></tr>
</thead>
<tbody>
{rows}
</tbody>
<tfoot>
<Adjunct
adjunct={newAdjunct}
onSave={() => this.onAdd()}
onDelete={() => {}}
onEditName={name => this.setState({ newAdjunct: { ...newAdjunct, name } })}
onEditAliases={aliases => this.setState({ newAdjunct: { ...newAdjunct, aliases } })}
onEditAscii={ascii => this.setState({ newAdjunct: { ...newAdjunct, ascii } })}
editing
canSave={!newAdjunctFailures.length}
/>
{AdjunctList.equal(newAdjunct, blankAdjunct) ? null :
newAdjunctFailures.map(({ message }) => (
<tr className="text-danger">
<td colSpan={5} dangerouslySetInnerHTML={{ __html: message }}></td>
</tr>
))}
{newAdjunctMessage}
</tfoot>
</table>
);
}
}

interface AppProps {
/** initial state */
state: AppState;

onEditEmoji: Callback<[string, CustomEmoji]>;
onDeleteEmoji: Callback<string>;
onEditAdjunct: Callback<[string, CustomAdjunct]>;
onDeleteAdjunct: Callback<string>;
}

interface AppState {
emojis: CustomEmoji[];
adjuncts: CustomAdjunct[];
}

class App extends Component<AppProps, AppState> {
public constructor({ state }: AppProps) {
super();
this.state = state;
}

// eslint-disable-next-line class-methods-use-this
public render({
onEditEmoji,
onDeleteEmoji,
onEditAdjunct,
onDeleteAdjunct,
}: AppProps, {
emojis,
adjuncts,
}: AppState) {
return (
<div>
<p>
Below you can add custom emoji, and also add new aliases
and ASCII patterns for existing emoji. While this list is
edited live, you must still <strong>Build Emoji Assets </strong>
to actually use these customizations.
</p>
<div className="panel panel-default">
<div className="panel-heading">
<h3 className="panel-title">Custom Emoji</h3>
</div>
<EmojiList
emojis={emojis}
onEdit={onEditEmoji}
onDelete={onDeleteEmoji}
/>
</div>
<div className="panel panel-default">
<div className="panel-heading">
<h3 className="panel-title">Custom Extensions</h3>
</div>
<AdjunctList
adjuncts={adjuncts}
onEdit={onEditAdjunct}
onDelete={onDeleteAdjunct}
/>
</div>
</div>
);
}
}

let initialized = false;
export function init(
elem: Element,
cb: Callback
) {
if (initialized) {
cb(null);
return;
}
initialized = true;

socket.emit('admin.plugins.emoji.getCustomizations', (err: Error, customizations: AppState) => {
const props: AppProps = {
state: customizations,
onEditEmoji: (args) => {
socket.emit('admin.plugins.emoji.editEmoji', args);
},
onDeleteEmoji: (name) => {
socket.emit('admin.plugins.emoji.deleteEmoji', name);
},
onEditAdjunct: (args) => {
socket.emit('admin.plugins.emoji.editAdjunct', args);
},
onDeleteAdjunct: (name) => {
socket.emit('admin.plugins.emoji.deleteAdjunct', name);
},
};
initEmoji(() => {
render((
<App {...props} />
), elem);

cb(null);
});
});
}

View file

@ -17,14 +17,14 @@ import {
const $html = $('html');

export const dialogActions = {
open(dialog: JQuery) {
open(dialog: JQuery): JQuery {
$html.addClass('emoji-insert');
dialog.addClass('open');
dialog.find('.emoji-dialog-search').focus();

return dialog;
},
close(dialog: JQuery) {
close(dialog: JQuery): JQuery {
$html.removeClass('emoji-insert');
return dialog.removeClass('open');
},
@ -59,7 +59,7 @@ function stringCompare(a: string, b: string) {
}

// create modal
export function init(callback: Callback<JQuery>) {
export function init(callback: Callback<JQuery>): void {
Promise.all([
$.getJSON(`${base}/emoji/categories.json?${buster}`),
$.getJSON(`${base}/emoji/packs.json?${buster}`),
@ -184,7 +184,7 @@ export function init(callback: Callback<JQuery>) {
export function toggle(
opener: HTMLElement | null,
onClick: (e: JQuery.Event, name: string, dialog: JQuery) => void
) {
): void {
function after(dialog: JQuery) {
if (dialog.hasClass('open')) {
dialogActions.close(dialog);
@ -238,7 +238,7 @@ export function toggleForInsert(
selectStart: number,
selectEnd: number,
event: JQuery.ClickEvent
) {
): void {
// handle new and old API case
let button;
if (event && event.target) {

View file

@ -1,11 +1,10 @@
// eslint-disable-next-line spaced-comment
/// <amd-module name="emoji"/>

const base = `${config.relative_path}/plugins/nodebb-plugin-emoji`;
const buster = config['cache-buster'];

export { base, buster };
export function buildEmoji(emoji: StoredEmoji, defer?: boolean) {
export function buildEmoji(emoji: StoredEmoji, defer?: boolean): string {
const whole = `:${emoji.name}:`;
const deferClass = defer ? ' defer' : '';

@ -30,25 +29,19 @@ export let search: (term: string) => StoredEmoji[];

export const strategy = {
match: /\B:([^\s\n:]+)$/,
search: (term: string, callback: Callback<StoredEmoji[]>) => {
search: (term: string, callback: Callback<StoredEmoji[]>): void => {
callback(search(term.toLowerCase().replace(/[_-]/g, '')).slice(0, 10));
},
index: 1,
replace: (emoji: StoredEmoji) => `:${emoji.name}: `,
template: (emoji: StoredEmoji) => `${buildEmoji(emoji)} ${emoji.name}`,
replace: (emoji: StoredEmoji): string => `:${emoji.name}: `,
template: (emoji: StoredEmoji): string => `${buildEmoji(emoji)} ${emoji.name}`,
cache: true,
};

let initialized = false;
let initialized: Promise<void>;

export function init(callback?: Callback<undefined>) {
if (initialized) {
if (callback) { setTimeout(callback, 0); }
return;
}
initialized = true;

Promise.all([
export function init(callback?: Callback<void>): Promise<void> {
initialized = initialized || Promise.all([
import('fuzzysearch'),
import('leven'),
import('composer/formatting'),
@ -126,12 +119,16 @@ export function init(callback?: Callback<undefined>) {
.then(({ toggleForInsert }) => toggleForInsert(textarea, start, end, event));
}
);

if (callback) { setTimeout(callback, 0); }
}).catch((err) => {
const e = Error('[[emoji:meta-load-failed]]');
console.error(e);
app.alertError(e);
throw err;
});

if (callback) {
initialized.then(() => setTimeout(callback, 0));
}

return initialized;
}

View file

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

declare type Callback<T = void> = (result: T) => void;

declare const config: {
@ -9,7 +11,10 @@ declare const config: {
declare const app: {
alertSuccess(message?: string): void;
alertError(message?: string): void;
alertError(error: Error): void;
alertError(error?: Error): void;
};
declare const ajaxify: {
data: any;
};
declare const utils: {
generateUUID(): string;
@ -20,8 +25,6 @@ interface String {
startsWith(str: string): boolean;
}

declare const socket: SocketIO.Server;

interface JQuery {
ajaxSubmit: any;
draggable: any;

View file

@ -1,61 +0,0 @@
<form id="emoji-settings">
<div class="panel panel-default">
<div class="panel-body">
<div class="form-group">
<label for="emoji-parseAscii">
<input id="emoji-parseAscii" type="checkbox" {{{ if settings.parseAscii }}} checked {{{ end }}} />
[[admin/plugins/emoji:settings.parseAscii]]
</label>
</div>

<div class="form-group">
<label for="emoji-parseNative">
<input id="emoji-parseNative" type="checkbox" {{{ if settings.parseNative }}} checked {{{ end }}} />
[[admin/plugins/emoji:settings.parseNative]]
</label>
</div>

<div class="form-group">
<label for="emoji-customFirst">
<input id="emoji-customFirst" type="checkbox" {{{ if settings.customFirst }}} checked {{{ end }}} />
[[admin/plugins/emoji:settings.customFirst]]
</label>
</div>
</div>

<div class="panel-footer">
<div class="form-group">
<button type="button" id="build" class="btn btn-primary" aria-describedby="emoji-build_description">[[admin/plugins/emoji:build]]</button>
<p id="emoji-build_description" class="help-block">
[[admin/plugins/emoji:build_description]]
</p>
</div>
</div>
</div>
</form>

<button id="save" class="floating-button mdl-button mdl-js-button mdl-button--fab mdl-js-ripple-effect mdl-button--colored">
<i class="material-icons">save</i>
</button>

<button id="edit" class="floating-button mdl-button mdl-js-button mdl-button--fab mdl-js-ripple-effect mdl-button--colored" style="
left: 30px;
margin-left: 0;
background: rgb(255,64,129) !important;
">
<i class="material-icons">edit</i>
</button>

<div class="modal fade" id="editModal" tabindex="-1" role="dialog" aria-labelledby="editModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title" id="editModalLabel">Customize Emoji</h4>
</div>
<div class="modal-body" id="editModalBody">
</div>
</div>
</div>
</div>

View file

@ -1,12 +1,8 @@
{
"include": [
"lib/**/*.ts",
"lib/**/*.tsx",
"../lib/types.d.ts"
],
"exclude": [
"lib/admin/extension.tsx"
],
"compilerOptions": {
"outDir": "../build/public/lib",
"lib": ["es5", "dom", "es2015.promise", "es2015.iterable"],
@ -16,14 +12,9 @@
"sourceMap": true,
"types": [
"jquery",
"bootstrap",
"preact",
"socket.io"
"bootstrap"
],

"jsx": "react",
"jsxFactory": "h",

"moduleResolution": "node",
"baseUrl": ".",
"paths": {
@ -34,4 +25,4 @@
"typeAcquisition": {
"enable": false
}
}
}

View file

@ -4,17 +4,14 @@
],
"compilerOptions": {
"outDir": "build/lib",
"lib": ["es2017"],
"target": "es2017",
"lib": ["es2019"],
"target": "es2019",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"noImplicitAny": true,
"alwaysStrict": true,
"sourceMap": true,
"types": [
"socket.io"
]
"sourceMap": true
},
"typeAcquisition": {
"enable": false