diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx index 74d6c388..817b8f08 100644 --- a/resources/js/guest/context/NotificationCenterContext.tsx +++ b/resources/js/guest/context/NotificationCenterContext.tsx @@ -8,6 +8,7 @@ import { type GuestNotificationItem, } from '../services/notificationApi'; import { fetchPendingUploadsSummary } from '../services/pendingUploadsApi'; +import { updateAppBadge } from '../lib/badges'; export type NotificationCenterValue = { notifications: GuestNotificationItem[]; @@ -265,6 +266,10 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke const loading = loadingNotifications || queueLoading || pendingLoading; const totalCount = unreadCount + queueCount + pendingCount; + React.useEffect(() => { + void updateAppBadge(totalCount); + }, [totalCount]); + const value: NotificationCenterValue = { notifications, unreadCount, diff --git a/resources/js/guest/lib/__tests__/badges.test.ts b/resources/js/guest/lib/__tests__/badges.test.ts new file mode 100644 index 00000000..cb547d5d --- /dev/null +++ b/resources/js/guest/lib/__tests__/badges.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { supportsBadging, updateAppBadge } from '../badges'; + +const originalSet = (navigator as any).setAppBadge; +const originalClear = (navigator as any).clearAppBadge; +const hadSet = 'setAppBadge' in navigator; +const hadClear = 'clearAppBadge' in navigator; + +function restoreNavigator() { + if (hadSet) { + Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: originalSet }); + } else { + delete (navigator as any).setAppBadge; + } + if (hadClear) { + Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: originalClear }); + } else { + delete (navigator as any).clearAppBadge; + } +} + +describe('badges', () => { + afterEach(() => { + restoreNavigator(); + }); + + it('sets the badge count when supported', async () => { + const setAppBadge = vi.fn(); + Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: setAppBadge }); + Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: vi.fn() }); + + expect(supportsBadging()).toBe(true); + await updateAppBadge(4); + expect(setAppBadge).toHaveBeenCalledWith(4); + }); + + it('clears the badge when count is zero', async () => { + const clearAppBadge = vi.fn(); + Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: vi.fn() }); + Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: clearAppBadge }); + + await updateAppBadge(0); + expect(clearAppBadge).toHaveBeenCalled(); + }); + + it('no-ops when unsupported', async () => { + delete (navigator as any).setAppBadge; + delete (navigator as any).clearAppBadge; + expect(supportsBadging()).toBe(false); + await updateAppBadge(3); + }); +}); diff --git a/resources/js/guest/lib/badges.ts b/resources/js/guest/lib/badges.ts new file mode 100644 index 00000000..5fc44c91 --- /dev/null +++ b/resources/js/guest/lib/badges.ts @@ -0,0 +1,46 @@ +type BadgingNavigator = Navigator & { + setAppBadge?: (contents?: number) => Promise | void; + clearAppBadge?: () => Promise | void; +}; + +function getNavigator(): BadgingNavigator | null { + if (typeof navigator === 'undefined') { + return null; + } + return navigator as BadgingNavigator; +} + +export function supportsBadging(): boolean { + const nav = getNavigator(); + return Boolean(nav && (typeof nav.setAppBadge === 'function' || typeof nav.clearAppBadge === 'function')); +} + +export async function updateAppBadge(count: number): Promise { + const nav = getNavigator(); + if (!nav) { + return; + } + + const safeCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; + if (!supportsBadging()) { + return; + } + + try { + if (safeCount > 0 && nav.setAppBadge) { + await nav.setAppBadge(safeCount); + return; + } + + if (nav.clearAppBadge) { + await nav.clearAppBadge(); + return; + } + + if (nav.setAppBadge) { + await nav.setAppBadge(0); + } + } catch (error) { + console.warn('Updating app badge failed', error); + } +}