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; onEditImage: Callback; onEditAliases: Callback; onEditAscii: Callback; } const Emoji: FunctionalComponent = ({ 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 = `${window.utils.generateUUID()}-${imageInput.files[0].name}`; fileNameInput.value = fileName; $(imageForm).ajaxSubmit({ success: () => { onEditImage(fileName); imageInput.value = ''; }, error: () => { const err = Error('Failed to upload file'); console.error(err); window.app.alertError(err); imageInput.value = ''; }, }); }); }; return ( onEditName((e.target as HTMLInputElement).value)} />
{ imageForm = form as HTMLFormElement; }} > { imageInput = input as HTMLInputElement; }} /> { fileNameInput = input as HTMLInputElement; }} />
onEditAliases( (e.target as HTMLInputElement).value.split(',') )} /> onEditAscii( (e.target as HTMLInputElement).value.split(',') )} /> { editing ? ( ) : ( ) } ); }; interface EmojiListProps { onEdit: Callback<[string, CustomEmoji]>; onDelete: Callback; 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 { 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: ( Name is required ), }, { fn: () => !!emoji.image, message: ( Image is required ), }, { fn: () => pattern.test(emoji.name), message: ( Name can only contain letters, numbers, and _-+. ), }, { fn: () => emoji.aliases.every((alias) => pattern.test(alias)), message: ( Aliases can only contain letters, numbers, and _-+. (comma-separated) ), }, { fn: () => all.every(({ name }) => emoji.name !== name), message: ( Multiple custom emojis cannot have the same Name ), }, ]; 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] = ( Are you sure you want to delete this emoji? ); 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(); rows.push(...failures.map(({ message }) => ( {message} ))); if (messages[i]) { rows.push(messages[i]); } }); const newEmojiFailures = EmojiList.validate(previous, newEmoji); return ( {rows} 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 }) => ( ))} {newEmojiMessage}
NameImageAliasesASCII patterns
{message}
); } } interface AdjunctProps { editing?: boolean; canSave?: boolean; adjunct: CustomAdjunct; onSave: Callback; onDelete: Callback; onEditName: Callback; onEditAliases: Callback; onEditAscii: Callback; } class Adjunct extends Component { private nameInput: Element; public render({ editing, canSave, adjunct, onSave, onDelete, onEditName, onEditAliases, onEditAscii, }: AdjunctProps) { const emoji = adjunct.name && table[adjunct.name]; return ( onEditName((e.target as HTMLInputElement).value)} ref={(input) => { this.nameInput = input; }} /> onEditAliases( (e.target as HTMLInputElement).value.split(',') )} /> onEditAscii( (e.target as HTMLInputElement).value.split(',') )} /> { editing ? ( ) : ( ) } ); } public componentDidMount() { const Textcomplete = window.Textcomplete; 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; 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 { 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: 'Name is required', }, { fn: () => !!table[emoji.name], message: 'Name must be an existing emoji', }, { fn: () => emoji.aliases.every((alias) => pattern.test(alias)), message: 'Aliases can only contain ' + 'letters, numbers, and _-+. (comma-separated)', }, { fn: () => all.every(({ name }) => emoji.name !== name), message: 'Multiple custom extensions cannot have the same Name', }, ]; 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] = ( Are you sure you want to delete this extension? ); 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(); rows.push(...failures.map(({ message }) => ( ))); if (messages[i]) { rows.push(messages[i]); } }); const newAdjunctFailures = AdjunctList.validate(previous, newAdjunct); return ( {rows} 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 }) => ( ))} {newAdjunctMessage}
NameEmojiAliasesASCII patterns
); } } interface AppProps { /** initial state */ state: AppState; onEditEmoji: Callback<[string, CustomEmoji]>; onDeleteEmoji: Callback; onEditAdjunct: Callback<[string, CustomAdjunct]>; onDeleteAdjunct: Callback; } interface AppState { emojis: CustomEmoji[]; adjuncts: CustomAdjunct[]; } class App extends Component { 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 (

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 Build Emoji Assets to actually use these customizations.

Custom Emoji

Custom Extensions

); } } 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(( ), elem); cb(null); }); }); }