Files
familienarchiv/frontend/src/lib/components/NotificationBell.svelte
Marcel 2ff5073bfb refactor(notifications): convert per-component stream hook to module-level singleton
Replaces the per-component createNotificationStream() factory with a shared
$lib/stores/notifications.svelte.ts singleton. Ref-counted init()/destroy()
ensures one EventSource per tab no matter how many consumers mount
simultaneously.

Motivation: the /chronik "Für dich" box (#285) needs the same live-arrival
stream that NotificationBell already consumes. Two factories would open two
SSE connections per tab — this refactor avoids the silent regression before
it ships.

- New: src/lib/stores/notifications.svelte.ts (module state, refcount)
- New: src/lib/stores/notifications.svelte.spec.ts (proves single EventSource
  across multiple consumers + ref-counted teardown)
- Deleted: src/lib/hooks/useNotificationStream.svelte.ts (factory)
- Deleted: src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts
- NotificationBell now imports the singleton

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:21:47 +02:00

114 lines
3.1 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { notificationStore } from '$lib/stores/notifications.svelte';
import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false);
let bellButtonEl: HTMLButtonElement | null = null;
const stream = notificationStore;
async function toggleDropdown() {
open = !open;
if (open) {
await stream.fetchNotifications();
setTimeout(() => {
const firstBtn = document.querySelector<HTMLButtonElement>(
'[role="dialog"] button, [role="dialog"] a'
);
firstBtn?.focus();
}, 0);
}
}
function closeDropdown() {
open = false;
bellButtonEl?.focus();
}
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}`;
closeDropdown();
goto(url);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) {
event.stopPropagation();
closeDropdown();
}
}
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
bellButtonEl = null;
};
}
onMount(() => {
stream.init();
});
onDestroy(() => {
stream.destroy();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
<!-- Bell button -->
<button
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
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>
<!-- 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>
{#if open}
<NotificationDropdown
notifications={stream.notifications}
onMarkRead={handleMarkRead}
onMarkAllRead={stream.markAllRead}
onClose={closeDropdown}
/>
{/if}
</div>