From a8b54b75ea832311b004640f125b370601b3217f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 27 Dec 2025 14:15:14 +0100 Subject: [PATCH] =?UTF-8?q?Added=20app=20badge=20support=20for=20the=20gue?= =?UTF-8?q?st=20PWA=20and=20wired=20it=20to=20the=20existing=20counts=20(u?= =?UTF-8?q?nread=20notifications=20+=20upload=20queue=20+=20=20pending=20u?= =?UTF-8?q?ploads).=20When=20the=20total=20hits=20zero,=20the=20badge=20is?= =?UTF-8?q?=20cleared;=20when=20it=E2=80=99s=20>0,=20it=E2=80=99s=20set.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../context/NotificationCenterContext.tsx | 5 ++ .../js/guest/lib/__tests__/badges.test.ts | 52 +++++++++++++++++++ resources/js/guest/lib/badges.ts | 46 ++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 resources/js/guest/lib/__tests__/badges.test.ts create mode 100644 resources/js/guest/lib/badges.ts 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); + } +}