diff --git a/src/emote-side-panel/index.js b/src/emote-side-panel/index.js
new file mode 100644
index 00000000..665e7dda
--- /dev/null
+++ b/src/emote-side-panel/index.js
@@ -0,0 +1,210 @@
+const {createElement} = FrankerFaceZ.utilities.dom;
+import STYLE_URL from './styles.scss';
+
+class EmoteSidePanel extends Addon {
+
+ getContainer() {
+ return document.querySelector(".chat-list--default,.chat-list--other");
+ }
+
+ getPanel() {
+ const panel = document.querySelector('#emote_side_panel');
+ if (panel) return panel;
+
+ const newPanel = createElement('div', {
+ id: 'emote_side_panel',
+ class: 'ffz--esp'
+ });
+
+ const container = this.getContainer();
+ container.style.setProperty("position", "relative");
+ container.append(newPanel);
+
+ return newPanel;
+ }
+
+ updatePadding() {
+ // Had some problems changing the chat width, so for now i'll leave it fixed
+ const padding = (this.emotes.length > 0) ? 50 : 50;
+ this.getContainer().querySelector(".simplebar-content").style.setProperty('padding-right', padding + 'px');
+ }
+
+ updateCount(emote) {
+ const len = emote.instances.length;
+ emote.element.querySelector("span").innerHTML = ((len == 1) ? "" : "x" + len);
+ }
+
+ updateElement(emote) {
+ const panel = this.getPanel();
+ const el = emote.element;
+ if (panel.lastChild !== el) {
+ // Send emote to the end
+ panel.removeChild(el);
+ panel.appendChild(el);
+ el.classList.add('animate')
+ this.setRemoveAnimation(el);
+ }
+ this.updateCount(emote);
+ }
+
+ updatePanel() {
+ this.updateTimer = null;
+
+ // Remove old stuff
+ const limit = (new Date()).getTime() - (this.timeout * 1000);
+ const panel = this.getPanel();
+ for (let i = this.emotes.length - 1; i >= 0; i--) {
+ const emote = this.emotes[i];
+ emote.instances = emote.instances.filter(e => e.time > limit);
+ if (emote.instances.length == 0) {
+ panel.removeChild(emote.element);
+ this.emotes.splice(i, 1);
+ } else {
+ this.updateCount(emote);
+ }
+ }
+
+ this.updatePadding();
+ this.setUpdatePanel();
+ }
+
+ setUpdatePanel() {
+ if (!this.updateTimer) this.updateTimer = window.setTimeout(() => this.updatePanel(), 350);
+ }
+
+ createEmoteElement(emote) {
+ return (
+
+ {this.chat.renderTokens([emote])}
+
)
+ }
+
+ clearEmotes() {
+ this.emotes = [];
+ this.getPanel().innerHTML = "";
+ }
+
+ setRemoveAnimation(el) {
+ window.setTimeout(() => el.classList.remove('animate'), 200);
+ }
+
+ handleMessage(ctx, tokens, msg) {
+ // Avoid handling the message twice
+ if (msg.esp_handled) return tokens;
+ msg.esp_handled = true;
+
+ let removeMessage = false;
+
+ let emoteOnly = true;
+ for (const token of tokens) {
+ if ((token.type === 'emote') ||
+ (token.type === 'text' && /^(\s|[^\x20-\x7E])+$/g.test(token.text))) continue;
+ emoteOnly = false;
+ break;
+ }
+
+ const captureAll = ctx.context.get('emote_side_panel.capture_all');
+ if (!emoteOnly && !captureAll) return tokens;
+
+ const keepEmoteMessages = ctx.context.get('emote_side_panel.keep_messages');
+ if (emoteOnly && !keepEmoteMessages) msg.ffz_removed = true;
+
+ // Add emotes to the list
+ for (const token of tokens) {
+ if (token.type === 'emote') {
+ this.log.debug(token);
+ const instance = {user: msg.user, time: msg.timestamp};
+ const text = token.text;
+ const el = this.emotes.find(e => e.text == text);
+ if (el) {
+ el.instances.push(instance);
+ this.updateElement(el);
+ } else {
+ const el = this.createEmoteElement(token, 1);
+ this.getPanel().appendChild(el);
+ this.setRemoveAnimation(el);
+ this.emotes.push({text: text, element: el, firstTime: instance.timestamp, instances: [instance]});
+ }
+ }
+ }
+
+ this.updatePadding();
+ this.setUpdatePanel();
+
+ return tokens;
+ }
+
+ constructor(...args) {
+ super(...args);
+
+ this.inject('chat');
+ this.injectAs('site_chat', 'site.chat');
+
+ this.settings.add('emote_side_panel.capture_all', {
+ default: false,
+ ui: {
+ path: 'Add-Ons > Emote Side Panel',
+ title: 'Capture all',
+ description: 'Capture emotes from all messages, even those that are not emote only',
+ component: 'setting-check-box',
+ },
+ });
+
+ this.settings.add('emote_side_panel.keep_messages', {
+ default: false,
+ ui: {
+ path: 'Add-Ons > Emote Side Panel',
+ title: 'Keep messages',
+ description: 'Capture emotes but do not remove emote only messages',
+ component: 'setting-check-box',
+ },
+ });
+
+ this.settings.add('emote_side_panel.timeout', {
+ default: 15,
+ ui: {
+ path: 'Add-Ons > Emote Side Panel',
+ title: 'Timeout',
+ description: 'Time in seconds that the emote is visible on the side panel',
+ component: 'setting-text-box',
+ },
+ changed: val => this.timeout = parseInt(val) == 0 ? 30 : parseInt(val)
+ });
+
+ this.emotes = {};
+ this.updateTimer = null;
+ this.style_link = null;
+ this.timeout = parseInt(this.settings.get('emote_side_panel.timeout'));
+
+ const outerThis = this;
+ this.messageFilter = {
+ type: 'emote_side_panel',
+ priority: 9,
+ process(tokens, msg) {
+ return outerThis.handleMessage(this, tokens, msg)
+ }
+ }
+ }
+
+ onEnable() {
+ this.on('site.router:route', this.clearEmotes, this);
+ this.chat.addTokenizer(this.messageFilter);
+ this.emit('chat:update-lines');
+
+ if (!this.style_link)
+ document.head.appendChild(this.style_link = createElement('link', {
+ href: STYLE_URL,
+ rel: 'stylesheet',
+ type: 'text/css',
+ crossOrigin: 'anonymous'
+ }));
+ }
+
+ onDisable() {
+ this.off('site.router:route', this.clearEmotes, this);
+ this.chat.removeTokenizer(this.messageFilter);
+ this.emit('chat:update-lines');
+ }
+}
+
+EmoteSidePanel.register();
diff --git a/src/emote-side-panel/logo.png b/src/emote-side-panel/logo.png
new file mode 100644
index 00000000..2bb1dfb1
Binary files /dev/null and b/src/emote-side-panel/logo.png differ
diff --git a/src/emote-side-panel/logo.psd b/src/emote-side-panel/logo.psd
new file mode 100644
index 00000000..45a546c9
Binary files /dev/null and b/src/emote-side-panel/logo.psd differ
diff --git a/src/emote-side-panel/manifest.json b/src/emote-side-panel/manifest.json
new file mode 100644
index 00000000..364de0c4
--- /dev/null
+++ b/src/emote-side-panel/manifest.json
@@ -0,0 +1,12 @@
+{
+ "enabled": true,
+ "requires": [],
+ "version": "1.0.0",
+ "short_name": "EmoteSidePanel",
+ "name": "Emote Side Panel",
+ "author": "amartini",
+ "description": "Declutter the chat by sending emote-only messages to a side panel.",
+ "website": "https://github.com/amartini",
+ "created": "2024-03-31T22:15:03.336Z",
+ "updated": "2024-03-31T22:15:03.336Z"
+}
\ No newline at end of file
diff --git a/src/emote-side-panel/styles.scss b/src/emote-side-panel/styles.scss
new file mode 100644
index 00000000..77c26a02
--- /dev/null
+++ b/src/emote-side-panel/styles.scss
@@ -0,0 +1,35 @@
+.ffz--esp {
+ text-align: right;
+ position: absolute;
+ bottom: 0;
+ right: 18px;
+
+ & > div {
+ position: relative;
+ margin-bottom: 14px;
+
+ & > span {
+ position: absolute;
+ bottom: -6px;
+ right: -4px;
+ font-size: 10px;
+ z-index: 12;
+ }
+
+ &.animate {
+ animation: ffz-esp-grow 0.2s normal forwards ease-out;
+ }
+ }
+}
+
+@keyframes ffz-esp-grow {
+ from {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.1);
+ }
+ to {
+ transform: scale(1);
+ }
+}
\ No newline at end of file