在回复框中快速 @ 常见用户,支持保存常用联系人列表
// ==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();
}
})();