/** * 浏览器通知(v32 第 3 条)。 * * 用户原话:「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了10点开始, * 提前十分钟提醒,则需要在 9:50 时浏览器中发提醒通知。」 * * 做两件事: * ① 定期问服务端 `?r=notifications/poll`(**到点与否由 PHP 算**,见 index.php 那段); * ② 把该响的用 Notification API 弹出来(站内消息 + 日程提醒)。 * * ──────────────────────────────────────────────────────────────────── * 五条设计判断(每条都对应一个"不这么做就会出的问题"): * * ① **权限只在用户"动手"时要,绝不在页面加载时自动弹**。 * 浏览器对"未经交互就 requestPermission"的做法会直接**静默拒绝** * (Chrome 的 abusiveness 规则),而且会把本域名拉进"用户不喜欢"的名单。 * ⇒ 页头放一个小铃铛按钮(`.fb-notify-enable`),用户点了才要权限。 * 用户没开权限时轮询**照常跑**(站内未读数还得更新),只是不弹系统通知。 * * ② **同一个提醒只弹一次**。轮询窗口故意留了"过去 2 分钟"(见后端注释: * 防标签页节流导致整条错过),代价是同一档提醒可能连续两轮都落进窗口。 * ⇒ 用 `已弹过的 key 集合`(`事件id@提醒时刻`)去重,且集合只在**内存**里 —— * 刷新页面会重弹一次"刚刚那条",这反而是对的(用户刷新说明他还没看到)。 * * ③ **点通知要能回到那个东西**。`new Notification(...).onclick` 里 * `window.focus()` + `location.href = 链接`。 * ⚠️ 用 `tag` 而不是 `new Notification` 的自定义字段:部分浏览器只认 tag, * 不认自定义属性(用 tag 还能让同一件事的通知**合并**而不是堆一列)。 * * ④ **后台标签页也要能弹**。`setInterval` 在后台标签页会被浏览器节流到 * 1~2 分钟一次(甚至更慢)。所以**轮询间隔取 60 秒**(与节流后的粒度相当), * 并把窗口开到"过去 2 分钟"把这层抖动吸收掉 —— 而不是去和浏览器对抗。 * * ⑤ **不用 Service Worker / 推送服务**。那要 HTTPS + 服务端推送通道, * 而用户要的是"页面开着时能提醒"。页面关掉还提醒属于 Web Push 的范畴, * 是另一件事(也超出"站内"的边界,与 Notification 头部第 ⑤条同一取舍)。 */ (function () { 'use strict'; var API = (window.FB_NOTIFY && window.FB_NOTIFY.poll) || ''; if (!API) { return; } var POLL_MS = 60000; // 见头部第 ④条 var seen = {}; // 已弹过的提醒 key(见头部第 ②条) var lastUnread = null; // 上一轮的未读数:只在**变多**时提醒,避免反复骚扰 var timer = null; function supported() { return (typeof window.Notification === 'function'); } function enabled() { return supported() && window.Notification.permission === 'granted'; } /** 弹一条系统通知;点了就回到对应的页面 */ function fire(title, body, href, tag, icon) { try { var n = new window.Notification(title, { body: body || '', tag: tag || '', // 同 tag 合并(见头部第 ③条) icon: icon || '' }); n.onclick = function () { try { window.focus(); } catch (e) { /* 有些浏览器不允许抢焦点 */ } if (href) { window.location.href = href; } try { n.close(); } catch (e) { /* 已关闭 */ } }; // 8 秒后自动收起:提醒条一直在会挡住下一个 setTimeout(function () { try { n.close(); } catch (e) { /* 已关 */ } }, 8000); } catch (e) { // 某些浏览器在"页面不可见"时抛(旧实现)—— 忽略,别让它影响轮询 } } function poll() { var xhr = new XMLHttpRequest(); xhr.open('GET', API, true); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.onload = function () { if (xhr.status !== 200) { return; } var d; try { d = JSON.parse(xhr.responseText); } catch (e) { return; } handle(d); }; xhr.send(); } function handle(d) { if (!d) { return; } // ---- 站内消息:只在**未读数变多**时弹一次(否则每轮都弹同样的几条)---- var unread = parseInt(d.unread || 0, 10); if (enabled() && unread > 0 && lastUnread !== null && unread > lastUnread) { var first = (d.items && d.items[0]) || null; fire('家书 · 新消息', first ? (first.actor + first.body) : ('你有 ' + unread + ' 条新消息'), (window.FB_NOTIFY && window.FB_NOTIFY.list) || '', 'fb-notify', (window.FB_NOTIFY && window.FB_NOTIFY.icon) || ''); } lastUnread = unread; // ---- 日程提醒:按 `id@at` 去重(见头部第 ②条)---- var rs = d.reminders || []; for (var i = 0; i < rs.length; i++) { var r = rs[i]; var key = r.id + '@' + r.at + '@' + (r.lead || ''); if (seen[key]) { continue; } seen[key] = true; if (!enabled()) { continue; } var t = String(r.starts_at || '').substr(11, 5); fire('日程提醒:' + (r.title || '(无标题)'), (r.lead ? ('还有 ' + r.lead + ' 分钟开始') : '马上开始') + (t ? ('(' + t + ')') : '') + (r.cal ? (' · ' + r.cal) : ''), r.href || '', 'fb-remind-' + r.id + '-' + (r.lead || ''), ''); } } /** 页头那个小铃铛:点一下要权限(见头部第 ①条) */ function bindButton() { var btn = document.querySelector('.fb-notify-enable'); if (!btn) { return; } var paint = function () { var on = enabled(); btn.hidden = on || !supported(); btn.title = '开启浏览器提醒'; btn.setAttribute('aria-label', '开启浏览器提醒'); }; btn.hidden = enabled() || !supported(); btn.addEventListener('click', function () { if (!supported()) { return; } window.Notification.requestPermission().then(function () { paint(); if (enabled()) { fire('家书 · 提醒已开启', '有新的消息或日程提醒时,我会在这里通知你。', '', 'fb-on', ''); } }); }); paint(); } function start() { bindButton(); poll(); // 先拉一次(页面刚打开时可能有未读) timer = setInterval(poll, POLL_MS); // 页面重新可见时立刻补一次:标签页在后台被节流,回来时可能已经错过窗口 document.addEventListener('visibilitychange', function () { if (!document.hidden) { poll(); } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } // 供探针/调试:手动触发一次 window.fbNotifyPoll = poll; })();