LINUX DO Quick Mention

在回复框中快速 @ 常见用户,支持保存常用联系人列表

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         LINUX DO Quick Mention
// @namespace    https://linux.do/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=linux.do
// @version      0.2
// @description  在回复框中快速 @ 常见用户,支持保存常用联系人列表
// @author       ccc9527-c
// @match        https://linux.do/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const CONTACTS_KEY = "quick_mention_contacts";
  const BUTTON_ID = "quick-mention-btn";
  const PANEL_ID = "quick-mention-panel";
  const SEARCH_INPUT_ID = "quick-mention-search";
  const SEARCH_RESULTS_ID = "quick-mention-results";
  const CONTACTS_LIST_ID = "quick-mention-contacts";

  let currentEditor = null;
  let searchTimer = null;

  function getCsrfToken() {
    return (
      document
        .querySelector('meta[name="csrf-token"]')
        ?.getAttribute("content") || ""
    );
  }

  function getDiscourseHeaders() {
    const csrfToken = getCsrfToken();
    return {
      "X-Requested-With": "XMLHttpRequest",
      "X-CSRF-Token": csrfToken,
      Accept: "application/json",
    };
  }

  function escapeHtml(text) {
    if (!text) return "";
    const div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
  }

  function getAvatarUrl(template, size = 48) {
    if (!template) {
      return "";
    }
    if (template.startsWith("http")) return template;
    return "https://cdn.ldstatic.com" + template.replace("{size}", size);
  }

  function normalizeContacts(contacts) {
    const result = [];
    const seen = new Set();

    contacts.forEach((contact) => {
      if (!contact || !contact.username) return;
      const username = String(contact.username).trim();
      if (!username) return;

      const key = username.toLowerCase();
      if (seen.has(key)) return;
      seen.add(key);

      result.push({
        username,
        name: String(contact.name || username).trim(),
        avatar_template: String(contact.avatar_template || ""),
      });
    });

    return result;
  }

  function getStoredContacts() {
    try {
      const raw = GM_getValue(CONTACTS_KEY, "[]");
      const parsed = JSON.parse(raw);
      return normalizeContacts(Array.isArray(parsed) ? parsed : []);
    } catch (e) {
      console.error("[快速 @] 读取联系人失败", e);
      return [];
    }
  }

  function saveContacts(contacts) {
    const normalized = normalizeContacts(Array.isArray(contacts) ? contacts : []);
    GM_setValue(CONTACTS_KEY, JSON.stringify(normalized));
    return normalized;
  }

  function addContact(user) {
    const contacts = getStoredContacts();
    if (contacts.find((c) => c.username === user.username)) {
      return contacts;
    }
    contacts.unshift({
      username: user.username,
      name: user.name || user.username,
      avatar_template: user.avatar_template || "",
    });
    return saveContacts(contacts);
  }

  function removeContact(username) {
    const contacts = getStoredContacts();
    return saveContacts(contacts.filter((c) => c.username !== username));
  }

  function insertMention(username) {
    if (!currentEditor) return;

    const textarea = currentEditor.querySelector("textarea");
    if (!textarea) return;

    const mention = `@${username} `;
    const start = textarea.selectionStart;
    const end = textarea.selectionEnd;
    const text = textarea.value;

    textarea.value = text.substring(0, start) + mention + text.substring(end);
    textarea.selectionStart = textarea.selectionEnd = start + mention.length;

    textarea.focus();
    textarea.dispatchEvent(new Event("input", { bubbles: true }));

    hidePanel();
  }

  async function searchUsers(term) {
    if (!term || term.trim().length === 0) {
      return [];
    }

    try {
      const response = await fetch(
        `/search/query.json?term=${encodeURIComponent(
          term,
        )}&type_filter=exclude_topics`,
        {
          headers: getDiscourseHeaders(),
        },
      );
      const data = await response.json();
      return Array.isArray(data.users) ? data.users : [];
    } catch (e) {
      console.error("[快速 @] 搜索用户失败", e);
      return [];
    }
  }

  function isInContacts(username) {
    const contacts = getStoredContacts();
    return contacts.some((c) => c.username === username);
  }

  function renderUserItem(user, showActionButton = false) {
    const inContacts = isInContacts(user.username);
    return `
      <div class="quick-mention-user-item" data-username="${escapeHtml(user.username)}">
        <img src="${getAvatarUrl(user.avatar_template)}" class="quick-mention-avatar ${
      user.username === "neo" ? "square-avatar" : ""
    }">
        <div class="quick-mention-user-info">
          <div class="quick-mention-user-name">${escapeHtml(user.name || user.username)}</div>
          <div class="quick-mention-user-username">@${escapeHtml(user.username)}</div>
        </div>
        ${
          showActionButton
            ? inContacts
              ? `<button class="quick-mention-remove-btn" data-username="${escapeHtml(user.username)}">×</button>`
              : `<button class="quick-mention-add-btn" data-username="${escapeHtml(user.username)}">+</button>`
            : `<button class="quick-mention-remove-btn" data-username="${escapeHtml(user.username)}">×</button>`
        }
      </div>
    `;
  }

  function renderSearchResults(users) {
    const resultsDiv = document.getElementById(SEARCH_RESULTS_ID);
    if (!resultsDiv) return;

    if (!users || users.length === 0) {
      resultsDiv.innerHTML =
        '<div class="quick-mention-empty">未找到用户</div>';
      return;
    }

    resultsDiv.innerHTML = users.map((user) => renderUserItem(user, true)).join("");

    resultsDiv.querySelectorAll(".quick-mention-user-item").forEach((item) => {
      item.addEventListener("click", (e) => {
        if (
          e.target.classList.contains("quick-mention-add-btn") ||
          e.target.classList.contains("quick-mention-remove-btn")
        ) {
          return;
        }
        const username = item.dataset.username;
        insertMention(username);
      });
    });

    resultsDiv.querySelectorAll(".quick-mention-add-btn").forEach((btn) => {
      btn.addEventListener("click", (e) => {
        e.stopPropagation();
        const username = btn.dataset.username;
        const user = users.find((u) => u.username === username);
        if (user) {
          addContact(user);
          renderSearchResults(users);
          renderContacts();
        }
      });
    });

    resultsDiv.querySelectorAll(".quick-mention-remove-btn").forEach((btn) => {
      btn.addEventListener("click", (e) => {
        e.stopPropagation();
        const username = btn.dataset.username;
        removeContact(username);
        renderSearchResults(users);
        renderContacts();
      });
    });
  }

  function renderContacts() {
    const contactsDiv = document.getElementById(CONTACTS_LIST_ID);
    if (!contactsDiv) return;

    const contacts = getStoredContacts();

    if (contacts.length === 0) {
      contactsDiv.innerHTML =
        '<div class="quick-mention-empty">暂无常用联系人<br><small>搜索用户后点击 + 添加</small></div>';
      return;
    }

    contactsDiv.innerHTML = `
      <div class="quick-mention-contacts-header">
        <span>常用联系人</span>
      </div>
      <div class="quick-mention-contacts-list">
        ${contacts.map((contact) => renderUserItem(contact, false)).join("")}
      </div>
    `;

    contactsDiv
      .querySelectorAll(".quick-mention-user-item")
      .forEach((item) => {
        item.addEventListener("click", (e) => {
          if (e.target.classList.contains("quick-mention-remove-btn")) {
            return;
          }
          const username = item.dataset.username;
          insertMention(username);
        });
      });

    contactsDiv.querySelectorAll(".quick-mention-remove-btn").forEach((btn) => {
      btn.addEventListener("click", (e) => {
        e.stopPropagation();
        const username = btn.dataset.username;
        removeContact(username);
        renderContacts();
      });
    });
  }

  function createPanel() {
    let panel = document.getElementById(PANEL_ID);
    if (panel) return panel;

    panel = document.createElement("div");
    panel.id = PANEL_ID;
    panel.className = "quick-mention-panel";
    panel.innerHTML = `
      <div class="quick-mention-search-box">
        <input
          id="${SEARCH_INPUT_ID}"
          type="text"
          placeholder="搜索用户..."
          class="quick-mention-search-input"
        >
      </div>
      <div id="${SEARCH_RESULTS_ID}" class="quick-mention-results"></div>
      <div id="${CONTACTS_LIST_ID}" class="quick-mention-contacts"></div>
    `;
    document.body.appendChild(panel);

    const searchInput = panel.querySelector(`#${SEARCH_INPUT_ID}`);
    searchInput.addEventListener("input", () => {
      clearTimeout(searchTimer);
      const term = searchInput.value.trim();

      if (!term) {
        document.getElementById(SEARCH_RESULTS_ID).innerHTML = "";
        return;
      }

      searchTimer = setTimeout(async () => {
        const users = await searchUsers(term);
        renderSearchResults(users);
      }, 300);
    });

    renderContacts();

    return panel;
  }

  function showPanel(button) {
    const panel = createPanel();
    const rect = button.getBoundingClientRect();

    panel.style.top = `${rect.bottom + 5}px`;
    panel.style.left = `${rect.left}px`;
    panel.classList.add("active");

    const searchInput = panel.querySelector(`#${SEARCH_INPUT_ID}`);
    if (searchInput) {
      searchInput.value = "";
      searchInput.focus();
    }

    document.getElementById(SEARCH_RESULTS_ID).innerHTML = "";
    renderContacts();
  }

  function hidePanel() {
    const panel = document.getElementById(PANEL_ID);
    if (panel) {
      panel.classList.remove("active");
    }
  }

  function addButtonToEditor(editor) {
    if (!editor) return;
    if (editor.dataset.quickMentionAdded === "1") return;

    const toolbar = editor.querySelector(".d-editor-button-bar");
    if (!toolbar) return;

    const button = document.createElement("button");
    button.id = BUTTON_ID;
    button.className = "btn btn-icon no-text quick-mention-trigger";
    button.title = "快速 @ 用户";
    button.innerHTML = "@";
    button.type = "button";

    button.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      currentEditor = editor;
      const panel = document.getElementById(PANEL_ID);
      if (panel && panel.classList.contains("active")) {
        hidePanel();
      } else {
        showPanel(button);
      }
    });

    toolbar.appendChild(button);
    editor.dataset.quickMentionAdded = "1";
  }

  function observeEditors() {
    const observer = new MutationObserver(() => {
      const editors = document.querySelectorAll(".d-editor-container");
      editors.forEach((editor) => {
        addButtonToEditor(editor);
      });
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    document.querySelectorAll(".d-editor-container").forEach((editor) => {
      addButtonToEditor(editor);
    });
  }

  document.addEventListener("click", (e) => {
    const panel = document.getElementById(PANEL_ID);
    if (!panel) return;

    const button = document.getElementById(BUTTON_ID);
    if (
      !panel.contains(e.target) &&
      e.target !== button &&
      !button?.contains(e.target)
    ) {
      hidePanel();
    }
  });

  function addStyles() {
    GM_addStyle(`
      #quick-mention-btn {
        transform: translateY(-2px);
      }

      .quick-mention-trigger {
        font-weight: bold;
        font-size: 16px;
      }

      .quick-mention-panel {
        position: fixed;
        width: 320px;
        max-height: 480px;
        background: var(--secondary, #fff);
        border: 1px solid var(--primary-low, #ddd);
        border-radius: 8px;
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
        z-index: 10000;
        display: none;
        flex-direction: column;
        overflow: hidden;
      }

      .quick-mention-panel.active {
        display: flex;
      }

      .quick-mention-search-box {
        padding: 12px;
        border-bottom: 1px solid var(--primary-low, #eee);
      }

      .quick-mention-search-input {
        width: 100%;
        height: 36px;
        border: 1px solid var(--primary-low, #ddd);
        border-radius: 6px;
        padding: 0 12px;
        font-size: 14px;
        background: var(--secondary, #fff);
        color: var(--primary, #222);
      }

      .quick-mention-search-input:focus {
        outline: none;
        border-color: var(--tertiary, #08c);
      }

      .quick-mention-results,
      .quick-mention-contacts {
        max-height: 200px;
        overflow-y: auto;
      }

      .quick-mention-contacts {
        flex: 1;
        overflow-y: auto;
      }

      .quick-mention-contacts-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 10px 12px 8px;
        font-size: 12px;
        font-weight: 600;
        color: var(--primary-medium, #777);
        border-top: 1px solid var(--primary-low, #eee);
      }

      .quick-mention-contacts-list {
        padding: 0 12px 8px;
      }

      .quick-mention-user-item {
        display: flex;
        align-items: center;
        gap: 10px;
        padding: 8px;
        cursor: pointer;
        border-radius: 6px;
        transition: background 0.2s;
        position: relative;
      }

      .quick-mention-user-item:hover {
        background: var(--primary-very-low, #f5f5f5);
      }

      .quick-mention-avatar {
        width: 36px;
        height: 36px;
        border-radius: 50%;
        object-fit: cover;
        flex-shrink: 0;
      }

      .quick-mention-avatar.square-avatar {
        border-radius: 6px;
      }

      .quick-mention-user-info {
        flex: 1;
        min-width: 0;
      }

      .quick-mention-user-name {
        font-size: 14px;
        font-weight: 600;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      .quick-mention-user-username {
        font-size: 12px;
        color: var(--primary-medium, #777);
      }

      .quick-mention-add-btn {
        width: 24px;
        height: 24px;
        border-radius: 50%;
        border: none;
        background: var(--tertiary, #08c);
        color: #fff;
        font-size: 18px;
        line-height: 1;
        cursor: pointer;
        flex-shrink: 0;
        transition: transform 0.2s;
      }

      .quick-mention-add-btn:hover {
        transform: scale(1.1);
      }

      .quick-mention-remove-btn {
        width: 24px;
        height: 24px;
        border-radius: 50%;
        border: none;
        background: var(--danger, #e45735);
        color: #fff;
        font-size: 18px;
        line-height: 1;
        cursor: pointer;
        flex-shrink: 0;
        transition: transform 0.2s;
      }

      .quick-mention-remove-btn:hover {
        transform: scale(1.1);
      }

      .quick-mention-empty {
        padding: 20px;
        text-align: center;
        font-size: 13px;
        color: var(--primary-medium, #777);
      }

      .quick-mention-empty small {
        display: block;
        margin-top: 6px;
        font-size: 11px;
        color: var(--primary-low-mid, #aaa);
      }
    `);
  }

  function init() {
    addStyles();
    observeEditors();
    console.log("[快速 @] 已加载");
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();