新消息提示:标签页标题滚动+未读数、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:
co-authored by
Claude Sonnet 5
parent
8a593f50c3
commit
cd6b7675d4
@@ -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">
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user