Discourse Customizable Quick Replies

Adds customizable quick replies with a UI to Discourse forums, with enhanced UI theming.

Versión del día 6/11/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Discourse Customizable Quick Replies
// @namespace    https://github.com/stevessr/bug-v3
// @version      2.1.0
// @description  Adds customizable quick replies with a UI to Discourse forums, with enhanced UI theming.
// @author       stevessr (modified by an AI assistant)
// @match        https://linux.do/*
// @match        https://meta.discourse.org/*
// @match        https://*.discourse.org/*
// @match        http://localhost:5173/*
// @exclude      https://linux.do/a/*
// @match        https://idcflare.com/*
// @grant        none
// @license      MIT
// @homepageURL  https://github.com/stevessr/bug-v3
// @supportURL   https://github.com/stevessr/bug-v3/issues
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // ===== Settings Management =====
  const SETTINGS_KEY = 'custom_quick_replies_settings';
  let quickReplies = [];

  const DEFAULT_QUICK_REPLIES = [
    { display: 'Info', insert: '>[!info]+\n', prefix: '[!info' },
    { display: 'Tip', insert: '>[!tip]+\n', prefix: '[!tip' },
    { display: 'Success', insert: '>[!success]+\n', prefix: '[!success' },
    { display: 'Warning', insert: '>[!warning]+\n', prefix: '[!warning' },
    { display: 'Danger', insert: '>[!danger]+\n', prefix: '[!danger' },
  ];

  function loadQuickReplies() {
    try {
      const settingsData = localStorage.getItem(SETTINGS_KEY);
      if (settingsData) {
        quickReplies = JSON.parse(settingsData);
      } else {
        quickReplies = DEFAULT_QUICK_REPLIES;
      }
    } catch (e) {
      console.warn('[Quick Replies] Failed to load settings:', e);
      quickReplies = DEFAULT_QUICK_REPLIES;
    }
  }

  function saveQuickReplies() {
    try {
      localStorage.setItem(SETTINGS_KEY, JSON.stringify(quickReplies));
    } catch (e) {
      console.error('[Quick Replies] Failed to save settings:', e);
    }
  }

  // ===== Suggestion Box =====
  let suggestionBox = null;
  let activeSuggestionIndex = 0;

  // ===== Entry Point =====
  if (isDiscoursePage()) {
    console.log('[Quick Replies] Discourse detected, initializing...');
    loadQuickReplies();
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', () => {
        initQuickReplyFeatures();
        initQuickInsertButton();
      });
    } else {
      initQuickReplyFeatures();
      initQuickInsertButton();
    }
  }

  // ===== Detailed Implementation =====

  function isDiscoursePage() {
    return document.querySelector('#main-outlet, .ember-application, textarea.d-editor-input, .ProseMirror.d-editor-input');
  }

  function initQuickReplyFeatures() {
    createSuggestionBox();
    document.addEventListener('input', handleInput, true);
    document.addEventListener('keydown', handleKeydown, true);
    document.addEventListener('click', e => {
      if (e.target && e.target.tagName !== 'TEXTAREA' && suggestionBox && !suggestionBox.contains(e.target)) {
        hideSuggestionBox();
      }
    });
    console.log('[Quick Replies] Suggestion features initialized.');
  }

  function initQuickInsertButton() {
    const observer = new MutationObserver(() => {
      const toolbars = document.querySelectorAll('.d-editor-button-bar, .chat-composer__inner-container');
      toolbars.forEach(toolbar => {
        if (!toolbar.querySelector('.quick-reply-settings-btn')) {
          injectQuickInsertButton(toolbar);
        }
      });
    });
    observer.observe(document.body, { childList: true, subtree: true });
    console.log('[Quick Replies] Quick insert button observer initialized.');
  }

  function injectQuickInsertButton(toolbar) {
    const quickInsertButton = document.createElement('button');
    quickInsertButton.className = 'btn no-text btn-icon toolbar__button quick-insert-button';
    quickInsertButton.title = 'Quick Replies';
    quickInsertButton.innerHTML = '⎘';
    quickInsertButton.addEventListener('click', e => {
      e.stopPropagation();
      toggleQuickInsertMenu(quickInsertButton);
    });

    const settingsButton = document.createElement('button');
    settingsButton.className = 'btn no-text btn-icon toolbar__button quick-reply-settings-btn';
    settingsButton.title = 'Quick Reply Settings';
    settingsButton.innerHTML = '⚙️';
    settingsButton.addEventListener('click', e => {
      e.stopPropagation();
      createSettingsUI();
    });

    toolbar.appendChild(quickInsertButton);
    toolbar.appendChild(settingsButton);
  }

  function toggleQuickInsertMenu(button) {
    let menu = document.getElementById('quick-insert-menu');
    if (menu) {
      menu.remove();
      return;
    }
    menu = createQuickInsertMenu();
    document.body.appendChild(menu);
    const rect = button.getBoundingClientRect();
    menu.style.position = 'fixed';
    menu.style.top = `${rect.bottom + 5}px`;
    menu.style.left = `${rect.left}px`;

    const removeMenu = ev => {
      if (menu && !menu.contains(ev.target)) {
        menu.remove();
        document.removeEventListener('click', removeMenu);
      }
    };
    setTimeout(() => document.addEventListener('click', removeMenu), 100);
  }

  function createQuickInsertMenu() {
    const menu = document.createElement('div');
    menu.id = 'quick-insert-menu';
    menu.style.cssText = `
      position: absolute;
      background-color: var(--secondary);
      border: 1px solid var(--primary-low-mid);
      border-radius: 6px;
      box-shadow: var(--d-shadow-medium);
      z-index: 10000;
      padding: 5px;
    `;
    quickReplies.forEach(reply => {
      const item = document.createElement('div');
      item.textContent = reply.display;
      item.style.cssText = `
        padding: 8px 12px;
        cursor: pointer;
        color: var(--primary-high);
        border-radius: 4px;
      `;
      item.onmouseover = () => item.style.backgroundColor = 'var(--d-hover)';
      item.onmouseout = () => item.style.backgroundColor = 'transparent';
      item.addEventListener('click', () => {
        insertIntoEditor(reply.insert);
        toggleQuickInsertMenu(); // Close menu
      });
      menu.appendChild(item);
    });
    return menu;
  }

  function insertIntoEditor(text) {
    const textarea = document.querySelector('textarea.d-editor-input, textarea.ember-text-area');
    if (textarea) {
      const start = textarea.selectionStart;
      const end = textarea.selectionEnd;
      textarea.value = textarea.value.substring(0, start) + text + textarea.value.substring(end);
      textarea.selectionStart = textarea.selectionEnd = start + text.length;
      textarea.focus();
      textarea.dispatchEvent(new Event('input', { bubbles: true }));
    } else {
        document.execCommand('insertText', false, text);
    }
  }

  // --- Suggestion Box Functions ---

  function createSuggestionBox() {
    if (suggestionBox) return;
    suggestionBox = document.createElement('div');
    suggestionBox.id = 'callout-suggestion-box';
    document.body.appendChild(suggestionBox);
    const style = document.createElement('style');
    style.textContent = `
      #callout-suggestion-box {
        position: absolute;
        background-color: var(--secondary);
        border: 1px solid var(--primary-low-mid);
        border-radius: 6px;
        box-shadow: var(--d-shadow-medium);
        z-index: 999999;
        padding: 5px;
        display: none;
      }
      .callout-suggestion-item {
        padding: 8px 12px;
        cursor: pointer;
        color: var(--primary-high);
        border-radius: 4px;
      }
      .callout-suggestion-item.active {
        background-color: var(--d-hover) !important;
      }
    `;
    document.head.appendChild(style);
  }

  function hideSuggestionBox() {
    if (suggestionBox) suggestionBox.style.display = 'none';
  }

  function updateSuggestionBox(element, matches) {
    if (!suggestionBox || matches.length === 0) {
      hideSuggestionBox();
      return;
    }
    suggestionBox.innerHTML = matches.map((reply, index) =>
      `<div class="callout-suggestion-item" data-index="${index}">${reply.display}</div>`
    ).join('');

    suggestionBox.querySelectorAll('.callout-suggestion-item').forEach((item, index) => {
      item.addEventListener('mousedown', e => {
        e.preventDefault();
        applyCompletion(element, matches[index]);
        hideSuggestionBox();
      });
    });

    const cursorPos = getCursorXY(element);
    suggestionBox.style.display = 'block';
    suggestionBox.style.left = `${cursorPos.x}px`;
    suggestionBox.style.top = `${cursorPos.bottom}px`;
    activeSuggestionIndex = 0;
    updateActiveSuggestion();
  }

    function updateActiveSuggestion() {
        if (!suggestionBox) return;
        const items = suggestionBox.querySelectorAll('.callout-suggestion-item');
        items.forEach((item, idx) => {
            item.classList.toggle('active', idx === activeSuggestionIndex);
            if (idx === activeSuggestionIndex) {
                item.scrollIntoView({ block: 'nearest' });
            }
        });
    }

  function handleInput(event) {
    const target = event.target;
    if (!(target instanceof HTMLTextAreaElement)) return;
    const textBeforeCursor = target.value.substring(0, target.selectionStart);
    const matches = quickReplies.filter(reply => textBeforeCursor.endsWith(reply.prefix));
    if (matches.length > 0) {
      updateSuggestionBox(target, matches);
    } else {
      hideSuggestionBox();
    }
  }

  function handleKeydown(event) {
    if (!suggestionBox || suggestionBox.style.display === 'none') return;
    const items = suggestionBox.querySelectorAll('.callout-suggestion-item');
    if (items.length === 0) return;
    if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) {
        event.preventDefault();
        event.stopPropagation();
    }
    switch (event.key) {
        case 'ArrowDown':
            activeSuggestionIndex = (activeSuggestionIndex + 1) % items.length;
            updateActiveSuggestion();
            break;
        case 'ArrowUp':
            activeSuggestionIndex = (activeSuggestionIndex - 1 + items.length) % items.length;
            updateActiveSuggestion();
            break;
        case 'Enter':
            const selectedItem = suggestionBox.querySelector(`.callout-suggestion-item[data-index="${activeSuggestionIndex}"]`);
            if (selectedItem) {
                const textBeforeCursor = document.activeElement.value.substring(0, document.activeElement.selectionStart);
                const matches = quickReplies.filter(reply => textBeforeCursor.endsWith(reply.prefix));
                if (matches.length > 0) {
                    applyCompletion(document.activeElement, matches[activeSuggestionIndex]);
                }
            }
            hideSuggestionBox();
            break;
        case 'Escape':
            hideSuggestionBox();
            break;
    }
  }

  function applyCompletion(element, selectedReply) {
    const text = element.value;
    const selectionStart = element.selectionStart;
    const textBeforeCursor = text.substring(0, selectionStart);
    const triggerIndex = textBeforeCursor.lastIndexOf(selectedReply.prefix);

    if (triggerIndex !== -1) {
        const textAfter = text.substring(selectionStart);
        element.value = text.substring(0, triggerIndex) + selectedReply.insert + textAfter;
        const newCursorPos = triggerIndex + selectedReply.insert.length;
        element.selectionStart = element.selectionEnd = newCursorPos;
        element.dispatchEvent(new Event('input', { bubbles: true }));
    }
  }

  function getCursorXY(element) {
    const mirrorId = 'textarea-mirror-div';
    let mirror = document.getElementById(mirrorId);
    if (!mirror) {
        mirror = document.createElement('div');
        mirror.id = mirrorId;
        document.body.appendChild(mirror);
    }
    const style = window.getComputedStyle(element);
    const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'padding', 'border'];
    props.forEach(p => mirror.style[p] = style[p]);
    mirror.style.position = 'absolute';
    mirror.style.visibility = 'hidden';
    mirror.style.whiteSpace = 'pre-wrap';
    mirror.style.wordWrap = 'break-word';
    mirror.style.left = `${element.offsetLeft}px`;
    mirror.style.top = `${element.offsetTop}px`;
    mirror.style.width = `${element.clientWidth}px`;
    mirror.style.height = 'auto';

    const textUpToCursor = element.value.substring(0, element.selectionEnd);
    mirror.textContent = textUpToCursor;
    const span = document.createElement('span');
    span.textContent = '.';
    mirror.appendChild(span);

    const rect = element.getBoundingClientRect();
    const spanRect = span.getBoundingClientRect();
    return {
        x: rect.left + span.offsetLeft,
        y: rect.top + span.offsetTop,
        bottom: rect.top + span.offsetTop + span.offsetHeight,
    };
  }


  // --- Settings UI ---

  function createSettingsUI() {
    let modal = document.getElementById('quick-reply-settings-modal');
    if (modal) {
      modal.remove();
      return;
    }

    modal = document.createElement('div');
    modal.id = 'quick-reply-settings-modal';
    modal.style.cssText = `
      position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
      width: 600px; max-width: 90vw; max-height: 80vh;
      background-color: var(--secondary);
      color: var(--primary-high);
      border: 1px solid var(--primary-low-mid);
      border-radius: 8px;
      z-index: 10001;
      box-shadow: var(--d-shadow-huge);
      display: flex; flex-direction: column;
    `;

    const header = document.createElement('div');
    header.textContent = 'Quick Reply Settings';
    header.style.cssText = 'font-size: 1.2em; padding: 15px; border-bottom: 1px solid var(--primary-low-mid);';

    const content = document.createElement('div');
    content.id = 'quick-reply-list-container';
    content.style.cssText = 'padding: 15px; overflow-y: auto;';

    const footer = document.createElement('div');
    footer.style.cssText = 'padding: 15px; border-top: 1px solid var(--primary-low-mid); display: flex; justify-content: space-between; align-items: center;';

    const addButton = document.createElement('button');
    addButton.textContent = 'Add New';
    addButton.className = 'btn';
    addButton.onclick = () => {
        quickReplies.push({ display: 'New Reply', insert: '', prefix: ''});
        renderQuickReplyList(content);
    };

    const saveButton = document.createElement('button');
    saveButton.textContent = 'Save and Close';
    saveButton.className = 'btn btn-primary';
    saveButton.onclick = () => {
        saveQuickReplies();
        modal.remove();
    };

    footer.appendChild(addButton);
    footer.appendChild(saveButton);

    modal.appendChild(header);
    modal.appendChild(content);
    modal.appendChild(footer);
    document.body.appendChild(modal);

    renderQuickReplyList(content);
  }

  function renderQuickReplyList(container) {
    container.innerHTML = '';
    quickReplies.forEach((reply, index) => {
        const item = document.createElement('div');
        item.style.cssText = 'display: flex; align-items: center; margin-bottom: 10px; gap: 10px;';

        const inputs = ['display', 'insert', 'prefix'].map(key => {
            const input = document.createElement('input');
            input.type = 'text';
            input.placeholder = key.charAt(0).toUpperCase() + key.slice(1);
            input.value = reply[key];
            input.style.cssText = `
              flex: 1;
              padding: 8px;
              background-color: var(--input-background);
              border: 1px solid var(--primary-low-mid);
              color: var(--primary-high);
              border-radius: 5px;
            `;
            input.oninput = () => quickReplies[index][key] = input.value;
            return input;
        });

        const deleteButton = document.createElement('button');
        deleteButton.textContent = 'Delete';
        deleteButton.className = 'btn btn-danger';
        deleteButton.onclick = () => {
            quickReplies.splice(index, 1);
            renderQuickReplyList(container);
        };

        inputs.forEach(input => item.appendChild(input));
        item.appendChild(deleteButton);
        container.appendChild(item);
    });
  }
})();