Miniflux as Reeder

从 Reeder 迁移到 Miniflux 大概是去年的事。理由很简单:Reeder 只在 Apple 生态里活着,而我越来越需要在 Windows 和 Linux 上也能刷 RSS。自建 Miniflux,浏览器打开就能用,这件事本身没什么好说的。

但有一个功能一直让我有点别扭。

Reeder 里有个手势,长按某条条目,会弹出「标记此条及以上为已读」。这在实际用起来非常顺手——早上堆了一堆,往下翻到昨天看过的地方,长按一下,上面全消。干净。Miniflux 没有这个,每次要么全部标为已读(太粗暴),要么一条条点(太麻烦)。

忍了挺久。前几天终于坐下来看了一眼能不能自己加。


Miniflux 有个功能叫 Custom JavaScript,在设置里,可以往每个页面注入自定义脚本。这给了很大的空间。

思路其实不复杂:监听右键(桌面)和长按(手机),在条目上触发时弹出一个自定义菜单,点击后调用 Miniflux 内置的接口批量标记已读。

接口本身不需要 API Key,Miniflux 页面里已经把 CSRF token 放在 document.body.dataset.csrfToken 里,批量标记的 URL 也能从页面 DOM 里找到(entriesStatusUrl)。换句话说,权限问题已经解决了,剩下的只是怎么触发。

桌面端比较简单,监听 contextmenu 事件,阻止默认菜单,显示自己的就好。

手机端麻烦一点。长按在 iOS 上会和系统的文字选择、链接预览冲突。解决方法是两步:CSS 加上 -webkit-touch-callout: none 禁掉系统的长按菜单,然后事件监听要用 passive: false 加上 preventDefault(),才能真正拦住系统行为。调试这个花了点时间,主要是第一次忘了 passive: false,死活没效果。

最终的逻辑大概是这样:

// 注入到 Miniflux Custom JavaScript
(function () {
  let longPressTimer = null;
  const LONG_PRESS_MS = 600;

  function getEntryId(el) {
    const article = el.closest('article[data-id]');
    return article ? article.dataset.id : null;
  }

  function getAllEntryIds() {
    return [...document.querySelectorAll('article[data-id]')]
      .map(a => a.dataset.id);
  }

  function markAboveAsRead(targetId) {
    const ids = getAllEntryIds();
    const idx = ids.indexOf(targetId);
    if (idx === -1) return;
    const toMark = ids.slice(0, idx + 1);

    const csrfToken = document.body.dataset.csrfToken;
    const url = document.body.dataset.entriesStatusUrl;
    if (!url || !csrfToken) return;

    fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'X-Csrf-Token': csrfToken,
      },
      body: new URLSearchParams({ status: 'read', entryIDs: toMark.join(',') }),
    }).then(() => {
      toMark.forEach(id => {
        const el = document.querySelector(`article[data-id="${id}"]`);
        if (el) el.style.opacity = '0.4';
      });
    });
  }

  function showMenu(x, y, targetId) {
    document.querySelectorAll('.custom-ctx-menu').forEach(e => e.remove());
    const menu = document.createElement('div');
    menu.className = 'custom-ctx-menu';
    menu.style.cssText = `position:fixed;z-index:9999;background:#fff;border:1px solid #ccc;
      border-radius:6px;padding:6px 0;box-shadow:0 4px 12px rgba(0,0,0,.15);font-size:14px;`;
    menu.innerHTML = `<div style="padding:8px 16px;cursor:pointer;white-space:nowrap">标记此条及以上为已读</div>`;
    menu.querySelector('div').addEventListener('click', () => {
      markAboveAsRead(targetId);
      menu.remove();
    });
    menu.style.left = `${Math.min(x, window.innerWidth - 220)}px`;
    menu.style.top = `${Math.min(y, window.innerHeight - 60)}px`;
    document.body.appendChild(menu);
    const dismiss = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', dismiss); } };
    setTimeout(() => document.addEventListener('click', dismiss), 10);
  }

  // 桌面右键
  document.addEventListener('contextmenu', (e) => {
    const id = getEntryId(e.target);
    if (!id) return;
    e.preventDefault();
    showMenu(e.clientX, e.clientY, id);
  });

  // 移动端长按
  document.addEventListener('touchstart', (e) => {
    const id = getEntryId(e.target);
    if (!id) return;
    const touch = e.touches[0];
    longPressTimer = setTimeout(() => {
      showMenu(touch.clientX, touch.clientY, id);
    }, LONG_PRESS_MS);
  }, { passive: false });

  document.addEventListener('touchend', () => clearTimeout(longPressTimer));
  document.addEventListener('touchmove', () => clearTimeout(longPressTimer));

  // 禁用 iOS 系统长按菜单
  const style = document.createElement('style');
  style.textContent = 'article { -webkit-touch-callout: none; }';
  document.head.appendChild(style);
})();

贴进去,保存,刷新。右键一条条目,菜单出来了。手机上长按,也出来了。


有时候折腾这种小东西,乐趣不全在「做到了」,也在于顺着问题往里走的过程。Miniflux 暴露了足够的接口,Custom JavaScript 给了足够的空间,剩下的只是拼图。

Reeder 的体验确实打磨得很细。但自己补上缺的那一块,也挺好。