LINUX DO Reply → Boost Enhancer

为 linux.do 的回复区增加字数统计,并在短回复时提示改为 Boost。

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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();
  }
})();