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 的体验确实打磨得很细。但自己补上缺的那一块,也挺好。