refactor(notifications): extract useNotificationStream and NotificationDropdown from NotificationBell (#200)
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m38s
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (pull_request) Failing after 2m30s
CI / Backend Unit Tests (pull_request) Failing after 2m48s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 14:54:55 +02:00
parent 8898863a48
commit ff9ae198c4
4 changed files with 370 additions and 216 deletions

View File

@@ -3,53 +3,23 @@ import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import {
type NotificationItem,
relativeTime,
parseNotificationEvent
} from '$lib/utils/notifications';
import { createNotificationStream } from '$lib/hooks/useNotificationStream.svelte';
import NotificationDropdown from './NotificationDropdown.svelte';
let notifications: NotificationItem[] = $state([]);
let unreadCount: number = $state(0);
let open = $state(false);
// DOM refs managed via attachments
let bellButtonEl: HTMLButtonElement | null = null;
let firstFocusableEl: HTMLButtonElement | null = null;
let eventSource: EventSource | null = null;
async function fetchNotifications() {
try {
const res = await fetch('/api/notifications?size=10');
if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
}
}
async function fetchUnreadCount() {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
const stream = createNotificationStream();
async function toggleDropdown() {
open = !open;
if (open) {
await fetchNotifications();
// defer focus until DOM updates
await stream.fetchNotifications();
setTimeout(() => {
firstFocusableEl?.focus();
const firstBtn = document.querySelector<HTMLButtonElement>(
'[role="dialog"] button, [role="dialog"] a'
);
firstBtn?.focus();
}, 0);
}
}
@@ -59,16 +29,8 @@ function closeDropdown() {
bellButtonEl?.focus();
}
async function markRead(notification: NotificationItem) {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
await stream.markRead(notification);
const url = notification.annotationId
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
@@ -76,18 +38,6 @@ async function markRead(notification: NotificationItem) {
goto(url);
}
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) {
event.stopPropagation();
@@ -95,7 +45,6 @@ function handleKeydown(event: KeyboardEvent) {
}
}
// Attachment: stores the element reference for the bell button
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
@@ -103,27 +52,12 @@ function attachBellButton(node: HTMLButtonElement) {
};
}
// Attachment: stores the element reference for the first focusable element in the dropdown
function attachFirstFocusable(node: HTMLButtonElement) {
firstFocusableEl = node;
return () => {
firstFocusableEl = null;
};
}
onMount(() => {
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent(e.data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
stream.init();
});
onDestroy(() => {
eventSource?.close();
stream.destroy();
});
</script>
@@ -135,14 +69,13 @@ onDestroy(() => {
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
aria-label={unreadCount > 0
? m.notification_bell_unread_label({ count: unreadCount })
aria-label={stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()}
aria-expanded={open}
aria-haspopup="true"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<!-- Bell SVG -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -159,143 +92,22 @@ onDestroy(() => {
/>
</svg>
<!-- Unread badge -->
{#if unreadCount > 0}
<span
aria-live="polite"
aria-atomic="true"
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg"
>
{unreadCount}
</span>
{/if}
<!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
<span
aria-live="polite"
aria-atomic="true"
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg {stream.unreadCount > 0 ? '' : 'hidden'}"
>
{stream.unreadCount}
</span>
</button>
<!-- Dropdown -->
{#if open}
<div
role="dialog"
aria-modal="true"
aria-label={m.notification_bell_label()}
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-line px-4 py-3">
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.notification_bell_label()}
</span>
{#if notifications.length > 0}
<button
{@attach attachFirstFocusable}
type="button"
onclick={markAllRead}
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
{/if}
</div>
<!-- Notification list -->
{#if notifications.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-ink-3 opacity-40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span>{m.notification_empty()}</span>
</div>
{:else}
<ul role="list">
{#each notifications as notification (notification.id)}
<li>
<button
type="button"
onclick={() => markRead(notification)}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
{!notification.read ? 'bg-accent-bg/20' : ''}"
>
<!-- Type icon -->
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
{#if notification.type === 'REPLY'}
<!-- Reply icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{:else}
<!-- Mention icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/notifications"
onclick={closeDropdown}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.notification_view_all()}
</a>
</div>
</div>
<NotificationDropdown
notifications={stream.notifications}
onMarkRead={handleMarkRead}
onMarkAllRead={stream.markAllRead}
onClose={closeDropdown}
/>
{/if}
</div>