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 }),
+}));