From cd6b7675d4261a60eff4d4922873102ad8fcdcfc Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 20:14:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=B6=88=E6=81=AF=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=9A=E6=A0=87=E7=AD=BE=E9=A1=B5=E6=A0=87=E9=A2=98=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8+=E6=9C=AA=E8=AF=BB=E6=95=B0=E3=80=81favicon=E8=A7=92?= =?UTF-8?q?=E6=A0=87=E3=80=81=E6=A1=8C=E9=9D=A2=E9=80=9A=E7=9F=A5=EF=BC=88?= =?UTF-8?q?=E5=90=AB=E4=BD=95=E6=97=B6=E4=B8=8D=E5=BC=B9=E7=9A=84=E5=88=A4?= =?UTF-8?q?=E6=96=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件套,参照Slack/Telegram Web/WhatsApp Web等主流IM网页客户端的标准做法: - lib/notifications/tabIndicator.ts:标签页被切到后台且有未读时,标题在 "📨 (N条新消息) <原标题>"上做左向滚动,用户切回来的瞬间(visibilitychange) 立即恢复原标题,不等下一次未读数变化才发现。favicon右上角画一个红色圆点+ 白色数字角标(canvas绘制成PNG data URL,写回现有,不是 另加一个可能被浏览器忽略的竞争链接)。两者都由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 --- web/src/app/AppShell.tsx | 21 ++- web/src/app/RequireAuth.tsx | 8 ++ .../features/messages/ConversationView.tsx | 11 ++ web/src/lib/notifications/push.ts | 130 ++++++++++++++++++ web/src/lib/notifications/tabIndicator.ts | 101 ++++++++++++++ web/src/stores/notificationNav.ts | 20 +++ 6 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/notifications/push.ts create mode 100644 web/src/lib/notifications/tabIndicator.ts create mode 100644 web/src/stores/notificationNav.ts diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index 4b767d6f..e72b10a2 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -1,13 +1,15 @@ import { useEffect } from 'react'; 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 { useBootstrap } from '@/lib/api/hooks'; import { getMe } from '@/lib/api/profile'; +import { updateTabIndicator } from '@/lib/notifications/tabIndicator'; import { useChatList } from '@/lib/tinode/hooks'; import { initCallSignaling } from '@/stores/calls'; +import { useNotificationNavStore } from '@/stores/notificationNav'; import { BottomTabBar } from './BottomTabBar'; import { NavRail } from './NavRail'; @@ -19,6 +21,7 @@ import { NavRail } from './NavRail'; * columns within a section. */ export function AppShell() { + const navigate = useNavigate(); const contacts = useChatList(); const bootstrap = useBootstrap(); // 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) + (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 (
diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx index dcff6292..c1cd61c9 100644 --- a/web/src/app/RequireAuth.tsx +++ b/web/src/app/RequireAuth.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { Navigate, Outlet } from 'react-router-dom'; import { Toast } from '@/app/Toast'; +import { initMessageNotifications } from '@/lib/notifications/push'; import { restoreSession } from '@/lib/tinode/connection'; import { isTenantMismatch } from '@/lib/tinode/errors'; import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics'; @@ -90,6 +91,13 @@ export function RequireAuth() { return startMeReconciliation(); }, [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 ; if (myUserId) { return ( diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 2f390b54..a2ac5473 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -26,6 +26,7 @@ import { availableActions, recallRemainingSeconds, type MessageAction } from '@/ import { isRecalled } from '@/lib/messages/dispatch'; import { renderDrafty } from '@/lib/messages/drafty'; import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions'; +import { setActiveConversationTopic } from '@/lib/notifications/push'; import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send'; @@ -242,6 +243,16 @@ export function ConversationView() { setHighlightedSeq(null); }, [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(() => { if (autoScrollRef.current) { streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight }); diff --git a/web/src/lib/notifications/push.ts b/web/src/lib/notifications/push.ts new file mode 100644 index 00000000..f69f473a --- /dev/null +++ b/web/src/lib/notifications/push.ts @@ -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(); +let pendingScan: ReturnType | 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(); + }; +} diff --git a/web/src/lib/notifications/tabIndicator.ts b/web/src/lib/notifications/tabIndicator.ts new file mode 100644 index 00000000..a3fee0f8 --- /dev/null +++ b/web/src/lib/notifications/tabIndicator.ts @@ -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 | null = null; +let scrollText = ''; +let scrollPos = 0; + +function stopScrolling() { + if (scrollTimer) { + clearInterval(scrollTimer); + scrollTimer = null; + } + document.title = BASE_TITLE; +} + +/** Left-scrolling marquee over `📨 (N条新消息) ` -- 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 `` at the resulting PNG data URL -- mutating the same link + * element's href/type rather than adding a second competing ``, 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('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(); + } +} diff --git a/web/src/stores/notificationNav.ts b/web/src/stores/notificationNav.ts new file mode 100644 index 00000000..d9695a0f --- /dev/null +++ b/web/src/stores/notificationNav.ts @@ -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((set) => ({ + pendingTopic: null, + requestNavigate: (topic) => set({ pendingTopic: topic }), + clear: () => set({ pendingTopic: null }), +}));