mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
refactor: Update NotificationPopup and use-notifications for improved UI and performance
- Removed Badge component from NotificationPopup and replaced it with status icons for better visual representation. - Refactored getStatusBadge to getStatusIcon for clarity and updated icon sizes. - Enhanced useNotifications hook to utilize a ref for initialization tracking, improving performance and preventing unnecessary re-initializations. - Optimized notification fetching logic with improved error handling and real-time updates using Electric SQL. - Adjusted loading state management to ensure UI responsiveness during data fetching.
This commit is contained in:
parent
93d17b51f5
commit
44605749c5
3 changed files with 192 additions and 180 deletions
|
|
@ -4,7 +4,6 @@ import { Bell, Check, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "luc
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Notification } from "@/hooks/use-notifications";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -40,34 +39,18 @@ export function NotificationPopup({
|
|||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (notification: Notification) => {
|
||||
const getStatusIcon = (notification: Notification) => {
|
||||
const status = notification.metadata?.status as string | undefined;
|
||||
if (!status) return null;
|
||||
|
||||
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
In Progress
|
||||
</Badge>
|
||||
);
|
||||
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />;
|
||||
case "completed":
|
||||
return (
|
||||
<Badge variant="default" className="text-xs bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Completed
|
||||
</Badge>
|
||||
);
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Failed
|
||||
</Badge>
|
||||
);
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
return <Bell className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -76,13 +59,7 @@ export function NotificationPopup({
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
<h3 className="font-semibold text-sm">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
|
|
@ -91,7 +68,7 @@ export function NotificationPopup({
|
|||
onClick={handleMarkAllAsRead}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-1" />
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-0" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -121,6 +98,9 @@ export function NotificationPopup({
|
|||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getStatusIcon(notification)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<p
|
||||
|
|
@ -131,12 +111,6 @@ export function NotificationPopup({
|
|||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{getStatusBadge(notification)}
|
||||
{!notification.read && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary mt-1.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{notification.message}
|
||||
|
|
@ -145,20 +119,6 @@ export function NotificationPopup({
|
|||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(notification.created_at)}
|
||||
</span>
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(notification.id);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Mark read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -172,4 +132,3 @@ export function NotificationPopup({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,14 +19,18 @@ export interface Notification {
|
|||
export function useNotifications(userId: string | null) {
|
||||
const [electric, setElectric] = useState<ElectricClient | null>(null)
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null)
|
||||
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null)
|
||||
// Use ref instead of state to track initialization - prevents cleanup from running when set
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
// Initialize Electric SQL and start syncing with real-time updates
|
||||
useEffect(() => {
|
||||
if (!userId || initialized) return
|
||||
// Use ref to prevent re-initialization without triggering cleanup
|
||||
if (!userId || initializedRef.current) return
|
||||
initializedRef.current = true
|
||||
|
||||
let mounted = true
|
||||
|
||||
|
|
@ -55,12 +59,19 @@ export function useNotifications(userId: string | null) {
|
|||
hasInitialSyncPromise: !!handle.initialSyncPromise,
|
||||
})
|
||||
|
||||
// Wait for initial sync to complete if the promise is available
|
||||
if (handle.initialSyncPromise) {
|
||||
// Optimized: Check if already up-to-date before waiting
|
||||
if (handle.isUpToDate) {
|
||||
console.log('Sync already up-to-date, skipping wait')
|
||||
} else if (handle.initialSyncPromise) {
|
||||
// Only wait if not already up-to-date
|
||||
console.log('Waiting for initial sync to complete...')
|
||||
try {
|
||||
await handle.initialSyncPromise
|
||||
console.log('Initial sync promise resolved, checking status:', {
|
||||
// Use Promise.race with a shorter timeout to avoid long waits
|
||||
await Promise.race([
|
||||
handle.initialSyncPromise,
|
||||
new Promise(resolve => setTimeout(resolve, 2000)), // Max 2s wait
|
||||
])
|
||||
console.log('Initial sync promise resolved or timed out, checking status:', {
|
||||
isUpToDate: handle.isUpToDate,
|
||||
})
|
||||
} catch (syncErr) {
|
||||
|
|
@ -80,122 +91,74 @@ export function useNotifications(userId: string | null) {
|
|||
}
|
||||
|
||||
syncHandleRef.current = handle
|
||||
setInitialized(true)
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
|
||||
// Fetch notifications after sync is complete (we already waited above)
|
||||
await fetchNotifications(electricClient.db)
|
||||
|
||||
// Set up real-time updates using PGlite live queries
|
||||
// Electric SQL syncs data to PGlite in real-time via WebSocket/HTTP
|
||||
// Electric SQL syncs data to PGlite in real-time via HTTP streaming
|
||||
// PGlite live queries detect when the synced data changes and trigger callbacks
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = electricClient.db as any
|
||||
|
||||
// Use PGlite's live query API for real-time updates
|
||||
// Based on latest PGlite docs: db.live.query(query, params, callback)
|
||||
// CORRECT API: await db.live.query() then use .subscribe()
|
||||
if (db.live?.query && typeof db.live.query === 'function') {
|
||||
const liveQuery = db.live.query(
|
||||
// IMPORTANT: db.live.query() returns a Promise - must await it!
|
||||
const liveQuery = await db.live.query(
|
||||
`SELECT * FROM notifications WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId],
|
||||
(result: { rows: Notification[] }) => {
|
||||
// This callback fires automatically when Electric SQL syncs changes
|
||||
if (mounted) {
|
||||
setNotifications(result.rows)
|
||||
}
|
||||
}
|
||||
[userId]
|
||||
)
|
||||
|
||||
// Set initial results immediately
|
||||
if (liveQuery.initialResults) {
|
||||
setNotifications(liveQuery.initialResults.rows)
|
||||
if (!mounted) {
|
||||
liveQuery.unsubscribe?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (mounted && liveQuery && typeof liveQuery.unsubscribe === 'function') {
|
||||
liveQueryRef.current = liveQuery
|
||||
// Set initial results immediately from the resolved query
|
||||
if (liveQuery.initialResults?.rows) {
|
||||
console.log('📋 Initial live query results:', liveQuery.initialResults.rows.length)
|
||||
setNotifications(liveQuery.initialResults.rows)
|
||||
} else if (liveQuery.rows) {
|
||||
// Some versions have rows directly on the result
|
||||
console.log('📋 Initial live query results (direct):', liveQuery.rows.length)
|
||||
setNotifications(liveQuery.rows)
|
||||
}
|
||||
|
||||
// Subscribe to changes - this is the correct API!
|
||||
// The callback fires automatically when Electric SQL syncs new data to PGlite
|
||||
if (typeof liveQuery.subscribe === 'function') {
|
||||
liveQuery.subscribe((result: { rows: Notification[] }) => {
|
||||
console.log('🔔 Live query update received:', result.rows?.length || 0, 'notifications')
|
||||
if (mounted && result.rows) {
|
||||
setNotifications(result.rows)
|
||||
}
|
||||
})
|
||||
console.log('✅ Real-time notifications enabled via PGlite live queries')
|
||||
} else {
|
||||
console.warn('⚠️ Live query subscribe method not available')
|
||||
}
|
||||
|
||||
// Store for cleanup
|
||||
if (typeof liveQuery.unsubscribe === 'function') {
|
||||
liveQueryRef.current = liveQuery
|
||||
}
|
||||
} else {
|
||||
// Fallback: Monitor sync handle for updates
|
||||
// Electric SQL's syncShape should trigger updates, but we need to detect them
|
||||
// This is a lightweight approach that only checks when sync indicates changes
|
||||
console.warn('PGlite live queries not available - using sync-based change detection')
|
||||
|
||||
let lastNotificationIds = new Set<number>()
|
||||
|
||||
const checkForSyncUpdates = async () => {
|
||||
if (!mounted) return
|
||||
|
||||
try {
|
||||
const result = await electricClient.db.query<Notification>(
|
||||
`SELECT * FROM notifications WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
// PGlite query returns { rows: [] } format
|
||||
const rows = result.rows || []
|
||||
|
||||
// Only update if data actually changed
|
||||
const currentIds = new Set(rows.map(r => r.id))
|
||||
const currentHash = JSON.stringify(
|
||||
rows.map(r => ({ id: r.id, read: r.read, updated_at: r.updated_at }))
|
||||
)
|
||||
|
||||
// Check if IDs changed (new/deleted notifications)
|
||||
const idsChanged =
|
||||
currentIds.size !== lastNotificationIds.size ||
|
||||
[...currentIds].some(id => !lastNotificationIds.has(id)) ||
|
||||
[...lastNotificationIds].some(id => !currentIds.has(id))
|
||||
|
||||
if (idsChanged) {
|
||||
setNotifications(rows)
|
||||
lastNotificationIds = currentIds
|
||||
} else {
|
||||
// Check if any notification properties changed (e.g., read status)
|
||||
// Compare with current state
|
||||
setNotifications(prev => {
|
||||
const prevHash = JSON.stringify(
|
||||
prev.map(r => ({ id: r.id, read: r.read, updated_at: r.updated_at }))
|
||||
)
|
||||
if (prevHash !== currentHash) {
|
||||
return rows
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check for notification updates:', err)
|
||||
}
|
||||
|
||||
// Check again after a short delay (Electric SQL syncs are fast)
|
||||
if (mounted) {
|
||||
setTimeout(checkForSyncUpdates, 500) // Check every 500ms - Electric SQL syncs are near-instant
|
||||
}
|
||||
}
|
||||
|
||||
// Start monitoring
|
||||
checkForSyncUpdates()
|
||||
|
||||
liveQueryRef.current = {
|
||||
unsubscribe: () => {
|
||||
mounted = false
|
||||
}
|
||||
}
|
||||
console.error('❌ PGlite live queries not available - db.live.query is not a function')
|
||||
console.log('db.live:', db.live)
|
||||
}
|
||||
} catch (liveErr) {
|
||||
console.warn('Failed to set up real-time updates:', liveErr)
|
||||
// Minimal fallback - this should rarely be needed
|
||||
liveQueryRef.current = {
|
||||
unsubscribe: () => {}
|
||||
}
|
||||
console.error('❌ Failed to set up real-time updates:', liveErr)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return
|
||||
console.error('Failed to initialize Electric SQL:', err)
|
||||
setError(err instanceof Error ? err : new Error('Failed to initialize Electric SQL'))
|
||||
// Still mark as initialized so the UI doesn't block
|
||||
setInitialized(true)
|
||||
// Still mark as loaded so the UI doesn't block
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +194,9 @@ export function useNotifications(userId: string | null) {
|
|||
|
||||
return () => {
|
||||
mounted = false
|
||||
// Reset initialization state so we can reinitialize with a new userId
|
||||
initializedRef.current = false
|
||||
setLoading(true)
|
||||
if (syncHandleRef.current) {
|
||||
syncHandleRef.current.unsubscribe()
|
||||
syncHandleRef.current = null
|
||||
|
|
@ -240,7 +206,9 @@ export function useNotifications(userId: string | null) {
|
|||
liveQueryRef.current = null
|
||||
}
|
||||
}
|
||||
}, [userId, initialized])
|
||||
// Only depend on userId - using ref for initialization tracking to prevent cleanup issues
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId])
|
||||
|
||||
// Mark notification as read (local only - needs backend sync)
|
||||
const markAsRead = useCallback(
|
||||
|
|
@ -301,7 +269,7 @@ export function useNotifications(userId: string | null) {
|
|||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
loading: !initialized,
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,16 +167,45 @@ export async function initElectric(): Promise<ElectricClient> {
|
|||
// Note: mapColumns is OPTIONAL per pglite-sync types.ts
|
||||
|
||||
// Create a promise that resolves when initial sync is complete
|
||||
// Using recommended approach: check isUpToDate immediately, watch stream, shorter timeout
|
||||
// IMPORTANT: We don't unsubscribe from the stream - it must stay active for real-time updates
|
||||
let resolveInitialSync: () => void
|
||||
let rejectInitialSync: (error: Error) => void
|
||||
let syncResolved = false
|
||||
|
||||
const initialSyncPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveInitialSync = resolve
|
||||
rejectInitialSync = reject
|
||||
// Safety timeout - if sync doesn't complete in 30s, something is wrong
|
||||
setTimeout(() => {
|
||||
console.warn(`⚠️ Sync timeout for ${table} - sync did not complete in 30s`)
|
||||
resolve() // Resolve anyway to not block, but log warning
|
||||
}, 30000)
|
||||
resolveInitialSync = () => {
|
||||
if (!syncResolved) {
|
||||
syncResolved = true
|
||||
// DON'T unsubscribe from stream - it needs to stay active for real-time updates
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
rejectInitialSync = (error: Error) => {
|
||||
if (!syncResolved) {
|
||||
syncResolved = true
|
||||
// DON'T unsubscribe from stream even on error - let Electric handle it
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Shorter timeout (5 seconds) as fallback
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!syncResolved) {
|
||||
console.warn(`⚠️ Sync timeout for ${table} - checking isUpToDate one more time...`)
|
||||
// Check isUpToDate one more time before resolving
|
||||
// This will be checked after shape is created
|
||||
setTimeout(() => {
|
||||
if (!syncResolved) {
|
||||
console.warn(`⚠️ Sync timeout for ${table} - resolving anyway after 5s`)
|
||||
resolveInitialSync()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// Store timeout ID for cleanup if needed
|
||||
// Note: timeout will be cleared if sync completes early
|
||||
})
|
||||
|
||||
const shapeConfig = {
|
||||
|
|
@ -220,35 +249,91 @@ export async function initElectric(): Promise<ElectricClient> {
|
|||
streamType: typeof shape?.stream,
|
||||
})
|
||||
|
||||
// Debug the stream if available
|
||||
if (shape?.stream) {
|
||||
const stream = shape.stream as any
|
||||
console.log('Shape stream details:', {
|
||||
shapeHandle: stream?.shapeHandle,
|
||||
lastOffset: stream?.lastOffset,
|
||||
isUpToDate: stream?.isUpToDate,
|
||||
error: stream?.error,
|
||||
hasSubscribe: typeof stream?.subscribe === 'function',
|
||||
hasUnsubscribe: typeof stream?.unsubscribe === 'function',
|
||||
})
|
||||
|
||||
// Try to subscribe to the stream to see if it's receiving messages
|
||||
if (typeof stream?.subscribe === 'function') {
|
||||
console.log('Subscribing to shape stream for debugging...')
|
||||
stream.subscribe((messages: unknown[]) => {
|
||||
console.log('🔵 Shape stream received messages:', messages?.length || 0)
|
||||
if (messages && messages.length > 0) {
|
||||
console.log('First message:', JSON.stringify(messages[0], null, 2))
|
||||
}
|
||||
// Recommended Approach Step 1: Check isUpToDate immediately
|
||||
if (shape.isUpToDate) {
|
||||
console.log(`✅ Sync already up-to-date for ${table} (resuming from previous state)`)
|
||||
resolveInitialSync()
|
||||
} else {
|
||||
// Recommended Approach Step 2: Subscribe to stream and watch for "up-to-date" message
|
||||
if (shape?.stream) {
|
||||
const stream = shape.stream as any
|
||||
console.log('Shape stream details:', {
|
||||
shapeHandle: stream?.shapeHandle,
|
||||
lastOffset: stream?.lastOffset,
|
||||
isUpToDate: stream?.isUpToDate,
|
||||
error: stream?.error,
|
||||
hasSubscribe: typeof stream?.subscribe === 'function',
|
||||
hasUnsubscribe: typeof stream?.unsubscribe === 'function',
|
||||
})
|
||||
|
||||
// Subscribe to the stream to watch for "up-to-date" control message
|
||||
// NOTE: We keep this subscription active - don't unsubscribe!
|
||||
// The stream is what Electric SQL uses for real-time updates
|
||||
if (typeof stream?.subscribe === 'function') {
|
||||
console.log('Subscribing to shape stream to watch for up-to-date message...')
|
||||
// Subscribe but don't store unsubscribe - we want it to stay active
|
||||
stream.subscribe((messages: unknown[]) => {
|
||||
// Continue receiving updates even after sync is resolved
|
||||
if (!syncResolved) {
|
||||
console.log('🔵 Shape stream received messages:', messages?.length || 0)
|
||||
}
|
||||
|
||||
// Check if any message indicates sync is complete
|
||||
if (messages && messages.length > 0) {
|
||||
for (const message of messages) {
|
||||
const msg = message as any
|
||||
// Check for "up-to-date" control message
|
||||
if (msg?.headers?.control === 'up-to-date' ||
|
||||
msg?.headers?.electric_up_to_date === 'true' ||
|
||||
(typeof msg === 'object' && 'up-to-date' in msg)) {
|
||||
if (!syncResolved) {
|
||||
console.log(`✅ Received up-to-date message for ${table}`)
|
||||
resolveInitialSync()
|
||||
}
|
||||
// Continue listening for real-time updates - don't return!
|
||||
}
|
||||
}
|
||||
if (!syncResolved && messages.length > 0) {
|
||||
console.log('First message:', JSON.stringify(messages[0], null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
// Also check stream's isUpToDate property after receiving messages
|
||||
if (!syncResolved && stream?.isUpToDate) {
|
||||
console.log(`✅ Stream isUpToDate is true for ${table}`)
|
||||
resolveInitialSync()
|
||||
}
|
||||
})
|
||||
|
||||
// Also check stream's isUpToDate property immediately
|
||||
if (stream?.isUpToDate) {
|
||||
console.log(`✅ Stream isUpToDate is true immediately for ${table}`)
|
||||
resolveInitialSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Also poll isUpToDate periodically as a backup (every 200ms)
|
||||
const pollInterval = setInterval(() => {
|
||||
if (syncResolved) {
|
||||
clearInterval(pollInterval)
|
||||
return
|
||||
}
|
||||
|
||||
if (shape.isUpToDate || stream?.isUpToDate) {
|
||||
console.log(`✅ Sync completed (detected via polling) for ${table}`)
|
||||
clearInterval(pollInterval)
|
||||
resolveInitialSync()
|
||||
}
|
||||
}, 200)
|
||||
|
||||
// Clean up polling when promise resolves
|
||||
initialSyncPromise.finally(() => {
|
||||
clearInterval(pollInterval)
|
||||
})
|
||||
} else {
|
||||
console.warn(`⚠️ No stream available for ${table}, relying on callback and timeout`)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait briefly to see if sync starts
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
console.log('Shape sync result (after 100ms):', {
|
||||
isUpToDate: shape?.isUpToDate,
|
||||
})
|
||||
|
||||
// Return the shape handle - isUpToDate is a getter that reflects current state
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue