feat(#71,#72,#73): SSE push notifications, mention chips, deep-link fixes

- Add SseEmitterRegistry (ConcurrentHashMap, one emitter per user)
- Add GET /api/notifications/stream SSE endpoint and unread-count endpoint
- Push SSE event on every notifyReply / notifyMentions via saveAndPush()
- Collapse V18/V19 migrations into V16 (actor_name + annotation_id upfront)
- Add @Schema(requiredMode=REQUIRED) to NotificationDTO required fields
- Switch NotificationBell from polling to EventSource; seed unread count on open
- Fix MentionEditor: replace setTimeout with await tick(); div role=option
- Add aria-modal=true to NotificationBell dialog
- Tests: SseEmitterRegistryTest (3), NotificationServiceTest (+2), NotificationControllerTest (+5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-28 15:41:35 +01:00
parent 9900d0b54b
commit f568c0aeb7
14 changed files with 264 additions and 45 deletions

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { PUBLIC_NOTIFICATION_POLL_MS } from '$env/static/public';
import { m } from '$lib/paraglide/messages.js';
type NotificationItem = {
@@ -16,15 +15,14 @@ type NotificationItem = {
};
let notifications: NotificationItem[] = $state([]);
let unreadCount = $derived(notifications.filter((n) => !n.read).length);
let unreadCount: number = $state(0);
let open = $state(false);
// DOM refs managed via attachments
let bellButtonEl: HTMLButtonElement | null = null;
let firstFocusableEl: HTMLButtonElement | null = null;
const pollMs = Number(PUBLIC_NOTIFICATION_POLL_MS) || 60000;
let intervalId: ReturnType<typeof setInterval>;
let eventSource: EventSource | null = null;
async function fetchNotifications() {
try {
@@ -38,6 +36,18 @@ async function fetchNotifications() {
}
}
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);
}
}
async function toggleDropdown() {
open = !open;
if (open) {
@@ -59,6 +69,7 @@ async function markRead(notification: NotificationItem) {
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);
}
@@ -76,6 +87,7 @@ async function markAllRead() {
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
@@ -133,11 +145,17 @@ function relativeTime(isoString: string): string {
onMount(() => {
fetchNotifications();
intervalId = setInterval(fetchNotifications, pollMs);
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = JSON.parse(e.data) as NotificationItem;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
});
onDestroy(() => {
clearInterval(intervalId);
eventSource?.close();
});
</script>
@@ -189,6 +207,7 @@ onDestroy(() => {
{#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"
>