SPB Git

Repository Public

Notifications.tsx 7.5 KB 110 lines tsx

1// The bell: unread count, and a panel with one human sentence per notification.2import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';3import { useEffect, useRef, useState } from 'react';4import { Link } from 'wouter';5import { get, post } from '../api.ts';6import { Glyph } from '../glyphs.tsx';7import { fmtRelative, useT } from '../i18n.ts';8import type { Notification } from '../types.ts';9import { Button, cx, EmptyState, ErrorState, IconButton, Loading } from '../ui.tsx';1011const KEY = ['notifications'];12type T = ReturnType<typeof useT>;13interface Line { icon: string; tone: 'accent' | 'warn' | 'danger'; title: string; text?: string; href?: string }14const str = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '');15const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);1617/** What a notification says. Unknown kinds show their kind, so nothing is silently dropped. */18export function describe(n: Notification, t: T): Line {19  const d = n.data ?? {};20  switch (n.kind) {21    case 'security.recovery_used': return { icon: 'shield', tone: 'warn', title: t('account.notif.recoveryUsed'), text: str(d.ip) ? t('account.notif.recoveryUsedIp', { ip: str(d.ip) }) : undefined, href: '/settings/activite' };22    case 'security.passkey_added': return { icon: 'key', tone: 'accent', title: t('account.notif.passkeyAdded'), text: str(d.label) || undefined, href: '/settings/securite' };23    case 'security.passkey_removed': return { icon: 'key', tone: 'warn', title: t('account.notif.passkeyRemoved'), href: '/settings/securite' };24    case 'scan.infected': {25      const files = (Array.isArray(d.files) ? d.files : []) as { id?: unknown; name?: unknown }[];26      const names = files.map((f) => str(f?.name)).filter(Boolean);27      return { icon: 'warning', tone: 'danger', title: t('account.notif.infected', { n: names.length || 1 }), text: [names.slice(0, 3).join(', ') + (names.length > 3 ? '…' : ''), str(d.signature)].filter(Boolean).join(' — ') || undefined };28    }29    case 'request.submission': {30      const who = str(d.name) || t('account.notif.someone');31      const parts = [t('account.notif.placed', { n: num(d.placed) }), num(d.rejected) > 0 ? t('account.notif.rejected', { n: num(d.rejected) }) : '', str(d.ref)].filter(Boolean);32      return { icon: 'inbox', tone: 'accent', title: t('account.notif.submission', { name: who, title: str(d.title) }), text: parts.join(' · '), href: str(d.requestId) ? `/requests/${str(d.requestId)}` : undefined };33    }34    default: return { icon: 'bell', tone: 'accent', title: n.kind };35  }36}3738export function Notifications() {39  const t = useT();40  const qc = useQueryClient();41  const [open, setOpen] = useState(false);42  const wrap = useRef<HTMLDivElement>(null);43  const panel = useRef<HTMLDivElement>(null);44  const q = useQuery({ queryKey: KEY, queryFn: () => get<Notification[]>('/notifications'), refetchInterval: 60_000 });45  const unread = q.data?.filter((n) => !n.read).length ?? 0;46  const read = useMutation({47    mutationFn: (ids?: string[]) => post('/notifications/read', ids ? { ids } : {}),48    onMutate: (ids) => { qc.setQueryData<Notification[]>(KEY, (l) => l?.map((n) => (!ids || ids.includes(n.id) ? { ...n, read: true } : n))); },49    onSettled: () => qc.invalidateQueries({ queryKey: KEY }),50  });51  const close = (refocus: boolean) => { setOpen(false); if (refocus) wrap.current?.querySelector<HTMLElement>('button')?.focus(); };52  useEffect(() => {53    if (!open) return;54    panel.current?.focus();55    void q.refetch();56    const out = (e: MouseEvent) => { if (!wrap.current?.contains(e.target as Node)) setOpen(false); };57    window.addEventListener('mousedown', out);58    return () => window.removeEventListener('mousedown', out);59    // eslint-disable-next-line react-hooks/exhaustive-deps60  }, [open]);61  const dot = { accent: 'bg-accent-soft text-accent', warn: 'bg-warn-soft text-warn', danger: 'bg-danger-soft text-danger' };62  return (63    <div ref={wrap} className="relative">64      <IconButton icon="bell" label={unread ? t('account.notif.bellUnread', { n: unread }) : t('account.notif.bell')} aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen((o) => !o)} />65      {unread ? <span aria-hidden="true" data-testid="unread-count" className="pointer-events-none absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[10px] leading-none font-semibold text-white">{unread > 99 ? '99+' : unread}</span> : null}66      {open ? (67        <div ref={panel} role="dialog" aria-label={t('account.notif.title')} tabIndex={-1} onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close(true); } }}68          className="anim-pop fixed inset-x-2 top-[calc(env(safe-area-inset-top)+3.75rem)] z-40 flex max-h-[min(32rem,calc(100dvh-9rem))] flex-col rounded-2xl border border-line bg-raised shadow-pop outline-none sm:absolute sm:inset-x-auto sm:top-full sm:right-0 sm:mt-2 sm:w-[26rem]">69          <header className="flex items-center justify-between gap-3 border-b border-line py-2 pr-2 pl-4">70            <h2 className="text-sm font-semibold text-ink">{t('account.notif.title')}</h2>71            <div className="flex items-center">72              {unread ? <Button variant="ghost" size="sm" className="h-10" onClick={() => read.mutate(undefined)}>{t('account.notif.markAll')}</Button> : null}73              <IconButton icon="x" label={t('common.close')} onClick={() => close(true)} />74            </div>75          </header>76          <div className="min-h-0 flex-1 overflow-y-auto">77            {q.isLoading ? <Loading /> : q.error ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : !q.data?.length ? <EmptyState icon="bell" title={t('account.notif.empty')} text={t('account.notif.emptyText')} /> : (78              <ul className="divide-y divide-line">79                {q.data.map((n) => {80                  const l = describe(n, t);81                  const body = (82                    <>83                      <span className={cx('mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full', dot[l.tone])}><Glyph name={l.icon} size={16} /></span>84                      <span className="min-w-0 flex-1">85                        <span className={cx('block text-sm leading-snug text-ink', !n.read && 'font-semibold')}>{l.title}</span>86                        {l.text ? <span className="mt-0.5 block text-xs leading-snug break-words text-ink-2">{l.text}</span> : null}87                        <span className="mt-1 block text-xs text-ink-3">{fmtRelative(n.createdAt)}</span>88                      </span>89                      {!n.read ? <span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-accent"><span className="sr-only">{t('account.notif.unread')}</span></span> : null}90                    </>91                  );92                  const cls = 'flex w-full items-start gap-3 px-4 py-3 text-left transition hover:bg-hover focus-visible:bg-hover';93                  const mark = () => { if (!n.read) read.mutate([n.id]); };94                  return (95                    <li key={n.id} data-kind={n.kind}>96                      {l.href ? <Link href={l.href} className={cls} onClick={() => { mark(); close(false); }}>{body}</Link>97                        : n.read ? <div className={cls}>{body}</div>98                        : <button type="button" className={cls} onClick={mark} title={t('account.notif.markOne')}>{body}</button>}99                    </li>100                  );101                })}102              </ul>103            )}104          </div>105        </div>106      ) : null}107    </div>108  );109}110
Files main
Menu

Theme