新消息提示:标签页标题滚动+未读数、favicon角标、桌面通知(含何时不弹的判断)

三件套,参照Slack/Telegram Web/WhatsApp Web等主流IM网页客户端的标准做法:

- lib/notifications/tabIndicator.ts:标签页被切到后台且有未读时,标题在
  "📨 (N条新消息) <原标题>"上做左向滚动,用户切回来的瞬间(visibilitychange)
  立即恢复原标题,不等下一次未读数变化才发现。favicon右上角画一个红色圆点+
  白色数字角标(canvas绘制成PNG data URL,写回现有<link rel="icon">,不是
  另加一个可能被浏览器忽略的竞争链接)。两者都由AppShell.tsx里已经算好的
  同一个unreadMessages号驱动,不另起一套可能对不上的计数。

- lib/notifications/push.ts:真正的浏览器桌面通知(Notification API)。只
  在标签页还开着(哪怕最小化/切到后台)时生效——做不到浏览器整个关掉也能收到,
  那需要Service Worker+推送订阅+后端配合,是完全另一个量级的工程。

  抑制规则("什么时候不弹"):
  · 消息来自自己(比如另一个标签页/设备同步过来的回显)
  · 正在看的就是这个会话本身(会话已打开且标签页确实可见——两者都要满足,
    一个被切到后台的标签页哪怕挂载着这个会话也不算"正在看")
  · 该会话设置了"消息免打扰"(contact.private.mute_notifications)
  · 浏览器未授予通知权限
  同一会话的连续通知用tag合并显示为一条,不会叠加刷屏;点击通知会聚焦标签页
  并跳转到对应会话(通过stores/notificationNav.ts中转,push.ts在React之外,
  没有useNavigate可用,做法与stores/calls.ts、messageJump.ts是同一个模式)。

  判断"是否是真的新消息"用的是防抖后的seq对比(防止刚打开一个从未见过的会话时,
  历史消息分批到达被逐条误判成"新消息")——第一次见到某个会话时记录基准而不是
  照单全收。**这里踩到一个真实bug并当场修复**:最初版本把"第一次见到这个会话"
  一律当成"历史消息,不通知",结果全新好友的第一条消息(对这个会话来说seq=1,
  同时也是"第一次见到")被误判成历史消息静默吞掉——用两个全新互加好友的测试
  账号实机复现后,改成额外看消息时间戳:一分钟以内的算真正新消息,无论是不是
  第一次见到这个会话,否则才当历史记录处理。

实机验证(systest01/systest02两个全新互加好友的测试账号,全部通过mock
Notification类捕获+SDK本地状态直接读取的方式验证,因为这个自动化浏览器环境
本身把Notification.permission锁死成denied,没法测试真正弹出的系统通知):
- 真实新消息:通知的title/body/tag均正确(对方昵称/消息预览文本/会话id)
- 免打扰会话:确认不弹
- 已打开且标签页可见:确认不弹
- 标题栏滚动+favicon角标:实机确认能正确开始/停止滚动、favicon生成的是
  真实可解码的64×64 PNG

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 20:14:12 +08:00
co-authored by Claude Sonnet 5
parent 8a593f50c3
commit cd6b7675d4
6 changed files with 290 additions and 1 deletions
+130
View File
@@ -0,0 +1,130 @@
// Desktop/browser Notification API integration: fires a real OS-level notification for a genuinely
// new incoming message, with the same "when NOT to" judgment calls every other IM web client makes.
// Only works while this tab is open (foreground or backgrounded) -- there is no Service Worker/Push
// subscription here, so this cannot notify once the browser itself is fully closed. That would need
// a backend push-subscription endpoint + VAPID keys, a materially bigger feature than this.
import type { StoredMessage } from 'tinode-sdk';
import { formatMessagePreview } from '@/lib/messages/preview';
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange } from '@/lib/tinode/topics';
import { useNotificationNavStore } from '@/stores/notificationNav';
import { useSessionStore } from '@/stores/session';
let activeTopic: string | null = null;
/** Called by ConversationView on mount/unmount -- the "am I already looking at this exact
* conversation" half of the suppression rule below; a message arriving in the topic you already
* have open (and the tab is actually visible, not just mounted in a background tab) shouldn't also
* pop a desktop notification for something already on screen. */
export function setActiveConversationTopic(topicId: string | null) {
activeTopic = topicId;
}
const DEBOUNCE_MS = 400;
/** How recent a topic's first-ever-observed message must be to count as "genuinely live" rather than
* catch-up history -- see checkTopic's own comment. Generous relative to DEBOUNCE_MS on purpose: a
* slow network round trip for a brand-new friend's actual first message shouldn't get misjudged as
* history just because it took a couple of seconds to arrive. */
const LIVE_MESSAGE_MAX_AGE_MS = 10_000;
const lastSeqByTopic = new Map<string, number>();
let pendingScan: ReturnType<typeof setTimeout> | null = null;
function latestMessageOf(topicName: string): StoredMessage | undefined {
const msgs = getMessagesSnapshot(topicName);
return msgs[msgs.length - 1];
}
/**
* One global debounce covers every topic at once (re-scanning the whole chat list is cheap and
* already the pattern getChatListSnapshot/invalidateChatListSnapshot itself uses) rather than a
* per-topic timer -- coalescing matters because a historical page load (opening a conversation for
* the first time, or its chat-list preview prefetch) fires this same underlying "something changed"
* signal once per message in the batch, arriving synchronously back-to-back. Checking on every one of
* those would seed/compare a topic's baseline against a moving mid-batch target and could misfire on
* messages that are actually old; waiting for the burst to settle means the first evaluation always
* sees the batch's true final state.
*/
function scheduleScan() {
if (pendingScan) clearTimeout(pendingScan);
pendingScan = setTimeout(() => {
pendingScan = null;
for (const contact of getChatListSnapshot()) {
if (contact.topic) checkTopic(contact.topic);
}
}, DEBOUNCE_MS);
}
function checkTopic(topicName: string) {
const latest = latestMessageOf(topicName);
if (!latest || typeof latest.seq !== 'number') return;
const previousSeq = lastSeqByTopic.get(topicName);
lastSeqByTopic.set(topicName, latest.seq);
if (previousSeq !== undefined && latest.seq <= previousSeq) return; // no actual new message
// A topic observed here for the very first time (fresh login, or a brand-new conversation just
// learned about) can't be judged as "new" by seq alone -- a months-old conversation surfacing for
// the first time this session and a brand-new friend's actual first-ever message look identical by
// that measure (both are "seq 1, never seen before"), but only one of them should notify. Message
// age is what actually distinguishes them: something that happened within roughly this debounce
// window is genuinely live; anything older is catch-up history, silently seeded as the baseline
// instead. A topic already being tracked (previousSeq defined) always notifies on a real seq
// increase regardless of age -- that case doesn't have this ambiguity to begin with.
if (previousSeq === undefined) {
const ts = latest.ts instanceof Date ? latest.ts.getTime() : NaN;
const age = Number.isFinite(ts) ? Date.now() - ts : Infinity;
if (age > LIVE_MESSAGE_MAX_AGE_MS) return;
}
if (shouldNotify(topicName, latest)) fireNotification(topicName, latest);
}
function shouldNotify(topicName: string, msg: StoredMessage): boolean {
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return false;
const myUserId = useSessionStore.getState().myUserId;
// Your own message, echoed back (e.g. sent from another tab/device) -- never alert yourself.
if (msg.from && myUserId && msg.from === myUserId) return false;
// Already looking at this exact conversation, in the tab that's actually on screen right now --
// `activeTopic` alone isn't enough, since a backgrounded tab still keeps ConversationView mounted.
if (topicName === activeTopic && document.visibilityState === 'visible') return false;
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
if (contact?.private?.mute_notifications) return false;
return true;
}
function fireNotification(topicName: string, msg: StoredMessage) {
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
const title = contact?.public?.fn || topicName;
const myUserId = useSessionStore.getState().myUserId;
const body = formatMessagePreview(msg, myUserId) || '发来一条新消息';
let notif: Notification;
try {
// `tag: topicName` -- a second notification from the same conversation REPLACES the first in the
// OS notification tray instead of stacking, matching every mainstream IM web client (you get one
// live-updating notification per conversation, not a pile of them from one chatty sender).
notif = new Notification(title, { body, tag: topicName, icon: '/favicon.svg' });
} catch {
// Some browsers/contexts throw constructing Notification even with permission granted (e.g. an
// OS-level notification setting disabled after the fact) -- never let this crash the message
// pipeline over a non-essential feature.
return;
}
notif.onclick = () => {
window.focus();
useNotificationNavStore.getState().requestNavigate(topicName);
notif.close();
};
}
let unsubscribe: (() => void) | null = null;
/** Call once per authenticated session (RequireAuth.tsx, alongside startMeReconciliation) and invoke
* the returned function on logout/unmount. Safe to start/stop repeatedly. */
export function initMessageNotifications(): () => void {
if (unsubscribe) return unsubscribe;
unsubscribe = subscribeAnyTopicChange(scheduleScan);
return () => {
unsubscribe?.();
unsubscribe = null;
if (pendingScan) clearTimeout(pendingScan);
pendingScan = null;
lastSeqByTopic.clear();
};
}
+101
View File
@@ -0,0 +1,101 @@
// Browser-tab-level "you have unread messages" signal: a scrolling title (only while the tab is
// backgrounded -- flashing/scrolling while the user is actively looking at it would just be
// annoying, and every real IM web client stops the moment you look back) plus a numeric badge drawn
// onto the favicon (visible regardless of focus, same as Gmail/Slack's own tab badge).
const BASE_TITLE = document.title;
const TICK_MS = 300;
const TITLE_WINDOW = 18;
let scrollTimer: ReturnType<typeof setInterval> | null = null;
let scrollText = '';
let scrollPos = 0;
function stopScrolling() {
if (scrollTimer) {
clearInterval(scrollTimer);
scrollTimer = null;
}
document.title = BASE_TITLE;
}
/** Left-scrolling marquee over `📨 (N条新消息) <original title>` -- shifts one character per tick
* and wraps, rather than just alternating two strings, so it reads as genuine "滚动" rather than a
* blink. Re-callable while already running to pick up a changed count without restarting the timer
* (only the text/position reset; a second interval is never created). */
function startScrolling(count: number) {
scrollText = `📨 (${count}条新消息) ${BASE_TITLE}   `;
scrollPos = 0;
if (scrollTimer) return;
scrollTimer = setInterval(() => {
const doubled = scrollText + scrollText;
document.title = doubled.slice(scrollPos, scrollPos + TITLE_WINDOW);
scrollPos = (scrollPos + 1) % scrollText.length;
}, TICK_MS);
}
// Stop scrolling and restore the plain title the instant the user looks back at the tab, rather
// than waiting for the next unread-count change to notice -- otherwise a title that scrolled to a
// mid-cycle, half-scrambled substring could sit there indefinitely after the user has already
// returned, if no new message happens to arrive to trigger another updateTabIndicator() call.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') stopScrolling();
});
let faviconImg: HTMLImageElement | null = null;
const FAVICON_HREF = '/favicon.svg';
/** Draws a small red circle + white count in the favicon's top-right corner onto a canvas, then
* points the existing `<link rel="icon">` at the resulting PNG data URL -- mutating the same link
* element's href/type rather than adding a second competing `<link rel="icon">`, since browsers'
* tie-breaking between multiple icon links is inconsistent. Reverts to the plain SVG favicon at 0. */
function updateFaviconBadge(count: number) {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) return;
if (count <= 0) {
link.href = FAVICON_HREF;
link.type = 'image/svg+xml';
return;
}
if (!faviconImg) {
faviconImg = new Image();
faviconImg.src = FAVICON_HREF;
}
const draw = () => {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx || !faviconImg) return;
ctx.drawImage(faviconImg, 0, 0, size, size);
const radius = size * 0.32;
const cx = size - radius;
const cy = radius;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = '#f34b46'; // --color-unread (src/index.css) -- same red the in-app badges use
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${Math.round(radius * 1.15)}px -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(count > 99 ? '99+' : String(count), cx, cy + 1);
link.href = canvas.toDataURL('image/png');
link.type = 'image/png';
};
if (faviconImg.complete) draw();
else faviconImg.onload = draw;
}
/** Call whenever the app's canonical total-unread number changes (AppShell.tsx already computes it
* for NavRail/BottomTabBar) -- keeps the favicon badge and, while backgrounded, the scrolling title
* in sync with that same number rather than tracking a second, potentially-divergent count. */
export function updateTabIndicator(unreadCount: number) {
updateFaviconBadge(unreadCount);
if (unreadCount > 0 && document.visibilityState !== 'visible') {
startScrolling(unreadCount);
} else {
stopScrolling();
}
}