204 lines
6.3 KiB
TypeScript
204 lines
6.3 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
getNotifications,
|
|
getUnread,
|
|
markAllAsRead as apiMarkAllAsRead,
|
|
markAsRead,
|
|
type Notification,
|
|
} from '@/api/notifications';
|
|
|
|
type WsMessage =
|
|
| { type: 'connection_established' }
|
|
| { type: 'unread_count'; count: number }
|
|
| { type: 'new_notification'; notification: Notification; unread_count?: number }
|
|
| { type: 'notification_read'; notification_id: number }
|
|
| { type: 'all_notifications_read'; count: number }
|
|
| { type: 'nav_badges_updated' }
|
|
| { type: 'error'; message?: string }
|
|
| { type: string; [k: string]: unknown };
|
|
|
|
function dedupeById(items: Notification[]): Notification[] {
|
|
const seen = new Set<number>();
|
|
return items.filter((n) => {
|
|
if (seen.has(n.id)) return false;
|
|
seen.add(n.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function useNotifications(options?: { wsEnabled?: boolean; onNavBadgesUpdate?: () => void }) {
|
|
const wsEnabled = options?.wsEnabled ?? true;
|
|
const onNavBadgesUpdateRef = useRef(options?.onNavBadgesUpdate);
|
|
onNavBadgesUpdateRef.current = options?.onNavBadgesUpdate;
|
|
const PAGE_SIZE = 20;
|
|
const [list, setList] = useState<Notification[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [nextPage, setNextPage] = useState(2);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const fetchList = useCallback(async () => {
|
|
setLoading(true);
|
|
setHasMore(true);
|
|
setNextPage(2);
|
|
try {
|
|
const res = await getNotifications({ page: 1, page_size: PAGE_SIZE });
|
|
const results = res.results ?? [];
|
|
setList(dedupeById(results));
|
|
setHasMore(Boolean(res.next) || results.length >= PAGE_SIZE);
|
|
} catch {
|
|
setList([]);
|
|
setHasMore(false);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchMore = useCallback(async () => {
|
|
if (loadingMore || !hasMore) return;
|
|
setLoadingMore(true);
|
|
try {
|
|
const res = await getNotifications({ page: nextPage, page_size: PAGE_SIZE });
|
|
const results = res.results ?? [];
|
|
setList((prev) => {
|
|
const existingIds = new Set(prev.map((n) => n.id));
|
|
const newItems = results.filter((n) => !existingIds.has(n.id));
|
|
return newItems.length ? [...prev, ...newItems] : prev;
|
|
});
|
|
setHasMore(Boolean(res.next) || results.length >= PAGE_SIZE);
|
|
setNextPage((p) => p + 1);
|
|
} catch {
|
|
setHasMore(false);
|
|
} finally {
|
|
setLoadingMore(false);
|
|
}
|
|
}, [loadingMore, hasMore, nextPage]);
|
|
|
|
const fetchUnreadCount = useCallback(async () => {
|
|
try {
|
|
const { count } = await getUnread();
|
|
setUnreadCount(count);
|
|
} catch {
|
|
setUnreadCount(0);
|
|
}
|
|
}, []);
|
|
|
|
const markOneAsRead = useCallback(async (id: number) => {
|
|
try {
|
|
await markAsRead(id);
|
|
setList((prev) =>
|
|
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
|
|
);
|
|
setUnreadCount((c) => Math.max(0, c - 1));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
const markAllAsRead = useCallback(async () => {
|
|
try {
|
|
await apiMarkAllAsRead();
|
|
setUnreadCount(0);
|
|
setList((prev) => prev.map((n) => ({ ...n, is_read: true })));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchUnreadCount();
|
|
}, [fetchUnreadCount]);
|
|
|
|
useEffect(() => {
|
|
if (!wsEnabled || typeof window === 'undefined') return;
|
|
|
|
const token = localStorage.getItem('access_token');
|
|
if (!token) return;
|
|
|
|
let baseUrl = process.env.NEXT_PUBLIC_API_URL || window.location.origin;
|
|
baseUrl = baseUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
|
|
const wsProtocol = baseUrl.startsWith('https') ? 'wss:' : 'ws:';
|
|
const wsHost = baseUrl.replace(/^https?:\/\//, '');
|
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/notifications/?token=${token}`;
|
|
|
|
const connect = () => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onmessage = (ev) => {
|
|
try {
|
|
const data = JSON.parse(ev.data) as WsMessage;
|
|
switch (data.type) {
|
|
case 'unread_count':
|
|
setUnreadCount((data as { count: number }).count);
|
|
break;
|
|
case 'new_notification': {
|
|
const payload = data as { notification: Notification; unread_count?: number };
|
|
if (payload.unread_count !== undefined) setUnreadCount(payload.unread_count);
|
|
else setUnreadCount((c) => c + 1);
|
|
setList((prev) => {
|
|
const has = prev.some((n) => n.id === payload.notification.id);
|
|
if (has) return prev;
|
|
return [payload.notification, ...prev];
|
|
});
|
|
break;
|
|
}
|
|
case 'notification_read': {
|
|
const id = (data as { notification_id: number }).notification_id;
|
|
setList((prev) =>
|
|
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
|
|
);
|
|
setUnreadCount((c) => Math.max(0, c - 1));
|
|
break;
|
|
}
|
|
case 'all_notifications_read':
|
|
setUnreadCount(0);
|
|
setList((prev) => prev.map((n) => ({ ...n, is_read: true })));
|
|
break;
|
|
case 'nav_badges_updated':
|
|
onNavBadgesUpdateRef.current?.();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
wsRef.current = null;
|
|
reconnectTimeoutRef.current = setTimeout(connect, 3000);
|
|
};
|
|
|
|
ws.onerror = () => {};
|
|
};
|
|
|
|
connect();
|
|
return () => {
|
|
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
|
wsRef.current?.close();
|
|
wsRef.current = null;
|
|
};
|
|
}, [wsEnabled]);
|
|
|
|
return {
|
|
list,
|
|
unreadCount,
|
|
loading,
|
|
loadingMore,
|
|
hasMore,
|
|
fetchList,
|
|
fetchMore,
|
|
fetchUnreadCount,
|
|
markOneAsRead,
|
|
markAllAsRead,
|
|
};
|
|
}
|