Files
zclaw_openfang/desktop/src/lib/useProposalNotifications.ts
iven 5c74e74f2a fix(desktop): component cleanup + dead code removal + DeerFlow ai-elements
- ChatArea: DeerFlow ai-elements annotations for accessibility
- Conversation: remove unused Context, simplify message rendering
- Delete dead modules: audit-logger.ts, gateway-reconnect.ts
- Replace console.log with structured logger across components
- Add idb dependency for IndexedDB persistence
- Fix kernel-skills type safety improvements
2026-04-03 00:28:58 +08:00

187 lines
5.4 KiB
TypeScript

/**
* 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<string> {
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<string>): 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<void>;
} {
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;