feat : can turn off notifcation
This commit is contained in:
@@ -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 <Popover open={open} onOpenChange={value => { setOpen(value); if (value) void state.refresh(); }}>
|
||||
<PopoverTrigger render={<Button variant="ghost" size="icon" className="relative shrink-0" aria-label={`การแจ้งเตือน${state.unread ? ` ใหม่ ${state.unread} รายการ` : ''}`} />}><BellIcon />
|
||||
{state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full bg-destructive px-1 text-background dark:bg-destructive">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
||||
<PopoverTrigger render={<Button variant="ghost" size="icon" className="relative shrink-0" aria-label={`การแจ้งเตือน${disabled ? ' ปิดชั่วคราว' : state.unread ? ` ใหม่ ${state.unread} รายการ` : ''}`} />}>{disabled ? <BellOffIcon /> : <BellIcon />}
|
||||
{!disabled && state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full bg-destructive px-1 text-background dark:bg-destructive">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">การแจ้งเตือน</h2><Button variant="ghost" size="icon-sm" aria-label="ตั้งค่าการแจ้งเตือน" onClick={() => { setOpen(false); window.dispatchEvent(new Event('notifications:enable')); }}><SettingsIcon /></Button></div>
|
||||
|
||||
@@ -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<string, string>;
|
||||
let session: Map<string, string>;
|
||||
const storage = (values: Map<string, string>) => ({
|
||||
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)!);
|
||||
});
|
||||
});
|
||||
@@ -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<unknown> = Promise.resolve();
|
||||
function updateSubscription<T>(operation: () => Promise<T>): Promise<T> {
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 <Dialog open={open} onOpenChange={value => { if (!value && !busy) close(); }}><DialogContent>
|
||||
<DialogHeader><DialogTitle>รับการแจ้งเตือนจากเว็บไซต์?</DialogTitle><DialogDescription>รับข้อความ Commission คำตอบกลับความคิดเห็น และการแจ้งเตือนอื่นๆ ของเว็บไซต์ในอนาคต แม้ปิดแท็บแล้ว</DialogDescription></DialogHeader>
|
||||
async function disable() {
|
||||
setBusy(true); setError('');
|
||||
try { await disableSitePush(userId); close(); }
|
||||
catch { setError('ปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <Dialog open={open} onOpenChange={value => { if (!value && !busy) close(); }}><DialogContent showCloseButton={!busy}>
|
||||
<DialogHeader><DialogTitle>ตั้งค่าการแจ้งเตือน</DialogTitle><DialogDescription>รับข้อความ Commission คำตอบกลับความคิดเห็น และการแจ้งเตือนอื่นๆ ของเว็บไซต์ในอนาคต แม้ปิดแท็บแล้ว</DialogDescription></DialogHeader>
|
||||
{disabled && <p className="text-sm text-muted-foreground">ปิดการแจ้งเตือนชั่วคราวในเบราว์เซอร์นี้จนกว่าจะเปิดอีกครั้ง</p>}
|
||||
{!userId && <p className="text-sm text-muted-foreground">หลังเข้าสู่ระบบ เราจะเชื่อมการแจ้งเตือนกับบัญชีของคุณ</p>}
|
||||
<Field orientation="horizontal"><Checkbox id={id} checked={dontAsk} onCheckedChange={setDontAsk} disabled={busy} /><FieldLabel htmlFor={id}>ไม่ต้องถามอีก</FieldLabel></Field>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={close}>ไว้ก่อน</Button><Button disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button></DialogFooter>
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={close}>ไว้ก่อน</Button>{!disabled && <Button variant="outline" disabled={busy} onClick={() => void disable()}>ปิดชั่วคราว</Button>}<Button disabled={busy} onClick={() => void enable()}>{disabled ? 'เปิดอีกครั้ง' : 'เปิดการแจ้งเตือน'}</Button></DialogFooter>
|
||||
</DialogContent></Dialog>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user