feat: add browser notifications and tab title badge for unread messages

When the tab is not focused:
- Tab title shows unread count: (3) PinchChat
- Browser notification with message preview (if permitted)
- Notifications collapse into one via tag
- Click notification to focus tab
- All clears when tab regains focus
- Permission requested on first user interaction
This commit is contained in:
Nicolas Varrot
2026-02-11 21:45:59 +00:00
parent d02009475b
commit 473d23c140
2 changed files with 95 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useGateway } from './hooks/useGateway';
import { useNotifications } from './hooks/useNotifications';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Chat } from './components/Chat';
@@ -15,6 +16,21 @@ export default function App() {
} = useGateway();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const { notify } = useNotifications();
const prevMessageCountRef = useRef(messages.length);
// Notify on new assistant messages when tab is not focused
useEffect(() => {
const prevCount = prevMessageCountRef.current;
prevMessageCountRef.current = messages.length;
if (messages.length > prevCount) {
const last = messages[messages.length - 1];
if (last && last.role === 'assistant' && !last.isStreaming) {
const preview = last.content?.slice(0, 100) || 'New message';
notify('PinchChat', preview);
}
}
}, [messages, notify]);
// Close sidebar on Escape key, open shortcuts on ?
const handleKeyDown = useCallback((e: KeyboardEvent) => {

View File

@@ -0,0 +1,78 @@
import { useState, useEffect, useCallback, useRef } from 'react';
const ORIGINAL_TITLE = document.title;
/**
* Hook that manages browser notifications and tab title badge
* when new messages arrive while the tab is not focused.
*/
export function useNotifications() {
const [unreadCount, setUnreadCount] = useState(0);
const isVisibleRef = useRef(!document.hidden);
const permissionRef = useRef(Notification.permission);
// Track tab visibility
useEffect(() => {
const handleVisibility = () => {
isVisibleRef.current = !document.hidden;
if (!document.hidden) {
// Reset unread when tab becomes visible
setUnreadCount(0);
document.title = ORIGINAL_TITLE;
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
// Update tab title when unread count changes
useEffect(() => {
if (unreadCount > 0) {
document.title = `(${unreadCount}) ${ORIGINAL_TITLE}`;
}
}, [unreadCount]);
// Request permission on first user interaction
useEffect(() => {
if (permissionRef.current !== 'default') return;
const requestOnInteraction = () => {
if (permissionRef.current === 'default') {
Notification.requestPermission().then(p => {
permissionRef.current = p;
});
}
document.removeEventListener('click', requestOnInteraction);
};
document.addEventListener('click', requestOnInteraction);
return () => document.removeEventListener('click', requestOnInteraction);
}, []);
const notify = useCallback((title: string, body?: string) => {
if (isVisibleRef.current) return; // Tab is focused, no need
setUnreadCount(c => c + 1);
// Send browser notification if permitted
if (permissionRef.current === 'granted') {
try {
const n = new Notification(title, {
body: body?.slice(0, 200),
icon: '/logo.png',
tag: 'pinchchat-message', // Collapse multiple into one
silent: false,
});
// Auto-close after 5s
setTimeout(() => n.close(), 5000);
// Focus tab on click
n.onclick = () => {
window.focus();
n.close();
};
} catch {
// Notifications not supported (e.g. some mobile browsers)
}
}
}, []);
return { notify, unreadCount };
}