新消息提示:标签页标题滚动+未读数、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
+20 -1
View File
@@ -1,13 +1,15 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Outlet } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { CallRoot } from '@/features/calls/CallRoot'; import { CallRoot } from '@/features/calls/CallRoot';
import { useBootstrap } from '@/lib/api/hooks'; import { useBootstrap } from '@/lib/api/hooks';
import { getMe } from '@/lib/api/profile'; import { getMe } from '@/lib/api/profile';
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
import { useChatList } from '@/lib/tinode/hooks'; import { useChatList } from '@/lib/tinode/hooks';
import { initCallSignaling } from '@/stores/calls'; import { initCallSignaling } from '@/stores/calls';
import { useNotificationNavStore } from '@/stores/notificationNav';
import { BottomTabBar } from './BottomTabBar'; import { BottomTabBar } from './BottomTabBar';
import { NavRail } from './NavRail'; import { NavRail } from './NavRail';
@@ -19,6 +21,7 @@ import { NavRail } from './NavRail';
* columns within a section. * columns within a section.
*/ */
export function AppShell() { export function AppShell() {
const navigate = useNavigate();
const contacts = useChatList(); const contacts = useChatList();
const bootstrap = useBootstrap(); const bootstrap = useBootstrap();
// Same `['me']` query SettingsLayout already runs -- sharing the key means whichever mounts // Same `['me']` query SettingsLayout already runs -- sharing the key means whichever mounts
@@ -42,6 +45,22 @@ export function AppShell() {
contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) + contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) +
(bootstrap.data?.entry_badges?.system_unread_count || 0); (bootstrap.data?.entry_badges?.system_unread_count || 0);
// Same canonical number the rail/tab-bar badges above already show -- keeps the browser tab's
// favicon badge and (while backgrounded) scrolling title in sync with it rather than tracking a
// second, potentially-divergent count.
useEffect(() => {
updateTabIndicator(unreadMessages);
}, [unreadMessages]);
// Consumes a desktop-notification click (lib/notifications/push.ts has no React Router access of
// its own -- see stores/notificationNav.ts for why this goes through a store instead).
const pendingNotificationTopic = useNotificationNavStore((s) => s.pendingTopic);
useEffect(() => {
if (!pendingNotificationTopic) return;
navigate(`/messages/${pendingNotificationTopic}`);
useNotificationNavStore.getState().clear();
}, [pendingNotificationTopic, navigate]);
return ( return (
<div className="flex h-dvh w-full overflow-hidden bg-surface-page"> <div className="flex h-dvh w-full overflow-hidden bg-surface-page">
<div className="hidden sm:flex sm:h-full"> <div className="hidden sm:flex sm:h-full">
+8
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom'; import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast'; import { Toast } from '@/app/Toast';
import { initMessageNotifications } from '@/lib/notifications/push';
import { restoreSession } from '@/lib/tinode/connection'; import { restoreSession } from '@/lib/tinode/connection';
import { isTenantMismatch } from '@/lib/tinode/errors'; import { isTenantMismatch } from '@/lib/tinode/errors';
import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics'; import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics';
@@ -90,6 +91,13 @@ export function RequireAuth() {
return startMeReconciliation(); return startMeReconciliation();
}, [myUserId]); }, [myUserId]);
// Same start/stop shape as the reconciliation effect above, same reason it isn't merged into the
// one-time subscribeMeTopic effect.
useEffect(() => {
if (!myUserId) return undefined;
return initMessageNotifications();
}, [myUserId]);
if (!tenant) return <Navigate to="/tenant" replace />; if (!tenant) return <Navigate to="/tenant" replace />;
if (myUserId) { if (myUserId) {
return ( return (
@@ -26,6 +26,7 @@ import { availableActions, recallRemainingSeconds, type MessageAction } from '@/
import { isRecalled } from '@/lib/messages/dispatch'; import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty'; import { renderDrafty } from '@/lib/messages/drafty';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions'; import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { setActiveConversationTopic } from '@/lib/notifications/push';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send'; import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
@@ -242,6 +243,16 @@ export function ConversationView() {
setHighlightedSeq(null); setHighlightedSeq(null);
}, [topicId]); }, [topicId]);
// Tells lib/notifications/push.ts "the user is already looking at this one" so a message arriving
// here doesn't also pop a desktop notification for something already on screen -- cleared on
// switch/unmount so leaving this screen (closing the tab, navigating to /contacts, etc.) doesn't
// leave some OTHER, no-longer-open conversation permanently exempt from notifications.
useEffect(() => {
if (!topicId) return undefined;
setActiveConversationTopic(topicId);
return () => setActiveConversationTopic(null);
}, [topicId]);
useEffect(() => { useEffect(() => {
if (autoScrollRef.current) { if (autoScrollRef.current) {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight }); streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
+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();
}
}
+20
View File
@@ -0,0 +1,20 @@
import { create } from 'zustand';
/**
* Transient cross-component instruction: `lib/notifications/push.ts` lives outside React (it reacts
* to raw SDK topic events, same as topics.ts itself) and has no way to call `useNavigate()` directly
* when a desktop Notification is clicked. Matches this codebase's established pattern for exactly
* this shape of problem (stores/calls.ts, stores/messageJump.ts) rather than threading a navigate
* callback down into a non-React module.
*/
interface NotificationNavState {
pendingTopic: string | null;
requestNavigate: (topic: string) => void;
clear: () => void;
}
export const useNotificationNavStore = create<NotificationNavState>((set) => ({
pendingTopic: null,
requestNavigate: (topic) => set({ pendingTopic: topic }),
clear: () => set({ pendingTopic: null }),
}));