为 linux.do 的回复区增加字数统计,并在短回复时提示改为 Boost。
// ==UserScript==
// @name LINUX DO Reply → Boost Enhancer
// @namespace https://linux.do/
// @version 0.1.1
// @description 为 linux.do 的回复区增加字数统计,并在短回复时提示改为 Boost。
// @author QST Powered by Codex
// @match https://linux.do/t/*
// @run-at document-idle
// @grant unsafeWindow
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const SELECTORS = {
replyControl: '#reply-control',
textarea: '#reply-control textarea.d-editor-input',
submitButton: '#reply-control button.create',
saveOrCancel: '#reply-control .save-or-cancel',
};
const BOOST_LIMITS = {
maxChars: 16,
maxEmoji: 5,
};
const STATE = {
modal: null,
syncQueued: false,
observer: null,
lastUrl: location.href,
};
const EMOJI_RE = /\p{Extended_Pictographic}/gu;
function log(...args) {
console.debug('[linux.do boost enhancer]', ...args);
}
function injectStyles() {
if (document.getElementById('boost-reply-enhancer-style')) {
return;
}
const style = document.createElement('style');
style.id = 'boost-reply-enhancer-style';
style.textContent = `
.boost-reply-enhancer__counter {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-inline-start: 12px;
padding: 0.35em 0.75em;
border-radius: 999px;
border: 1px solid var(--primary-low, rgba(255,255,255,.18));
background: var(--secondary, rgba(255,255,255,.04));
color: var(--primary-high, #fff);
font-size: 0.875rem;
line-height: 1;
white-space: nowrap;
vertical-align: middle;
}
.boost-reply-enhancer__counter.is-short {
border-color: var(--danger-low, rgba(255, 120, 120, .35));
background: color-mix(in srgb, var(--danger, #ff6b6b) 14%, transparent);
color: var(--danger, #ff8f8f);
}
.boost-reply-enhancer__counter.is-boostable {
border-color: var(--tertiary-low, rgba(119, 195, 255, .35));
background: color-mix(in srgb, var(--tertiary, #6bc7ff) 14%, transparent);
color: var(--tertiary, #7dd3fc);
}
.boost-reply-enhancer__counter.is-ok {
border-color: var(--success-low, rgba(82, 196, 117, .35));
background: color-mix(in srgb, var(--success, #52c475) 14%, transparent);
color: var(--success, #79d996);
}
.boost-reply-enhancer__counter strong {
font-weight: 700;
}
.boost-reply-enhancer__modal {
position: fixed;
inset: 0;
z-index: 10050;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.boost-reply-enhancer__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(2px);
}
.boost-reply-enhancer__dialog {
position: relative;
width: min(520px, calc(100vw - 32px));
border: 1px solid var(--primary-low, rgba(255,255,255,.15));
border-radius: 14px;
background: var(--secondary, #1f1f1f);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.boost-reply-enhancer__header {
padding: 18px 20px 12px;
border-bottom: 1px solid var(--primary-low, rgba(255,255,255,.08));
}
.boost-reply-enhancer__title {
margin: 0;
color: var(--primary-high, #fff);
font-size: 1.05rem;
font-weight: 700;
}
.boost-reply-enhancer__body {
padding: 16px 20px;
color: var(--primary-high, #fff);
line-height: 1.65;
}
.boost-reply-enhancer__meta {
margin-top: 10px;
color: var(--primary-medium, rgba(255,255,255,.75));
font-size: 0.92rem;
}
.boost-reply-enhancer__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px 18px;
border-top: 1px solid var(--primary-low, rgba(255,255,255,.08));
}
.boost-reply-enhancer__footer .btn {
min-width: 118px;
}
`;
document.head.appendChild(style);
}
function getContainer() {
return W.Discourse?.__container__;
}
function lookup(name) {
try {
return getContainer()?.lookup?.(name) ?? null;
} catch (_error) {
return null;
}
}
function getComposer() {
return lookup('service:composer') || lookup('controller:composer');
}
function getComposerModel() {
return getComposer()?.model ?? null;
}
function getSiteSettings() {
return lookup('service:site-settings') || W.Discourse?.SiteSettings || {};
}
function getCurrentUser() {
return lookup('service:current-user');
}
function getDialog() {
return lookup('service:dialog');
}
function getCreateBoost() {
try {
return W.require?.('discourse/plugins/discourse-boosts/discourse/lib/create-boost')?.default ?? null;
} catch (_error) {
return null;
}
}
function getReplyTextarea() {
return document.querySelector(SELECTORS.textarea);
}
function getReplyText() {
const model = getComposerModel();
const textarea = getReplyTextarea();
return String(model?.reply ?? textarea?.value ?? '').replace(/\u200B/g, '');
}
function normalizeBoostText(text) {
return String(text || '')
.replace(/\u00A0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function countVisibleChars(text) {
return Array.from(String(text || '')).length;
}
function countEmoji(text) {
return (String(text || '').match(EMOJI_RE) || []).length;
}
function getMinReplyChars() {
const settings = getSiteSettings();
return Number(settings.min_post_length || 20);
}
function getReplyLength(rawText) {
const model = getComposerModel();
if (model && typeof model.replyLength === 'number' && model.reply === rawText) {
return model.replyLength;
}
return countVisibleChars(String(rawText || '').trim());
}
function getReplyTargetPost() {
const model = getComposerModel();
if (!model) {
return null;
}
if (model.post?.id) {
return model.post;
}
const replyToPostNumber =
model.replyToPostNumber ||
model.reply_to_post_number ||
model.post?.post_number ||
null;
const topic = model.topic || lookup('controller:topic')?.model;
const stream = topic?.postStream;
if (!stream) {
return null;
}
if (replyToPostNumber) {
return (
stream.posts?.find?.((post) => post.post_number === replyToPostNumber) ||
stream.findLoadedPost?.(replyToPostNumber) ||
null
);
}
return stream.posts?.[0] || null;
}
function canBoostPost(post) {
return !!(post && post.can_boost && !post.deleted);
}
function getBoostStatus(rawText) {
const normalized = normalizeBoostText(rawText);
const charCount = countVisibleChars(normalized);
const emojiCount = countEmoji(normalized);
const hasContent = normalized.length > 0;
const fitsChars = charCount <= BOOST_LIMITS.maxChars;
const fitsEmoji = emojiCount <= BOOST_LIMITS.maxEmoji;
return {
text: normalized,
hasContent,
charCount,
emojiCount,
fitsChars,
fitsEmoji,
canConvert: hasContent && fitsChars && fitsEmoji,
};
}
function getReplyMetrics() {
const raw = getReplyText();
const replyLength = getReplyLength(raw);
const minChars = getMinReplyChars();
const targetPost = getReplyTargetPost();
const boost = getBoostStatus(raw);
return {
raw,
replyLength,
minChars,
missingChars: Math.max(minChars - replyLength, 0),
targetPost,
targetCanBoost: canBoostPost(targetPost),
boost,
};
}
function shouldInterceptReply(metrics) {
const model = getComposerModel();
if (!model || model.action !== 'reply') {
return false;
}
return Boolean(metrics.raw.trim()) && metrics.replyLength > 0 && metrics.replyLength < metrics.minChars;
}
function focusReplyTextarea() {
getReplyTextarea()?.focus();
}
function showAlert(message) {
const dialog = getDialog();
if (dialog?.alert) {
dialog.alert(message);
return;
}
W.alert(message);
}
function showNotice(message) {
const dialog = getDialog();
if (dialog?.notice) {
dialog.notice(message);
return;
}
log(message);
}
function closeGuardModal() {
if (STATE.modal) {
STATE.modal.remove();
STATE.modal = null;
}
}
function showGuardModal(metrics) {
closeGuardModal();
injectStyles();
return new Promise((resolve) => {
const wrapper = document.createElement('div');
wrapper.className = 'boost-reply-enhancer__modal';
wrapper.innerHTML = `
<div class="boost-reply-enhancer__backdrop"></div>
<div class="boost-reply-enhancer__dialog" role="dialog" aria-modal="true" aria-labelledby="boost-reply-enhancer-title">
<div class="boost-reply-enhancer__header">
<h2 id="boost-reply-enhancer-title" class="boost-reply-enhancer__title">这条内容更像表态,是否改为 Boost?</h2>
</div>
<div class="boost-reply-enhancer__body">
<div>当前回复 <strong>${metrics.replyLength}</strong> / <strong>${metrics.minChars}</strong> 字,低于论坛回复门槛。</div>
<div class="boost-reply-enhancer__meta">目标帖子可接收 Boost;如果你只是想表达赞同、感受或简短补充,改成 Boost 会更顺手。</div>
</div>
<div class="boost-reply-enhancer__footer">
<button type="button" class="btn btn-default boost-reply-enhancer__continue">继续写成回复</button>
<button type="button" class="btn btn-primary boost-reply-enhancer__boost">改为 Boost</button>
</div>
</div>
`;
const cleanup = (result) => {
closeGuardModal();
resolve(result);
};
wrapper.querySelector('.boost-reply-enhancer__backdrop')?.addEventListener('click', () => cleanup('continue'));
wrapper.querySelector('.boost-reply-enhancer__continue')?.addEventListener('click', () => cleanup('continue'));
wrapper.querySelector('.boost-reply-enhancer__boost')?.addEventListener('click', () => cleanup('boost'));
wrapper.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
cleanup('continue');
}
});
document.body.appendChild(wrapper);
STATE.modal = wrapper;
wrapper.querySelector('.boost-reply-enhancer__boost')?.focus();
});
}
async function clearComposer() {
const composer = getComposer();
const model = composer?.model;
const textarea = getReplyTextarea();
const draftSequence = model?.draftSequence ?? null;
try {
composer.skipAutoSave = true;
} catch (_error) {
// noop
}
try {
const { cancel } = W.require?.('@ember/runloop') || {};
if (typeof cancel === 'function' && composer?._saveDraftDebounce) {
cancel(composer._saveDraftDebounce);
}
} catch (error) {
log('cancel draft debounce failed', error);
}
try {
await composer?.destroyDraft?.(draftSequence);
} catch (error) {
log('destroyDraft failed', error);
}
try {
model?.clearState?.();
} catch (error) {
log('clearState failed', error);
}
if (textarea) {
textarea.value = '';
}
try {
model?.set?.('reply', '');
model?.set?.('composeState', 'closed');
} catch (_error) {
// noop
}
try {
if (typeof composer?.close === 'function') {
composer.close();
} else {
composer?.closeComposer?.();
}
} catch (_error) {
// noop
}
try {
composer.skipAutoSave = false;
} catch (_error) {
// noop
}
scheduleSync();
}
async function convertReplyToBoost(metrics) {
const createBoost = getCreateBoost();
const currentUser = getCurrentUser();
const targetPost = metrics.targetPost;
const boostText = metrics.boost.text;
if (!createBoost || !currentUser || !targetPost) {
showAlert('无法调用 Boost 接口,请刷新页面后再试。');
focusReplyTextarea();
return;
}
try {
await createBoost(targetPost, boostText, currentUser);
await clearComposer();
showNotice('已改为 Boost。');
} catch (error) {
log('boost create failed', error);
showAlert('改为 Boost 失败,请稍后重试。');
focusReplyTextarea();
}
}
function buildCounterText(metrics) {
const base = `${metrics.replyLength}/${metrics.minChars}`;
if (!metrics.raw.trim()) {
return `${base}`;
}
if (metrics.replyLength >= metrics.minChars) {
return `${base}`;
}
if (!metrics.boost.canConvert) {
if (!metrics.boost.fitsChars) {
return `${base} · Boost ≤ ${BOOST_LIMITS.maxChars}`;
}
if (!metrics.boost.fitsEmoji) {
return `${base} · Boost 最多 ${BOOST_LIMITS.maxEmoji} 个表情`;
}
return `${base} · 字数不足`;
}
if (!metrics.targetCanBoost) {
return `${base} · 目标帖不可 Boost`;
}
return `${base} · 可改 Boost`;
}
function getCounterState(metrics) {
if (!metrics.raw.trim()) {
return 'neutral';
}
if (metrics.replyLength >= metrics.minChars) {
return 'ok';
}
if (metrics.boost.canConvert && metrics.targetCanBoost) {
return 'boostable';
}
return 'short';
}
function ensureCounter() {
const replyControl = document.querySelector(`${SELECTORS.replyControl}.open`);
const host = replyControl?.querySelector(SELECTORS.saveOrCancel);
if (!host) {
document.querySelectorAll('.boost-reply-enhancer__counter').forEach((node) => node.remove());
return;
}
let counter = host.querySelector('.boost-reply-enhancer__counter');
if (!counter) {
counter = document.createElement('span');
counter.className = 'boost-reply-enhancer__counter';
counter.setAttribute('aria-live', 'polite');
host.appendChild(counter);
}
const metrics = getReplyMetrics();
const state = getCounterState(metrics);
counter.className = 'boost-reply-enhancer__counter';
if (state === 'ok') {
counter.classList.add('is-ok');
} else if (state === 'boostable') {
counter.classList.add('is-boostable');
} else if (state === 'short') {
counter.classList.add('is-short');
}
counter.textContent = buildCounterText(metrics);
}
function scheduleSync() {
if (STATE.syncQueued) {
return;
}
STATE.syncQueued = true;
W.requestAnimationFrame(() => {
STATE.syncQueued = false;
ensureCounter();
});
}
async function interceptShortReply(event) {
const metrics = getReplyMetrics();
if (!shouldInterceptReply(metrics)) {
return false;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
if (!metrics.targetCanBoost) {
showAlert('帖子字数过少,且目标帖子已无法继续 Boost(可能已达上限)。');
focusReplyTextarea();
return true;
}
if (!metrics.boost.canConvert) {
if (!metrics.boost.fitsChars) {
showAlert(`帖子字数过少;同时该内容已超过 Boost 的 ${BOOST_LIMITS.maxChars} 字上限,请继续补充后再作为回复发送。`);
} else if (!metrics.boost.fitsEmoji) {
showAlert(`帖子字数过少;同时该内容已超过 Boost 的 ${BOOST_LIMITS.maxEmoji} 个表情上限,请继续补充后再作为回复发送。`);
} else {
showAlert('帖子字数过少,请继续补充后再发送回复。');
}
focusReplyTextarea();
return true;
}
const action = await showGuardModal(metrics);
if (action === 'boost') {
await convertReplyToBoost(metrics);
} else {
focusReplyTextarea();
}
return true;
}
function handleClick(event) {
const button = event.target.closest(SELECTORS.submitButton);
if (!button) {
return;
}
void interceptShortReply(event);
}
function handleShortcut(event) {
const target = event.target;
if (!(target instanceof HTMLTextAreaElement) || !target.matches(SELECTORS.textarea)) {
return;
}
const isSubmitShortcut = event.key === 'Enter' && (event.metaKey || event.ctrlKey);
if (!isSubmitShortcut) {
return;
}
void interceptShortReply(event);
}
function bindGlobalEvents() {
document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleShortcut, true);
document.addEventListener('input', scheduleSync, true);
document.addEventListener('change', scheduleSync, true);
}
function watchComposer() {
if (STATE.observer) {
return;
}
STATE.observer = new MutationObserver(() => {
if (location.href !== STATE.lastUrl) {
STATE.lastUrl = location.href;
closeGuardModal();
}
scheduleSync();
});
STATE.observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class'],
});
}
function boot() {
injectStyles();
bindGlobalEvents();
watchComposer();
scheduleSync();
log('ready');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();