/** * Proposal Notifications Hook * * Periodically polls for pending identity change proposals and shows * notifications when new proposals are available. * * Usage: * ```tsx * // In App.tsx or a top-level component * useProposalNotifications(); * ``` */ import { useEffect, useRef, useCallback } from 'react'; import { useConversationStore } from '../store/chat/conversationStore'; import { intelligenceClient, type IdentityChangeProposal } from './intelligence-client'; import { createLogger } from './logger'; const log = createLogger('ProposalNotifications'); // Configuration const POLL_INTERVAL_MS = 60_000; // 1 minute const NOTIFICATION_COOLDOWN_MS = 300_000; // 5 minutes - don't spam notifications // Storage key for tracking notified proposals const NOTIFIED_PROPOSALS_KEY = 'zclaw-notified-proposals'; /** * Get set of already notified proposal IDs */ function getNotifiedProposals(): Set { try { const stored = localStorage.getItem(NOTIFIED_PROPOSALS_KEY); if (stored) { return new Set(JSON.parse(stored) as string[]); } } catch (e) { log.debug('Failed to parse notified proposals from localStorage', { error: e }); } return new Set(); } /** * Save notified proposal IDs */ function saveNotifiedProposals(ids: Set): void { try { // Keep only last 100 IDs to prevent storage bloat const arr = Array.from(ids).slice(-100); localStorage.setItem(NOTIFIED_PROPOSALS_KEY, JSON.stringify(arr)); } catch (e) { log.debug('Failed to save notified proposals to localStorage', { error: e }); } } /** * Hook for showing proposal notifications * * This hook: * 1. Polls for pending proposals every minute * 2. Shows a toast notification when new proposals are found * 3. Tracks which proposals have already been notified to avoid spam */ export function useProposalNotifications(): { pendingCount: number; refresh: () => Promise; } { const currentAgent = useConversationStore((s) => s.currentAgent); const agentId = currentAgent?.id; const pendingCountRef = useRef(0); const lastNotificationTimeRef = useRef(0); const notifiedProposalsRef = useRef(getNotifiedProposals()); const isPollingRef = useRef(false); const checkForNewProposals = useCallback(async () => { if (!agentId || isPollingRef.current) return; isPollingRef.current = true; try { const proposals = await intelligenceClient.identity.getPendingProposals(agentId); pendingCountRef.current = proposals.length; // Find proposals we haven't notified about const newProposals = proposals.filter( (p: IdentityChangeProposal) => !notifiedProposalsRef.current.has(p.id) ); if (newProposals.length > 0) { const now = Date.now(); // Check cooldown to avoid spam if (now - lastNotificationTimeRef.current >= NOTIFICATION_COOLDOWN_MS) { // Dispatch custom event for the app to handle // This allows the app to show toast, play sound, etc. const event = new CustomEvent('zclaw:proposal-available', { detail: { count: newProposals.length, proposals: newProposals, }, }); window.dispatchEvent(event); lastNotificationTimeRef.current = now; } // Mark these proposals as notified for (const p of newProposals) { notifiedProposalsRef.current.add(p.id); } saveNotifiedProposals(notifiedProposalsRef.current); } } catch (err) { log.warn('Failed to check proposals:', err); } finally { isPollingRef.current = false; } }, [agentId]); // Set up polling useEffect(() => { if (!agentId) return; // Initial check checkForNewProposals(); // Set up interval const intervalId = setInterval(checkForNewProposals, POLL_INTERVAL_MS); return () => { clearInterval(intervalId); }; }, [agentId, checkForNewProposals]); // Listen for visibility change to refresh when app becomes visible useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { checkForNewProposals(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [checkForNewProposals]); return { pendingCount: pendingCountRef.current, refresh: checkForNewProposals, }; } /** * Component that sets up proposal notification handling * * Place this near the root of the app to enable proposal notifications */ export function ProposalNotificationHandler(): null { // This effect sets up the global event listener for proposal notifications useEffect(() => { const handleProposalAvailable = (event: Event) => { const customEvent = event as CustomEvent<{ count: number }>; const { count } = customEvent.detail; // You can integrate with a toast system here log.debug(`${count} new proposal(s) available`); // If using the Toast system from Toast.tsx, you would do: // toast(`${count} 个新的人格变更提案待审批`, 'info'); }; window.addEventListener('zclaw:proposal-available', handleProposalAvailable); return () => { window.removeEventListener('zclaw:proposal-available', handleProposalAvailable); }; }, []); return null; } export default useProposalNotifications;