Files
fotospiel-app/resources/js/guest/context/NotificationCenterContext.tsx
2026-01-12 17:09:37 +01:00

305 lines
8.7 KiB
TypeScript

import React from 'react';
import { useUploadQueue } from '../queue/hooks';
import type { QueueItem } from '../queue/queue';
import {
dismissGuestNotification,
fetchGuestNotifications,
markGuestNotificationRead,
type GuestNotificationItem,
} from '../services/notificationApi';
import { fetchPendingUploadsSummary } from '../services/pendingUploadsApi';
import { updateAppBadge } from '../lib/badges';
export type NotificationCenterValue = {
notifications: GuestNotificationItem[];
unreadCount: number;
queueItems: QueueItem[];
queueCount: number;
pendingCount: number;
loading: boolean;
pendingLoading: boolean;
refresh: () => Promise<void>;
setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void;
markAsRead: (id: number) => Promise<void>;
dismiss: (id: number) => Promise<void>;
eventToken: string;
lastFetchedAt: Date | null;
isOffline: boolean;
};
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
export function NotificationCenterProvider({ eventToken, children }: { eventToken: string; children: React.ReactNode }) {
const { items, loading: queueLoading, refresh: refreshQueue } = useUploadQueue();
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
const [pendingCount, setPendingCount] = React.useState(0);
const [pendingLoading, setPendingLoading] = React.useState(true);
const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({
status: 'new',
scope: 'all',
});
const etagRef = React.useRef<string | null>(null);
const fetchLockRef = React.useRef(false);
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
const [isOffline, setIsOffline] = React.useState<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : false);
const queueCount = React.useMemo(
() => items.filter((item) => item.status !== 'done').length,
[items]
);
const loadNotifications = React.useCallback(
async (options: { silent?: boolean } = {}) => {
if (!eventToken) {
if (!options.silent) {
setLoadingNotifications(false);
}
return;
}
if (fetchLockRef.current) {
return;
}
fetchLockRef.current = true;
if (!options.silent) {
setLoadingNotifications(true);
}
try {
const statusFilter = filters.status && filters.status !== 'all' ? (filters.status === 'new' ? 'unread' : filters.status) : undefined;
const result = await fetchGuestNotifications(eventToken, etagRef.current, {
status: statusFilter as any,
scope: filters.scope,
});
if (!result.notModified) {
setNotifications(result.notifications);
setUnreadCount(result.unreadCount);
setLastFetchedAt(new Date());
}
etagRef.current = result.etag;
setIsOffline(false);
} catch (error) {
console.error('Failed to load guest notifications', error);
if (!options.silent) {
setNotifications([]);
setUnreadCount(0);
}
if (typeof navigator !== 'undefined' && !navigator.onLine) {
setIsOffline(true);
}
} finally {
fetchLockRef.current = false;
if (!options.silent) {
setLoadingNotifications(false);
}
}
},
[eventToken]
);
const loadPendingUploads = React.useCallback(async () => {
if (!eventToken) {
setPendingLoading(false);
return;
}
try {
setPendingLoading(true);
const result = await fetchPendingUploadsSummary(eventToken, 1);
setPendingCount(result.totalCount);
} catch (error) {
console.error('Failed to load pending uploads', error);
setPendingCount(0);
} finally {
setPendingLoading(false);
}
}, [eventToken]);
React.useEffect(() => {
setNotifications([]);
setUnreadCount(0);
etagRef.current = null;
setPendingCount(0);
if (!eventToken) {
setLoadingNotifications(false);
setPendingLoading(false);
return;
}
setLoadingNotifications(true);
void loadNotifications();
void loadPendingUploads();
}, [eventToken, loadNotifications, loadPendingUploads]);
React.useEffect(() => {
if (!eventToken) {
return;
}
const interval = window.setInterval(() => {
void loadNotifications({ silent: true });
void loadPendingUploads();
}, 90000);
return () => window.clearInterval(interval);
}, [eventToken, loadNotifications, loadPendingUploads]);
React.useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
React.useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === 'guest-notification-refresh') {
void loadNotifications({ silent: true });
}
};
navigator.serviceWorker?.addEventListener('message', handler);
return () => {
navigator.serviceWorker?.removeEventListener('message', handler);
};
}, [loadNotifications]);
const markAsRead = React.useCallback(
async (id: number) => {
if (!eventToken) {
return;
}
let decremented = false;
setNotifications((prev) =>
prev.map((item) => {
if (item.id !== id) {
return item;
}
if (item.status === 'new') {
decremented = true;
}
return {
...item,
status: 'read',
readAt: new Date().toISOString(),
};
})
);
if (decremented) {
setUnreadCount((prev) => Math.max(0, prev - 1));
}
try {
await markGuestNotificationRead(eventToken, id);
} catch (error) {
console.error('Failed to mark notification as read', error);
void loadNotifications({ silent: true });
}
},
[eventToken, loadNotifications]
);
const dismiss = React.useCallback(
async (id: number) => {
if (!eventToken) {
return;
}
let decremented = false;
setNotifications((prev) =>
prev.map((item) => {
if (item.id !== id) {
return item;
}
if (item.status === 'new') {
decremented = true;
}
return {
...item,
status: 'dismissed',
dismissedAt: new Date().toISOString(),
};
})
);
if (decremented) {
setUnreadCount((prev) => Math.max(0, prev - 1));
}
try {
await dismissGuestNotification(eventToken, id);
} catch (error) {
console.error('Failed to dismiss notification', error);
void loadNotifications({ silent: true });
}
},
[eventToken, loadNotifications]
);
const setFilters = React.useCallback((next: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => {
setFiltersState((prev) => ({ ...prev, ...next }));
void loadNotifications({ silent: true });
}, [loadNotifications]);
const refresh = React.useCallback(async () => {
await Promise.all([loadNotifications(), refreshQueue(), loadPendingUploads()]);
}, [loadNotifications, refreshQueue, loadPendingUploads]);
const loading = loadingNotifications || queueLoading || pendingLoading;
React.useEffect(() => {
void updateAppBadge(unreadCount);
}, [unreadCount]);
const value: NotificationCenterValue = {
notifications,
unreadCount,
queueItems: items,
queueCount,
pendingCount,
loading,
pendingLoading,
refresh,
setFilters,
markAsRead,
dismiss,
eventToken,
lastFetchedAt,
isOffline,
};
return (
<NotificationCenterContext.Provider value={value}>
{children}
</NotificationCenterContext.Provider>
);
}
export function useNotificationCenter(): NotificationCenterValue {
const ctx = React.useContext(NotificationCenterContext);
if (!ctx) {
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
}
return ctx;
}
export function useOptionalNotificationCenter(): NotificationCenterValue | null {
return React.useContext(NotificationCenterContext);
}