Flashii Chat Userscripts
#16752
it would have actually been more useful to have a shortcut for it, but the terminal to bbcode thing is just a sidebar button. the chatbox button i have is [P], for Pomf, because it's what i used to use before szyup before eeprom
#17506

i am here once again with a usage survey

Does anyone use the umi:message_add event in general, or the message field of the umi:ui:message_add event at all?

If so, can you modify it to just use the element field of umi:ui:message_add instead? (please do not use the dataset fields and expect them to exist in the future, they are subject to change)

If not, what data from the message object are you using?

https://sig.flash.moe/signature.png
#17955
i dont know if there's a better way to do this, but ui for deleting message


// ==UserScript==
// @name         Flashii Delete Message Button
// @namespace    http://no.com
// @version      1.3
// @description  Adds a delete button to each of your own message in Flashii Chat
// @author       no
// @match        *://chat.flashii.net/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Ensures chat is loaded
    window.addEventListener('umi:connect', function(ev) {

        // Function to add delete button to your own messages
        function addDeleteButtons() {
            const userId = Umi.User.getCurrentUser().id;
            const messages = document.querySelectorAll('.message');

            messages.forEach(message => {
                const messageId = message.getAttribute('data-id');
                const messageAuthor = message.getAttribute('data-author');
                const messageBody = message.getAttribute('data-body');

                // Exclude messages containing join and leave phrases, and with the class message-tiny
                const excludedPhrases = ["has disconnected", "has joined"];
                const containsExcludedPhrase = excludedPhrases.some(phrase => messageBody.includes(phrase));
                const isTinyMessage = message.classList.contains('message-tiny');

                if ((messageAuthor === userId && !containsExcludedPhrase && !message.querySelector('.delete-button')) ||
                    (isTinyMessage && messageAuthor === userId && !containsExcludedPhrase && !message.querySelector('.delete-button'))) {
                    const deleteButton = document.createElement('button');
                    deleteButton.innerHTML = '×'; // Cross symbol
                    deleteButton.className = 'delete-button';
                    deleteButton.style.position = 'absolute';
                    deleteButton.style.right = '10px';
                    deleteButton.style.top = '50%';
                    deleteButton.style.transform = 'translateY(-50%)';
                    deleteButton.style.backgroundColor = '#333';
                    deleteButton.style.color = '#fff';
                    deleteButton.style.border = 'none';
                    deleteButton.style.padding = '2px 5px';
                    deleteButton.style.borderRadius = '1px';
                    deleteButton.style.cursor = 'pointer';

                    deleteButton.addEventListener('click', () => {
                        Umi.Server.SendMessage(`/delete ${messageId}`);
                        }
                    );

                    message.style.position = 'relative';
                    message.appendChild(deleteButton);
                }
            });
        }

        // Add delete buttons when the script is loaded
        addDeleteButtons();

        // Observe mutations to add delete buttons to new messages
        const observer = new MutationObserver(addDeleteButtons);
        observer.observe(document.body, { childList: true, subtree: true });
    });
})();
---
https://lester.page/assets/images/lester.page_camera.gif
#18754
nook remover

// ==UserScript==
// @name         Nook Remover Reimagined
// @namespace    https://saikuru.net/
// @version      2024-11-24
// @description  Automatically removes embeds from nook
// @author       You
// @match        https://chat.flashii.net/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=flashii.net
// @grant        none
// ==/UserScript==

(function () {
  'use strict';
  const observer = new MutationObserver((mutationsList) => {
    mutationsList.forEach((mutation) => {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          const element = node;
          if (element.matches && element.matches('div.message--user-181')) {
            element.querySelectorAll('embed, iframe, object, img, video, audio').forEach((embeddable) => {
              embeddable.remove();
            });
          }
        }
      });
    });
  });

  observer.observe(document.body, { childList: true, subtree: true });
})();
https://saikuru.net/sig
#18756
im going to reroll his user id every time he logs in
https://sig.flash.moe/signature.png
#19657
//i.fii.moe/1VgmxZl4dcpG3V.png



// ==UserScript==
// @name         Flashii Chat - Better Quotes & Delete Button
// @version      2.1
// @description  Adds additional behaviour to quote selected text in messages, adds buttons to quote whole messages, and delete your own messages.
// @author       lester
// @match        *://chat.flashii.net/*
// @grant        none
// ==/UserScript==

(() => {
  let selectedText = '';
  const cssID = 'chat-style';

  document.addEventListener('mouseup', () => {
    const sel = window.getSelection();
    const range = sel?.rangeCount ? sel.getRangeAt(0) : null;
    const inside = range?.commonAncestorContainer?.closest?.('#umi-messages') || range?.commonAncestorContainer?.parentElement?.closest?.('#umi-messages');
    if (inside) selectedText = sel.toString().trim();
  });

  document.addEventListener('click', e => {
    if (e.target.matches('button.markup__button') && e.target.textContent.trim().toLowerCase() === 'quote') {
      setTimeout(() => {
        const input = document.querySelector('textarea.input__text');
        if (input && selectedText) {
          input.value = input.value.trim() === '[quote][/quote]' ? `[quote]${selectedText}[/quote]` : input.value + `[quote]${selectedText}[/quote]`;
          input.focus();
          selectedText = '';
        }
      }, 50);
    }
  });

  window.addEventListener('umi:connect', () => {
    const uid = Umi.User.getCurrentUser().id;

    const rgbToHex = rgb => {
      const m = rgb?.match(/\d+/g);
      return m?.length >= 3 ? '#' + m.slice(0, 3).map(x => (+x).toString(16).padStart(2, '0')).join('') : '#000';
    };

    const injectCSS = () => {
      if (!document.getElementById(cssID)) {
        const s = document.createElement('style');
        s.id = cssID;
        s.textContent = `
          .message .quote-button, .message .delete-button {
            opacity: 0;
            position: absolute;
            top: 50%;
            transform: translateY(-50%);
            border: none;
            border-radius: 2px;
            padding: 1px 6px;
            font-size: 13px;
            cursor: pointer;
            background: var(--theme-colour-input-menu-button);
            color: var(--theme-colour-main-colour);
            transition: opacity 0.15s ease;
          }
          .message .quote-button:hover, .message .delete-button:hover {
            background: var(--theme-colour-input-menu-button-hover);
          }
          .message .quote-button:active, .message .delete-button:active {
            background: var(--theme-colour-input-menu-button-active);
          }
          .message:hover .quote-button, .message:hover .delete-button {
            opacity: 1;
          }
        `;
        document.head.appendChild(s);
      }
    };

    const addButtons = () => {
      injectCSS();
      const input = document.querySelector('textarea.input__text');
      document.querySelectorAll('.message').forEach(msg => {
        const id = msg.dataset.id, author = msg.dataset.author, body = msg.dataset.body || '';
        if (!input || ['has disconnected', 'has joined'].some(p => body.includes(p))) return;

        const hasQuote = msg.querySelector('.quote-button');
        const hasDelete = msg.querySelector('.delete-button');
        const textEl = msg.querySelector('.message__text') || msg.querySelector('.message-tiny-text');
        const userEl = msg.querySelector('.message__user');
        const timeEl = msg.querySelector('.message__time');

        if (!hasQuote && textEl && userEl && timeEl) {
          const btn = document.createElement('button');
          btn.className = 'quote-button';
          btn.textContent = 'Quote';
          btn.style.right = '10px';
          btn.title = 'Quote this message';
          btn.onclick = () => {
            const name = userEl.textContent.trim() || 'Unknown';
            const time = timeEl.textContent.trim();
            const msgText = textEl.textContent.trim();
            const raw = userEl.style.color;
            const userColor = (!raw || raw === 'inherit') ? '#ffffff' : rgbToHex(raw);
            input.value += `[color=${userColor}][b]${name}[/b][/color][color=#c0c0c0] @ ${time} — [/color][quote]${msgText}[/quote]`;
            input.focus();
          };
          msg.style.position = 'relative';
          msg.appendChild(btn);
        }

        if (!hasDelete && author === uid && (textEl || msg.classList.contains('message-tiny'))) {
          const del = document.createElement('button');
          del.className = 'delete-button';
          del.innerHTML = '×';
          del.style.right = '60px';
          del.title = 'Delete this message';
          del.onclick = () => Umi.Server.SendMessage(`/delete ${id}`);
          msg.style.position = 'relative';
          msg.appendChild(del);
        }
      });
    };

    addButtons();
    new MutationObserver(addButtons).observe(document.body, { childList: true, subtree: true });
  });
})();
---
https://lester.page/assets/images/lester.page_camera.gif
#19664
//i.fii.moe/1VgmZEhegKhbPi.png



// ==UserScript==
// @name         Flashii Chat - File Upload Progress Bar
// @version      1.1
// @description  Show progress bar beside Spoiler button during file upload
// @author       lester
// @match        *://chat.flashii.net/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const originalXHR = window.XMLHttpRequest;

  function createProgressBar() {
    if (document.getElementById('upload-progress-wrapper')) return;

    const spoilerBtn = [...document.querySelectorAll('.markup__button')]
      .find(btn => btn.textContent.trim().toLowerCase() === 'spoiler');
    if (!spoilerBtn) return;

    const wrapper = document.createElement('div');
    wrapper.id = 'upload-progress-wrapper';
    wrapper.style.cssText = `
      display: flex;
      align-items: center;
      gap: 10px;
      margin-left: 10px;
      opacity: 0;
      transition: opacity 0.15s ease;
    `;

    const label = document.createElement('span');
    label.textContent = 'Uploading File...';
    label.style.cssText = `
      color: var(--theme-colour-main-colour);
      font-size: 13px;
    `;

    const barContainer = document.createElement('div');
    barContainer.style.cssText = `
      position: relative;
      width: 100px;
      height: 20px;
      background-color: var(--theme-colour-input-menu-button);
      border-radius: 2px;
      box-shadow: 0 0 0 1px var(--theme-colour-input-menu-box-shadow);
      overflow: hidden;
    `;

    const inner = document.createElement('div');
    inner.id = 'upload-progress-inner';
    inner.style.cssText = `
      background-color: var(--theme-colour-input-menu-button-hover);
      height: 100%;
      width: 0%;
      transition: width 0.15s ease;
      display: flex;
      align-items: center;
      justify-content: center;
      color: var(--theme-colour-main-colour);
      font-size: 12px;
      font-weight: bold;
      font-family: sans-serif;
    `;

    barContainer.appendChild(inner);
    wrapper.append(label, barContainer);
    spoilerBtn.parentElement.appendChild(wrapper);
  }

  function updateProgress(percent) {
    const wrapper = document.getElementById('upload-progress-wrapper');
    const inner = document.getElementById('upload-progress-inner');
    if (!wrapper || !inner) return;

    wrapper.style.opacity = '1';
    inner.style.width = `${percent}%`;
    inner.textContent = `${percent}%`;

    if (percent >= 100) {
      setTimeout(() => {
        wrapper.style.opacity = '0';
        setTimeout(() => {
          inner.style.width = '0%';
          inner.textContent = '';
        }, 150);
      }, 800);
    }
  }

  function CustomXHR() {
    const xhr = new originalXHR();

    xhr.open = function (method, url) {
      this._isUpload = method === 'POST' && url.includes('/uploads');
      return originalXHR.prototype.open.apply(this, arguments);
    };

    xhr.send = function (body) {
      if (this._isUpload) {
        createProgressBar();
        this.upload.onprogress = e => {
          if (e.lengthComputable) updateProgress(Math.round((e.loaded / e.total) * 100));
        };
      }
      return originalXHR.prototype.send.apply(this, arguments);
    };

    return xhr;
  }

  window.XMLHttpRequest = CustomXHR;
})();
---
https://lester.page/assets/images/lester.page_camera.gif
#19833
some improvements to the previous quote thingy i made


// ==UserScript==
// @name         Flashii Chat - Better Quotes & Delete Button
// @version      3.0
// @description  Adds message quoting and preview, via button and timestamp. Adds delete button to own messages.
// @author       lester
// @match        *://chat.flashii.net/*
// @grant        none
// ==/UserScript==

(() => {
  let selectedText = '';
  let pendingQuote = null;
  let previewInterval = null;
  const cssID = 'chat-enhanced-style';

  document.addEventListener('mouseup', () => {
    const sel = window.getSelection();
    const range = sel?.rangeCount ? sel.getRangeAt(0) : null;
    const inside = range?.commonAncestorContainer?.closest?.('#umi-messages') ||
                   range?.commonAncestorContainer?.parentElement?.closest?.('#umi-messages');
    if (inside) selectedText = sel.toString().trim();
  });

  document.addEventListener('click', e => {
    if (
      e.target.matches('button.markup__button') &&
      e.target.textContent.trim().toLowerCase() === 'quote'
    ) {
      setTimeout(() => {
        const input = document.querySelector('textarea.input__text');
        if (input && selectedText) {
          const quoted = `[quote]${selectedText}[/quote]`;
          input.value = input.value.trim() === '[quote][/quote]'
            ? quoted
            : input.value + quoted;
          input.focus();
          selectedText = '';
        }
      }, 50);
    }
  });

  window.addEventListener('umi:connect', () => {
    const uid = Umi.User.getCurrentUser().id;

    const rgbToHex = rgb => {
      const m = rgb?.match(/\d+/g);
      return m?.length >= 3 ? '#' + m.slice(0, 3).map(x => (+x).toString(16).padStart(2, '0')).join('') : '#000';
    };

    const getRelativeTime = (dateString) => {
      const createdDate = new Date(dateString);
      const now = new Date();
      const diffMs = now - createdDate;

      const seconds = Math.floor(diffMs / 1000);
      const minutes = Math.floor(seconds / 60);
      const hours = Math.floor(minutes / 60);
      const days = Math.floor(hours / 24);

      if (seconds < 60) return `${seconds}s ago`;
      if (minutes < 60) return `${minutes}m ago`;
      if (hours < 24) return `${hours}h ${minutes % 60}m ago`;
      return `${days}d ${hours % 24}h ${minutes % 60}m ago`;
    };

    const injectCSS = () => {
      if (document.getElementById(cssID)) return;
      const style = document.createElement('style');
      style.id = cssID;
      style.textContent = `
        .message .message-button-container {
          position: absolute;
          top: 50%;
          right: 10px;
          transform: translateY(-50%);
          display: flex;
          gap: 4px;
        }
        .message .quote-button, .message .delete-button, .message .goto-button {
          opacity: 0;
          border: none;
          border-radius: 2px;
          padding: 1px 6px;
          font-size: 13px;
          cursor: pointer;
          background: var(--theme-colour-input-menu-button);
          color: var(--theme-colour-main-colour);
          transition: opacity 0.15s ease;
        }
        .message .quote-button:hover, .message .delete-button:hover, .message .goto-button:hover {
          background: var(--theme-colour-input-menu-button-hover);
        }
        .message .quote-button:active, .message .delete-button:active, .message .goto-button:active {
          background: var(--theme-colour-input-menu-button-active);
        }
        .message:hover .quote-button, .message:hover .delete-button, .message:hover .goto-button {
          opacity: 1;
        }
        #quote-preview {
          background: var(--theme-colour-input-background);
          border: 1px solid var(--theme-colour-input-border);
          padding: 6px 10px;
          font-size: 13px;
          margin: 4px 0;
          border-radius: 4px;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
        #quote-preview span {
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
        }
        #cancel-quote {
          background: none;
          border: none;
          color: var(--theme-colour-main-colour);
          cursor: pointer;
          font-weight: bold;
          padding: 0 6px;
          font-size: 13px;
        }
        .highlight-temp {
          animation: blinkOutline 1s ease-in-out;
          outline: 2px solid transparent;
          border-radius: 4px;
          outline-offset: 1px;
        }
        @keyframes blinkOutline {
          0%   { outline-color: transparent; }
          50%  { outline-color: var(--theme-colour-main-accent); }
          100% { outline-color: transparent; }
        }
        .message__time,
        .message__text i {
          cursor: pointer;
        }
        .message__time:hover,
        .message__text i:hover {
          text-decoration: underline;
        }
      `;
      document.head.appendChild(style);
    };

    const showQuotePreview = ({ name, msg, color, created }) => {
      clearInterval(previewInterval);
      let preview = document.getElementById('quote-preview');
      if (!preview) {
        preview = document.createElement('div');
        preview.id = 'quote-preview';

        const span = document.createElement('span');
        const cancel = document.createElement('button');
        cancel.id = 'cancel-quote';
        cancel.textContent = '×';
        cancel.title = 'Cancel quote';
        cancel.onclick = () => {
          pendingQuote = null;
          preview.remove();
          clearInterval(previewInterval);
        };

        preview.append(span, cancel);
        const form = document.querySelector('form.input');
        const menus = form?.querySelector('.input__menus');
        const main = form?.querySelector('.input__main');
        if (menus && main) form.insertBefore(preview, main);
      }
      const span = preview.querySelector('span');
      const updateTime = () => {
        const time = getRelativeTime(created);
        span.innerHTML = `<span>Quoting </span><i><b style="color: ${color};">${name}</b> @ ${time} </i> "${msg.slice(0, 100)}..."`;
      };
      updateTime();
      previewInterval = setInterval(updateTime, 1000);
    };

    const addButtons = () => {
      injectCSS();
      const input = document.querySelector('textarea.input__text');
      const form = document.querySelector('form.input');

      document.querySelectorAll('.message').forEach(msg => {
        const id = msg.dataset.id;
        const author = msg.dataset.author;
        const body = msg.dataset.body || '';
        if (!input || ['has disconnected', 'has joined'].some(p => body.includes(p))) return;

        const textEl = msg.querySelector('.message__text') || msg.querySelector('.message-tiny-text');
        const userEl = msg.querySelector('.message__user');
        const timeEl = msg.querySelector('.message__time');

        const dataCreated = msg.getAttribute('data-created');
        const raw = userEl?.style?.color;
        const userColor = (!raw || raw === 'inherit') ? null : rgbToHex(raw);
        const name = userEl?.textContent?.trim() || 'Unknown';
        let msgText = msg.dataset.body || textEl?.textContent.trim() || '';

        const endQuoteIdx = msgText.lastIndexOf('[/quote]');
        if (endQuoteIdx !== -1) msgText = msgText.slice(endQuoteIdx + 8).trim();
        msgText = msgText.replace(/\[Embed\]|\[Remove\]/g, '').trim();

        if (timeEl) {
          timeEl.onclick = () => {
            pendingQuote = { name, color: userColor, created: dataCreated, id, msg: msgText };
            showQuotePreview(pendingQuote);
            input.focus();
          };
        }

        const relTimeEl = textEl?.querySelector('i');
        if (relTimeEl) {
          relTimeEl.onclick = () => {
            const anchor = textEl.querySelector('a[href^="#"]');
            const idMatch = anchor?.getAttribute('href')?.match(/^#(\d{17})$/);
            if (idMatch) {
              const targetId = idMatch[1];
              const target = document.getElementById(`message-${targetId}`);
              if (target) {
                target.scrollIntoView({ behavior: 'smooth', block: 'center' });
                target.classList.add('highlight-temp');
                setTimeout(() => target.classList.remove('highlight-temp'), 1500);
              }
            }
          };
        }

        let btnContainer = msg.querySelector('.message-button-container');
        if (!btnContainer) {
          btnContainer = document.createElement('div');
          btnContainer.className = 'message-button-container';
          msg.style.position = 'relative';
          msg.appendChild(btnContainer);
        }

        if (!msg.querySelector('.delete-button') && author === uid) {
          const del = document.createElement('button');
          del.className = 'delete-button';
          del.innerHTML = '&times;';
          del.title = 'Delete this message';
          del.onclick = () => Umi.Server.SendMessage(`/delete ${id}`);
          btnContainer.appendChild(del);
        }

        if (!msg.querySelector('.quote-button') && textEl && userEl && timeEl) {
          const btn = document.createElement('button');
          btn.className = 'quote-button';
          btn.textContent = 'Quote';
          btn.title = 'Quote this message';
          btn.onclick = () => {
            pendingQuote = { name, color: userColor, created: dataCreated, id, msg: msgText };
            showQuotePreview(pendingQuote);
            input.focus();
          };
          btnContainer.appendChild(btn);
        }

        if (!msg.querySelector('.goto-button') && textEl?.querySelector('a[href^="#"]')) {
          const anchor = textEl.querySelector('a[href^="#"]');
          const idMatch = anchor?.getAttribute('href')?.match(/^#(\d{17})$/);
          if (idMatch) {
            const targetId = idMatch[1];
            const go = document.createElement('button');
            go.className = 'goto-button';
            go.textContent = 'Go to quoted';
            go.title = 'Scroll to quoted message';
            go.onclick = () => {
              const target = document.getElementById(`message-${targetId}`);
              if (target) {
                target.scrollIntoView({ behavior: 'smooth', block: 'center' });
                target.classList.add('highlight-temp');
                setTimeout(() => target.classList.remove('highlight-temp'), 1500);
              }
            };
            btnContainer.appendChild(go);
          }
        }
      });

      if (form && !form.__quoteIntercepted) {
        form.__quoteIntercepted = true;
        form.addEventListener('submit', e => {
          if (pendingQuote && input) {
            const { name, color, id, msg, created } = pendingQuote;
            const hidden = '\u200C';
            const cleanMsg = msg.replace(/\[Embed\]|\[Remove\]/g, '').trim();
            const time = getRelativeTime(created);
            const quoteBlock = `[i]${color ? `[color=${color}]` : ''}[b]${name}[/b]${color ? '[/color]' : ''} @ ${time}[/i][url=#${id}]${hidden}[/url] [quote]${cleanMsg}[/quote]`;
            input.value = `${quoteBlock}${input.value ? '\n' + input.value.trimStart() : ''}`;
            pendingQuote = null;
            const preview = document.getElementById('quote-preview');
            if (preview) preview.remove();
            clearInterval(previewInterval);
          }
        }, true);
      }
    };

    addButtons();
    new MutationObserver(addButtons).observe(document.body, { childList: true, subtree: true });
  });
})();
---
https://lester.page/assets/images/lester.page_camera.gif
#19835


// ==UserScript==
// @name         Flashii Chat - Better Quotes & Delete (via timestamp only)
// @version      3.0
// @description  Adds message quoting and preview, via timestamp. Adds delete button to own messages.
// @author       lester
// @match        *://chat.flashii.net/*
// @grant        none
// ==/UserScript==

(() => {
  let selectedText = '';
  let pendingQuote = null;
  let previewInterval = null;
  const cssID = 'chat-enhanced-style';

  document.addEventListener('mouseup', () => {
    const sel = window.getSelection();
    const range = sel?.rangeCount ? sel.getRangeAt(0) : null;
    const inside = range?.commonAncestorContainer?.closest?.('#umi-messages') ||
                   range?.commonAncestorContainer?.parentElement?.closest?.('#umi-messages');
    if (inside) selectedText = sel.toString().trim();
  });

  document.addEventListener('click', e => {
    if (
      e.target.matches('button.markup__button') &&
      e.target.textContent.trim().toLowerCase() === 'quote'
    ) {
      setTimeout(() => {
        const input = document.querySelector('textarea.input__text');
        if (input && selectedText) {
          const quoted = `[quote]${selectedText}[/quote]`;
          input.value = input.value.trim() === '[quote][/quote]'
            ? quoted
            : input.value + quoted;
          input.focus();
          selectedText = '';
        }
      }, 50);
    }
  });

  window.addEventListener('umi:connect', () => {
    const uid = Umi.User.getCurrentUser().id;

    const rgbToHex = rgb => {
      const m = rgb?.match(/\d+/g);
      return m?.length >= 3 ? '#' + m.slice(0, 3).map(x => (+x).toString(16).padStart(2, '0')).join('') : '#000';
    };

    const getRelativeTime = (dateString) => {
      const createdDate = new Date(dateString);
      const now = new Date();
      const diffMs = now - createdDate;

      const seconds = Math.floor(diffMs / 1000);
      const minutes = Math.floor(seconds / 60);
      const hours = Math.floor(minutes / 60);
      const days = Math.floor(hours / 24);

      if (seconds < 60) return `${seconds}s ago`;
      if (minutes < 60) return `${minutes}m ago`;
      if (hours < 24) return `${hours}h ${minutes % 60}m ago`;
      return `${days}d ${hours % 24}h ${minutes % 60}m ago`;
    };

    const injectCSS = () => {
      if (document.getElementById(cssID)) return;
      const style = document.createElement('style');
      style.id = cssID;
      style.textContent = `
        .message .message-button-container {
          position: absolute;
          top: 50%;
          right: 10px;
          transform: translateY(-50%);
          display: flex;
          gap: 4px;
        }
        .message .delete-button {
          opacity: 0;
          border: none;
          border-radius: 2px;
          padding: 1px 6px;
          font-size: 13px;
          cursor: pointer;
          background: var(--theme-colour-input-menu-button);
          color: var(--theme-colour-main-colour);
          transition: opacity 0.15s ease;
        }
        .message .delete-button:hover {
          background: var(--theme-colour-input-menu-button-hover);
        }
        .message .delete-button:active {
          background: var(--theme-colour-input-menu-button-active);
        }
        .message:hover .delete-button {
          opacity: 1;
        }
        #quote-preview {
          background: var(--theme-colour-input-background);
          border: 1px solid var(--theme-colour-input-border);
          padding: 6px 10px;
          font-size: 13px;
          margin: 4px 0;
          border-radius: 4px;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
        #quote-preview span {
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
        }
        #cancel-quote {
          background: none;
          border: none;
          color: var(--theme-colour-main-colour);
          cursor: pointer;
          font-weight: bold;
          padding: 0 6px;
          font-size: 13px;
        }
        .highlight-temp {
          animation: blinkOutline 1s ease-in-out;
          outline: 2px solid transparent;
          border-radius: 4px;
          outline-offset: 1px;
        }
        @keyframes blinkOutline {
          0%   { outline-color: transparent; }
          50%  { outline-color: var(--theme-colour-main-accent); }
          100% { outline-color: transparent; }
        }
        .message__time,
        .message__text i {
          cursor: pointer;
        }
        .message__time:hover,
        .message__text i:hover {
          text-decoration: underline;
        }
      `;
      document.head.appendChild(style);
    };

    const showQuotePreview = ({ name, msg, color, created }) => {
      clearInterval(previewInterval);
      let preview = document.getElementById('quote-preview');
      if (!preview) {
        preview = document.createElement('div');
        preview.id = 'quote-preview';

        const span = document.createElement('span');
        const cancel = document.createElement('button');
        cancel.id = 'cancel-quote';
        cancel.textContent = '×';
        cancel.title = 'Cancel quote';
        cancel.onclick = () => {
          pendingQuote = null;
          preview.remove();
          clearInterval(previewInterval);
        };

        preview.append(span, cancel);
        const form = document.querySelector('form.input');
        const menus = form?.querySelector('.input__menus');
        const main = form?.querySelector('.input__main');
        if (menus && main) form.insertBefore(preview, main);
      }
      const span = preview.querySelector('span');
      const updateTime = () => {
        const time = getRelativeTime(created);
        span.innerHTML = `<span>Quoting </span><i><b style="color: ${color};">${name}</b> @ ${time} </i> "${msg.slice(0, 100)}..."`;
      };
      updateTime();
      previewInterval = setInterval(updateTime, 1000);
    };

    const addButtons = () => {
      injectCSS();
      const input = document.querySelector('textarea.input__text');
      const form = document.querySelector('form.input');

      document.querySelectorAll('.message').forEach(msg => {
        const id = msg.dataset.id;
        const author = msg.dataset.author;
        const body = msg.dataset.body || '';
        if (!input || ['has disconnected', 'has joined'].some(p => body.includes(p))) return;

        const textEl = msg.querySelector('.message__text') || msg.querySelector('.message-tiny-text');
        const userEl = msg.querySelector('.message__user');
        const timeEl = msg.querySelector('.message__time');

        const dataCreated = msg.getAttribute('data-created');
        const raw = userEl?.style?.color;
        const userColor = (!raw || raw === 'inherit') ? null : rgbToHex(raw);
        const name = userEl?.textContent?.trim() || 'Unknown';
        let msgText = msg.dataset.body || textEl?.textContent.trim() || '';

        const endQuoteIdx = msgText.lastIndexOf('[/quote]');
        if (endQuoteIdx !== -1) msgText = msgText.slice(endQuoteIdx + 8).trim();
        msgText = msgText.replace(/\[Embed\]|\[Remove\]/g, '').trim();

        if (timeEl) {
          timeEl.onclick = () => {
            pendingQuote = { name, color: userColor, created: dataCreated, id, msg: msgText };
            showQuotePreview(pendingQuote);
            input.focus();
          };
        }

        const relTimeEl = textEl?.querySelector('i');
        if (relTimeEl) {
          relTimeEl.onclick = () => {
            const anchor = textEl.querySelector('a[href^="#"]');
            const idMatch = anchor?.getAttribute('href')?.match(/^#(\d{17})$/);
            if (idMatch) {
              const targetId = idMatch[1];
              const target = document.getElementById(`message-${targetId}`);
              if (target) {
                target.scrollIntoView({ behavior: 'smooth', block: 'center' });
                target.classList.add('highlight-temp');
                setTimeout(() => target.classList.remove('highlight-temp'), 1500);
              }
            }
          };
        }

        let btnContainer = msg.querySelector('.message-button-container');
        if (!btnContainer) {
          btnContainer = document.createElement('div');
          btnContainer.className = 'message-button-container';
          msg.style.position = 'relative';
          msg.appendChild(btnContainer);
        }

        if (!msg.querySelector('.delete-button') && author === uid) {
          const del = document.createElement('button');
          del.className = 'delete-button';
          del.innerHTML = '&times;';
          del.title = 'Delete this message';
          del.onclick = () => Umi.Server.SendMessage(`/delete ${id}`);
          btnContainer.appendChild(del);
        }
      });

      if (form && !form.__quoteIntercepted) {
        form.__quoteIntercepted = true;
        form.addEventListener('submit', e => {
          if (pendingQuote && input) {
            const { name, color, id, msg, created } = pendingQuote;
            const hidden = '\u200C';
            const cleanMsg = msg.replace(/\[Embed\]|\[Remove\]/g, '').trim();
            const time = getRelativeTime(created);
            const quoteBlock = `[i]${color ? `[color=${color}]` : ''}[b]${name}[/b]${color ? '[/color]' : ''} @ ${time}[/i][url=#${id}]${hidden}[/url] [quote]${cleanMsg}[/quote]`;
            input.value = `${quoteBlock}${input.value ? '\n' + input.value.trimStart() : ''}`;
            pendingQuote = null;
            const preview = document.getElementById('quote-preview');
            if (preview) preview.remove();
            clearInterval(previewInterval);
          }
        }, true);
      }
    };

    addButtons();
    new MutationObserver(addButtons).observe(document.body, { childList: true, subtree: true });
  });
})();
---
https://lester.page/assets/images/lester.page_camera.gif