LinuxDo Sight shield

守护你的眼睛,远离你不喜欢的任何用户,你有权选择你想看见的东西。

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         LinuxDo Sight shield
// @namespace    https://github.com/Ooxygen7
// @version      1.1.0
// @description  守护你的眼睛,远离你不喜欢的任何用户,你有权选择你想看见的东西。
// @author       -
// @match        https://linux.do/*
// @match        https://www.linux.do/*
// @run-at       document-start
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_addValueChangeListener
// @license MIT
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  const STORE_KEY = 'ldub_blocked_users';
  const CFG_KEY = 'ldub_config';
  const HIDDEN = 'ldub-hidden';
  const STYLE_ID = 'ldub-style';

  let blockedList = loadList();
  let blockedSet = new Set(blockedList.map(normalize));
  let cfg = Object.assign({ enabled: true, showCardButton: true }, loadConfig());

  cfg.topicMode = 'op';
  cfg.hideBoosts = true;

  function loadList() {
    try {
      const value = JSON.parse(GM_getValue(STORE_KEY, '[]'));
      return Array.isArray(value) ? value : [];
    } catch (_) {
      return [];
    }
  }

  function loadConfig() {
    try {
      return JSON.parse(GM_getValue(CFG_KEY, '{}')) || {};
    } catch (_) {
      return {};
    }
  }

  function saveList() {
    GM_setValue(STORE_KEY, JSON.stringify(blockedList));
    blockedSet = new Set(blockedList.map(normalize));
  }

  function saveConfig() {
    GM_setValue(CFG_KEY, JSON.stringify(cfg));
  }

  function normalize(value) {
    return String(value || '').trim().replace(/^@+/, '').toLowerCase();
  }

  function isBlocked(username) {
    const normalized = normalize(username);
    return normalized !== '' && blockedSet.has(normalized);
  }

  function usernameFromHref(href) {
    const match = String(href || '').match(/\/u\/([^/?#]+)/i);
    return match ? decodeURIComponent(match[1]) : null;
  }

  function usernameOf(element) {
    if (!element || element.nodeType !== Node.ELEMENT_NODE) return null;
    return element.getAttribute('data-user-card')
      || element.getAttribute('data-username')
      || usernameFromHref(element.getAttribute('href'));
  }

  function hasBlockedUser(container) {
    if (!container) return false;
    for (const element of container.querySelectorAll('[data-user-card], [data-username], a[href*="/u/"]')) {
      if (isBlocked(usernameOf(element))) return true;
    }
    return false;
  }

  function collect(root, selector) {
    if (!root || root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_NODE) return [];
    const nodes = [];
    if (root.nodeType === Node.ELEMENT_NODE && root.matches(selector)) nodes.push(root);
    nodes.push(...root.querySelectorAll(selector));
    return nodes;
  }

  function hide(element) {
    if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
    element.classList.add(HIDDEN);
    element.hidden = true;
    element.style.setProperty('display', 'none', 'important');
  }

  function unhideAll() {
    document.querySelectorAll('.' + HIDDEN).forEach(element => {
      element.classList.remove(HIDDEN);
      element.hidden = false;
      element.style.removeProperty('display');
    });
  }

  function postAuthor(post) {
    return post.querySelector(
      '.topic-avatar a.main-avatar[data-user-card], ' +
      '.post-avatar a.main-avatar[data-user-card], ' +
      '.topic-meta-data .names a[data-user-card], ' +
      '.names a[data-user-card]'
    );
  }

  function scanPosts(root) {
    for (const post of collect(root, 'article[data-post-id]')) {
      const author = postAuthor(post);
      if (isBlocked(usernameOf(author))) hide(post.closest('.topic-post') || post);
    }
  }

  function scanOpenedTopic(root) {
    for (const owner of collect(root, '.topic-post.post--topic-owner')) {
      const author = owner.querySelector('a.main-avatar[data-user-card]');
      if (!isBlocked(usernameOf(author))) continue;
      const topic = owner.closest('.regular.ember-view');
      hide(topic || owner.closest('#topic') || owner);
    }
  }

  function posterLinks(row) {
    const scoped = row.querySelectorAll(
      '.posters a[data-user-card], .topic-list-data.posters a[data-user-card], td.posters a[data-user-card]'
    );
    return scoped.length ? Array.from(scoped) : Array.from(row.querySelectorAll('a[data-user-card]'));
  }

  function scanTopicRows(root) {
    for (const row of collect(root, '.topic-list-item, .latest-topic-list-item')) {
      const posters = posterLinks(row);
      if (!posters.length) continue;

      if (isBlocked(usernameOf(posters[0]))) {
        hide(row);
        continue;
      }

      for (const poster of posters) {
        if (isBlocked(usernameOf(poster))) hide(poster);
      }
    }
  }

  function scanSearchResults(root) {
    for (const result of collect(root, '.fps-result, .search-result')) {
      const author = result.querySelector('.author [data-user-card], .author a[href*="/u/"]');
      if (isBlocked(usernameOf(author))) hide(result);
    }
  }

  function scanTopicMapUsers(root) {
    for (const avatar of collect(root, '.topic-map__users-list a[data-user-card]')) {
      if (!isBlocked(usernameOf(avatar))) continue;
      const item = avatar.parentElement;
      hide(item?.parentElement?.classList.contains('topic-map__users-list') ? item : avatar);
    }
  }

  function scanBoosts(root) {
    if (!cfg.hideBoosts) return;
    const selector = '.discourse-boosts__bubble, .post-boost, .discourse-boost, [class*="boost-item"], [class*="boost-bubble"]';
    for (const boost of collect(root, selector)) {
      if (hasBlockedUser(boost)) hide(boost);
    }
  }

  function scanQuotes(root) {
    for (const quote of collect(root, 'aside.quote[data-username], .quote[data-username]')) {
      if (isBlocked(quote.getAttribute('data-username'))) hide(quote);
    }
  }

  function scanFeeds(root) {
    const selector = '.user-stream-item, .activity-stream .item, .bookmark-list-item';
    for (const item of collect(root, selector)) {
      const author = item.querySelector('a.main-avatar[data-user-card], .author [data-user-card], .avatar[data-user-card]');
      if (isBlocked(usernameOf(author))) hide(item);
    }
  }

  function scan(root) {
    if (!cfg.enabled || blockedSet.size === 0) return;
    scanPosts(root);
    scanOpenedTopic(root);
    scanTopicRows(root);
    scanSearchResults(root);
    scanTopicMapUsers(root);
    scanBoosts(root);
    scanQuotes(root);
    scanFeeds(root);
  }

  function maybeInjectCardButton(root) {
    if (!cfg.showCardButton) return;
    for (const card of collect(root, '.user-card, #user-card')) {
      if (card.querySelector('.ldub-card-btn')) continue;
      const profile = card.querySelector('a[data-user-card], a[href^="/u/"]');
      const username = usernameOf(profile);
      if (!username) continue;

      const host = card.querySelector('.usercard-controls, .user-card-controls, .card-row .controls, .names, .card-content') || card;
      const button = document.createElement('button');
      button.className = 'ldub-card-btn';
      button.textContent = isBlocked(username) ? '已屏蔽 ✓' : '🚫 本地屏蔽';
      button.title = '将 @' + username + ' 加入本地屏蔽名单';
      button.addEventListener('click', event => {
        event.preventDefault();
        event.stopPropagation();
        if (!isBlocked(username)) {
          blockedList.push(username);
          saveList();
          scan(document);
        }
        button.textContent = '已屏蔽 ✓';
      });
      host.appendChild(button);
    }
  }

  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;
    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = [
      '.' + HIDDEN + '{display:none !important;}',
      '.ldub-card-btn{display:inline-block;margin:6px 4px 2px;padding:4px 10px;font-size:12px;line-height:1.4;cursor:pointer;border:1px solid var(--primary-low-mid,#aaa);border-radius:6px;background:var(--secondary,#fff);color:var(--primary,#333);}',
      '.ldub-card-btn:hover{background:var(--danger-low,#fdecec);border-color:var(--danger,#c00);color:var(--danger,#c00);}'
    ].join('');
    (document.head || document.documentElement).appendChild(style);
  }

  let pending = false;
  function requestFullScan() {
    if (pending) return;
    pending = true;
    queueMicrotask(() => {
      pending = false;
      scan(document);
      maybeInjectCardButton(document);
    });
  }

  function scanMutationNode(node) {
    if (node.nodeType !== Node.ELEMENT_NODE) return;
    scan(node);
    maybeInjectCardButton(node);

    const container = node.closest(
      'article[data-post-id], .topic-post.post--topic-owner, .topic-list-item, .latest-topic-list-item, ' +
      '.fps-result, .search-result, .discourse-boosts__bubble, .post-boost, .discourse-boost, ' +
      '.topic-map__users-list, .user-stream-item, .activity-stream .item, .bookmark-list-item'
    );
    if (container && container !== node) {
      scan(container);
      maybeInjectCardButton(container);
    }
  }

  function startObserver() {
    new MutationObserver(mutations => {
      for (const mutation of mutations) {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach(scanMutationNode);
          continue;
        }
        if (mutation.target.classList?.contains(HIDDEN)) continue;
        if (mutation.attributeName === 'data-user-card' || mutation.attributeName === 'data-username' || mutation.attributeName === 'href' || mutation.attributeName === 'class') {
          scanMutationNode(mutation.target);
        }
      }
    }).observe(document.documentElement, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['data-user-card', 'data-username', 'href', 'class']
    });
  }

  function hookRouting() {
    const schedule = () => setTimeout(requestFullScan, 0);
    for (const method of ['pushState', 'replaceState']) {
      const original = history[method];
      history[method] = function () {
        const result = original.apply(this, arguments);
        schedule();
        return result;
      };
    }
    window.addEventListener('popstate', schedule);
  }

  function addUsers() {
    const input = prompt('输入要屏蔽的用户名(@ 后的用户名;可用空格或逗号分隔):');
    if (!input) return;
    let added = 0;
    for (const username of input.split(/[\s,,]+/).map(normalize).filter(Boolean)) {
      if (!blockedSet.has(username)) {
        blockedList.push(username);
        blockedSet.add(username);
        added++;
      }
    }
    if (added) saveList();
    scan(document);
    alert('已添加 ' + added + ' 人;当前共屏蔽 ' + blockedList.length + ' 人。');
  }

  function manageUsers() {
    if (!blockedList.length) {
      alert('当前没有屏蔽任何用户。');
      return;
    }
    const input = prompt('当前名单:\n' + blockedList.join('\n') + '\n\n输入要移除的用户名(多个用逗号分隔);输入 * 清空全部。');
    if (input === null) return;
    const value = input.trim();
    if (value === '*') blockedList = [];
    else if (value) {
      const removed = new Set(value.split(/[\s,,]+/).map(normalize).filter(Boolean));
      blockedList = blockedList.filter(username => !removed.has(normalize(username)));
    } else return;
    saveList();
    unhideAll();
    scan(document);
    alert('名单已更新;当前共屏蔽 ' + blockedList.length + ' 人。');
  }

  function toggle() {
    cfg.enabled = !cfg.enabled;
    saveConfig();
    if (cfg.enabled) scan(document);
    else unhideAll();
    alert('本地屏蔽已' + (cfg.enabled ? '开启' : '关闭') + '。');
  }

  function registerMenus() {
    GM_registerMenuCommand('➕ 添加屏蔽用户', addUsers);
    GM_registerMenuCommand('🧾 管理屏蔽名单 (' + blockedList.length + ')', manageUsers);
    GM_registerMenuCommand('🔇 ' + (cfg.enabled ? '关闭' : '开启') + '本地屏蔽', toggle);
  }

  function init() {
    injectStyle();
    registerMenus();

    if (typeof GM_addValueChangeListener === 'function') {
      GM_addValueChangeListener(STORE_KEY, (_, __, value, remote) => {
        if (!remote) return;
        try {
          blockedList = JSON.parse(value) || [];
          blockedSet = new Set(blockedList.map(normalize));
          unhideAll();
          scan(document);
        } catch (_) {}
      });
    }

    const ready = () => {
      requestFullScan();
      startObserver();
      hookRouting();
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready, { once: true });
    else ready();
  }

  init();
})();