diff --git a/components/notifications/notification-bell.tsx b/components/notifications/notification-bell.tsx index 3b67106..5d14976 100644 --- a/components/notifications/notification-bell.tsx +++ b/components/notifications/notification-bell.tsx @@ -1,13 +1,14 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; import Link from 'next/link'; -import { BellIcon, SettingsIcon } from 'lucide-react'; +import { BellIcon, BellOffIcon, SettingsIcon } from 'lucide-react'; import { authClient } from '@/lib/auth/client'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; +import { useSitePushDisabled } from './push-client'; type Item = { id: string; title: string; body: string; url: string; readAt: string | null }; type Feed = { items: Item[]; unread: number; nextCursor: string | null }; @@ -60,10 +61,11 @@ export function NotificationProvider({ children }: { children: ReactNode }) { } export function NotificationBell() { const state = useContext(Context); + const disabled = useSitePushDisabled(); const [open, setOpen] = useState(false); return { setOpen(value); if (value) void state.refresh(); }}> - }> - {state.unread > 0 && {state.unread > 9 ? '9+' : state.unread}} + }>{disabled ? : } + {!disabled && state.unread > 0 && {state.unread > 9 ? '9+' : state.unread}}

การแจ้งเตือน

diff --git a/components/notifications/push-client.test.ts b/components/notifications/push-client.test.ts new file mode 100644 index 0000000..59132a5 --- /dev/null +++ b/components/notifications/push-client.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { disableSitePush, sitePushDisabled, syncSitePush } from './push-client'; + +describe('notification pause and resume', () => { + const unsubscribe = vi.fn(); + const getSubscription = vi.fn(); + const subscribe = vi.fn(); + const fetchMock = vi.fn(); + let local: Map; + let session: Map; + const storage = (values: Map) => ({ + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + removeItem: (key: string) => { values.delete(key); }, + }); + beforeEach(() => { + vi.resetAllMocks(); + local = new Map(); session = new Map(); + vi.stubGlobal('localStorage', storage(local)); + vi.stubGlobal('sessionStorage', storage(session)); + vi.stubGlobal('window', new EventTarget()); + const registration = { pushManager: { getSubscription, subscribe } }; + vi.stubGlobal('navigator', { serviceWorker: { + getRegistration: vi.fn().mockResolvedValue(registration), + register: vi.fn().mockResolvedValue(registration), + } }); + vi.stubGlobal('fetch', fetchMock); + unsubscribe.mockResolvedValue(true); + getSubscription.mockResolvedValue({ unsubscribe, toJSON: () => ({ endpoint: 'endpoint' }) }); + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ publicKey: 'key', canAdmin: false }) }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('unsubscribes and prevents automatic registration while paused', async () => { + session.set('site-push-synced-user', '1'); + await disableSitePush('user'); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(sitePushDisabled()).toBe(true); + expect(session.has('site-push-synced-user')).toBe(false); + await syncSitePush('user'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('registers both notification features when explicitly resuming', async () => { + await disableSitePush('user'); + getSubscription.mockResolvedValue(null); + subscribe.mockResolvedValue({ toJSON: () => ({ endpoint: 'new-endpoint' }) }); + await syncSitePush('user', true); + expect(subscribe).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith('/api/comments/push-subscription', expect.objectContaining({ method: 'POST' })); + expect(fetchMock).toHaveBeenCalledWith('/api/commission/push-subscription', expect.objectContaining({ method: 'POST' })); + }); + + it('does not report a successful pause when unsubscribe fails', async () => { + unsubscribe.mockResolvedValue(false); + await expect(disableSitePush('user')).rejects.toThrow('push-unsubscribe-failed'); + expect(sitePushDisabled()).toBe(false); + }); + + it('waits for an in-flight sync before unsubscribing', async () => { + const syncing = syncSitePush('user'); + const pausing = disableSitePush('user'); + await Promise.all([syncing, pausing]); + expect(sitePushDisabled()).toBe(true); + expect(unsubscribe.mock.invocationCallOrder[0]).toBeGreaterThan(fetchMock.mock.invocationCallOrder.at(-1)!); + }); +}); diff --git a/components/notifications/push-client.ts b/components/notifications/push-client.ts index 4b779c4..87482b5 100644 --- a/components/notifications/push-client.ts +++ b/components/notifications/push-client.ts @@ -1,18 +1,63 @@ 'use client'; -// All notification features share one browser permission and root service worker. -export async function syncSitePush(userId: string) { - const configResponse = await fetch('/api/commission/push-subscription', { cache: 'no-store' }); - if (!configResponse.ok) throw new Error('push-config-failed'); - const config = await configResponse.json() as { publicKey: string; canAdmin: boolean }; - const registration = await navigator.serviceWorker.register('/commission-push-sw.js', { scope: '/' }); - const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: config.publicKey }); - const data = subscription.toJSON(); - const registrations = [ - { url: '/api/comments/push-subscription', body: data }, - { url: '/api/commission/push-subscription', body: { ...data, scope: config.canAdmin ? 'admin' : 'customer' } }, - ]; - const responses = await Promise.all(registrations.map(feature => fetch(feature.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feature.body) }))); - if (responses.some(response => !response.ok)) throw new Error('push-subscription-failed'); - sessionStorage.setItem(`site-push-synced-${userId}`, '1'); +import { useSyncExternalStore } from 'react'; + +const disabledKey = 'site-push-disabled'; +const preferenceEvent = 'notifications:preference'; +export function sitePushDisabled() { return localStorage.getItem(disabledKey) === '1'; } +export function setSitePushDisabled(disabled: boolean) { + if (disabled) localStorage.setItem(disabledKey, '1'); + else localStorage.removeItem(disabledKey); + window.dispatchEvent(new Event(preferenceEvent)); +} +function subscribePreference(onChange: () => void) { + window.addEventListener(preferenceEvent, onChange); + window.addEventListener('storage', onChange); + return () => { + window.removeEventListener(preferenceEvent, onChange); + window.removeEventListener('storage', onChange); + }; +} +export function useSitePushDisabled() { + return useSyncExternalStore(subscribePreference, sitePushDisabled, () => false); +} + +// Serialize subscription changes so pausing also stops an in-flight automatic sync. +let pending: Promise = Promise.resolve(); +function updateSubscription(operation: () => Promise): Promise { + const result = pending.then(operation, operation); + pending = result.catch(() => undefined); + return result; +} + +export function disableSitePush(userId?: string) { + return updateSubscription(async () => { + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.getRegistration('/'); + const subscription = await registration?.pushManager.getSubscription(); + if (subscription && !await subscription.unsubscribe()) throw new Error('push-unsubscribe-failed'); + } + if (userId) sessionStorage.removeItem(`site-push-synced-${userId}`); + setSitePushDisabled(true); + }); +} + +// All notification features share one browser permission and root service worker. +export function syncSitePush(userId: string, resume = false) { + return updateSubscription(async () => { + if (!resume && sitePushDisabled()) return; + const configResponse = await fetch('/api/commission/push-subscription', { cache: 'no-store' }); + if (!configResponse.ok) throw new Error('push-config-failed'); + const config = await configResponse.json() as { publicKey: string; canAdmin: boolean }; + const registration = await navigator.serviceWorker.register('/commission-push-sw.js', { scope: '/' }); + const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: config.publicKey }); + const data = subscription.toJSON(); + const registrations = [ + { url: '/api/comments/push-subscription', body: data }, + { url: '/api/commission/push-subscription', body: { ...data, scope: config.canAdmin ? 'admin' : 'customer' } }, + ]; + const responses = await Promise.all(registrations.map(feature => fetch(feature.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feature.body) }))); + if (responses.some(response => !response.ok)) throw new Error('push-subscription-failed'); + sessionStorage.setItem(`site-push-synced-${userId}`, '1'); + }); } diff --git a/components/notifications/site-push-prompt.tsx b/components/notifications/site-push-prompt.tsx index 93ad4ca..7815a3a 100644 --- a/components/notifications/site-push-prompt.tsx +++ b/components/notifications/site-push-prompt.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Field, FieldLabel } from '@/components/ui/field'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { syncSitePush } from './push-client'; +import { disableSitePush, setSitePushDisabled, sitePushDisabled, syncSitePush, useSitePushDisabled } from './push-client'; const dismissedKey = 'site-push-dont-ask'; const askedKey = 'site-push-asked'; @@ -14,6 +14,7 @@ export function SitePushPrompt({ configured }: { configured: boolean }) { const { data } = authClient.useSession(); const userId = data?.user.id; const id = useId(); + const disabled = useSitePushDisabled(); const [open, setOpen] = useState(false); const [dontAsk, setDontAsk] = useState(false); const [busy, setBusy] = useState(false); @@ -24,7 +25,7 @@ export function SitePushPrompt({ configured }: { configured: boolean }) { function show() { setError(''); setOpen(true); } window.addEventListener('notifications:enable', show); void Promise.resolve().then(async () => { - if (!active) return; + if (!active || sitePushDisabled()) return; if (Notification.permission === 'granted') { if (userId && !sessionStorage.getItem(`site-push-synced-${userId}`)) await syncSitePush(userId); } else if (Notification.permission === 'default' && !localStorage.getItem(dismissedKey) && !sessionStorage.getItem(askedKey)) { @@ -33,7 +34,7 @@ export function SitePushPrompt({ configured }: { configured: boolean }) { } }).catch(() => undefined); return () => { active = false; window.removeEventListener('notifications:enable', show); }; - }, [configured, userId]); + }, [configured, userId, disabled]); function close() { if (dontAsk) localStorage.setItem(dismissedKey, '1'); setOpen(false); @@ -45,16 +46,24 @@ export function SitePushPrompt({ configured }: { configured: boolean }) { setError('ยังไม่ได้อนุญาตการแจ้งเตือน เปิดสิทธิ์ในการตั้งค่าเว็บไซต์ของเบราว์เซอร์'); return; } - if (userId) await syncSitePush(userId); + if (userId) await syncSitePush(userId, true); + setSitePushDisabled(false); close(); } catch { setError('เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง'); } finally { setBusy(false); } } - return { if (!value && !busy) close(); }}> - รับการแจ้งเตือนจากเว็บไซต์?รับข้อความ Commission คำตอบกลับความคิดเห็น และการแจ้งเตือนอื่นๆ ของเว็บไซต์ในอนาคต แม้ปิดแท็บแล้ว + async function disable() { + setBusy(true); setError(''); + try { await disableSitePush(userId); close(); } + catch { setError('ปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง'); } + finally { setBusy(false); } + } + return { if (!value && !busy) close(); }}> + ตั้งค่าการแจ้งเตือนรับข้อความ Commission คำตอบกลับความคิดเห็น และการแจ้งเตือนอื่นๆ ของเว็บไซต์ในอนาคต แม้ปิดแท็บแล้ว + {disabled &&

ปิดการแจ้งเตือนชั่วคราวในเบราว์เซอร์นี้จนกว่าจะเปิดอีกครั้ง

} {!userId &&

หลังเข้าสู่ระบบ เราจะเชื่อมการแจ้งเตือนกับบัญชีของคุณ

} ไม่ต้องถามอีก {error &&

{error}

} - + {!disabled && }
; }