599514c0db
- WebSocket: Spring STOMP + SockJS, NotificationService, persistent notifications table - NotificationController: GET/PUT endpoints for notification management - Frontend: notification bell with unread badge, dropdown panel, real-time via STOMP - PWA: manifest.json, service worker (manual sw.js), offline page, install prompt - PWA icons (192+512), dark theme colors, standalone display - Full i18n (de/en) for notifications and PWA - Flyway V10 migration for notifications table - spring-boot-starter-websocket dependency added
103 lines
2.8 KiB
TypeScript
103 lines
2.8 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { useQueryClient } from "@tanstack/react-query"
|
|
|
|
export interface WsNotification {
|
|
id: string
|
|
type: string
|
|
title: string
|
|
message: string
|
|
link: string
|
|
read: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
interface UseNotificationsOptions {
|
|
userId?: string
|
|
enabled?: boolean
|
|
}
|
|
|
|
/**
|
|
* WebSocket hook for real-time notifications via STOMP over SockJS.
|
|
* Connects to /ws, subscribes to /user/queue/notifications.
|
|
* Falls back gracefully if WebSocket is unavailable.
|
|
*/
|
|
export function useNotifications({
|
|
userId,
|
|
enabled = true,
|
|
}: UseNotificationsOptions) {
|
|
const [connected, setConnected] = useState(false)
|
|
const [lastNotification, setLastNotification] =
|
|
useState<WsNotification | null>(null)
|
|
const stompClientRef = useRef<unknown>(null)
|
|
const queryClient = useQueryClient()
|
|
|
|
const connect = useCallback(async () => {
|
|
if (!userId || !enabled) return
|
|
|
|
try {
|
|
// Dynamic import to avoid SSR issues
|
|
const { Client } = await import("@stomp/stompjs")
|
|
const SockJS = (await import("sockjs-client")).default
|
|
|
|
const backendUrl =
|
|
process.env.NEXT_PUBLIC_WS_URL || "http://localhost:8080"
|
|
|
|
const client = new Client({
|
|
webSocketFactory: () => new SockJS(`${backendUrl}/ws`),
|
|
reconnectDelay: 5000,
|
|
heartbeatIncoming: 10000,
|
|
heartbeatOutgoing: 10000,
|
|
onConnect: () => {
|
|
setConnected(true)
|
|
|
|
client.subscribe(`/user/${userId}/queue/notifications`, (message) => {
|
|
try {
|
|
const notification: WsNotification = JSON.parse(message.body)
|
|
setLastNotification(notification)
|
|
// Invalidate notifications query to refresh badge count
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] })
|
|
} catch {
|
|
console.error("Failed to parse notification message")
|
|
}
|
|
})
|
|
},
|
|
onDisconnect: () => {
|
|
setConnected(false)
|
|
},
|
|
onStompError: (frame) => {
|
|
console.error("STOMP error:", frame.headers["message"])
|
|
setConnected(false)
|
|
},
|
|
})
|
|
|
|
client.activate()
|
|
stompClientRef.current = client
|
|
} catch {
|
|
// WebSocket libraries not available (SSR or missing deps) — fail silently
|
|
console.warn("WebSocket connection unavailable, falling back to polling")
|
|
}
|
|
}, [userId, enabled, queryClient])
|
|
|
|
const disconnect = useCallback(() => {
|
|
const client = stompClientRef.current as { deactivate?: () => void } | null
|
|
if (client?.deactivate) {
|
|
client.deactivate()
|
|
}
|
|
stompClientRef.current = null
|
|
setConnected(false)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
connect()
|
|
return () => disconnect()
|
|
}, [connect, disconnect])
|
|
|
|
return {
|
|
connected,
|
|
lastNotification,
|
|
disconnect,
|
|
}
|
|
}
|