feat(notifications) : add profile pictures and guide previews

This commit is contained in:
2026-10-12 23:58:58 +07:00 Unverified
parent 05350e7aab
commit 28517d9b29
10 changed files with 178 additions and 16 deletions
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/auth/client', () => ({ authClient: { useSession: () => ({ data: { user: { id: 'reader' } } }) } }));
vi.mock('./push-client', () => ({ useSitePushDisabled: () => false }));
import { NotificationBell, NotificationProvider } from './notification-bell';
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} });
vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(true);
vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(64);
const item = { body: 'A comment', readAt: '2026-10-12T00:00:00Z', authorName: 'Gunshin - dev', authorImage: 'https://profile.example/author.png', imageUrl: null };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ unread: 0, nextCursor: null, items: [
{ ...item, id: 'comment', title: 'Gunshin - dev แสดงความคิดเห็นใหม่ในไกด์ Lohen', url: '/admin/comments?comment=comment' },
{ ...item, id: 'reply', title: 'Reader ตอบกลับความคิดเห็นของคุณ', url: '/lohen/comment?reply=reply', authorName: 'Reader', authorImage: null },
{ ...item, id: 'commission', title: 'Commission message', url: '/commission/tickets/test', authorName: null, authorImage: null },
{ ...item, id: 'guide', title: 'มีไกด์ใหม่แล้ว!', body: 'คุณสามารถดูไกด์ Lohen ได้แล้ว!', url: '/lohen', authorName: null, authorImage: null, imageUrl: 'https://buzz-cdn.astrxl.dev/guides/lohen.png' },
] }) }));
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('comment profile pictures in the notification bell', () => {
it.each([false, true])('shows the avatar or its initials when the image is broken: %s', async (broken) => {
if (broken) vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(0);
await act(async () => root.render(<NotificationProvider><NotificationBell /></NotificationProvider>));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="การแจ้งเตือน"]')!.click());
const comment = document.querySelector<HTMLAnchorElement>('a[href="/admin/comments?comment=comment"]');
expect(comment).not.toBeNull();
expect(comment?.textContent).toContain('แสดงความคิดเห็นใหม่ในไกด์ Lohen');
if (broken) expect(comment?.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe('Gu');
else expect(comment?.querySelector('img')?.getAttribute('src')).toBe('https://profile.example/author.png');
expect(document.querySelector('a[href="/lohen/comment?reply=reply"] [data-slot="avatar-fallback"]')?.textContent).toBe('Re');
expect(document.querySelector('a[href="/commission/tickets/test"] [data-slot="avatar"]')).toBeNull();
const guide = document.querySelector('a[href="/lohen"]');
expect(guide?.querySelector('[data-slot="avatar"]')).toBeNull();
const overview = guide?.querySelector('img');
expect(overview?.getAttribute('width')).toBe('64');
expect(overview?.getAttribute('height')).toBe('64');
expect(overview?.classList.contains('rounded-full')).toBe(false);
expect(overview?.getAttribute('src')).toContain('lohen.png');
expect(guide?.firstElementChild).toBe(overview);
});
});
@@ -1,16 +1,18 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import Link from 'next/link';
import Image from '@/components/ui/resilient-image';
import { BellIcon, BellOffIcon, SettingsIcon } from 'lucide-react';
import { authClient } from '@/lib/auth/client';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
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 Item = { id: string; title: string; body: string; url: string; readAt: string | null; authorName: string | null; authorImage: string | null; imageUrl: string | null };
type Feed = { items: Item[]; unread: number; nextCursor: string | null };
const empty: Feed = { items: [], unread: 0, nextCursor: null };
type State = Feed & { signedIn: boolean; busy: boolean; error: string; refresh: (more?: boolean) => Promise<Feed | undefined>; read: (id?: string) => Promise<void> };
@@ -81,7 +83,11 @@ export function NotificationBell() {
{!state.signedIn ? <Link href="/auth/login" className="text-sm text-primary underline">เข้าสู่ระบบเพื่อดูการแจ้งเตือน</Link> : <>
{state.unread > 0 && <Button variant="link" size="sm" className="self-start" onClick={() => void state.read().catch(() => undefined)}>อ่านทั้งหมด</Button>}
<div className="flex max-h-[60svh] flex-col gap-1 overflow-y-auto">
{state.items.map(item => <Link key={item.id} href={item.url} prefetch={false} className={cn('rounded-lg p-3', !item.readAt ? 'bg-accent' : 'hover:bg-muted')} onClick={() => { setOpen(false); void state.read(item.id).catch(() => undefined); }}><p className="text-sm font-medium">{!item.readAt && <span className="mr-1 text-destructive" aria-label="ยังไม่ได้อ่าน">●</span>}{item.title}</p><p className="line-clamp-2 break-words text-xs text-muted-foreground">{item.body}</p></Link>)}
{state.items.map(item => <Link key={item.id} href={item.url} prefetch={false} className={cn('flex items-start gap-3 rounded-lg p-3', !item.readAt ? 'bg-accent' : 'hover:bg-muted')} onClick={() => { setOpen(false); void state.read(item.id).catch(() => undefined); }}>
{item.imageUrl ? <Image src={item.imageUrl} alt="" width={64} height={64} sizes="64px" className="size-16 shrink-0 rounded-lg object-contain" />
: (item.authorName || item.authorImage) && <Avatar size="lg"><AvatarImage src={item.authorImage ?? undefined} alt="" /><AvatarFallback>{item.authorName?.slice(0, 2) || '?'}</AvatarFallback></Avatar>}
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{!item.readAt && <span className="mr-1 text-destructive" aria-label="ยังไม่ได้อ่าน">●</span>}{item.title}</p><p className="line-clamp-2 break-words text-xs text-muted-foreground">{item.body}</p></div>
</Link>)}
{state.busy && !state.items.length && <div role="status" aria-label="กำลังโหลดการแจ้งเตือน" className="flex flex-col gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>}
{!state.busy && !state.items.length && !state.error && <p className="py-6 text-center text-sm text-muted-foreground">ยังไม่มีการแจ้งเตือน</p>}
{state.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}