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,5 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, tick } from 'svelte';
import { detectMention } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
@@ -80,7 +80,7 @@ function scheduleSearch(q: string) {
}, 200);
}
function selectUser(user: MentionDTO) {
async function selectUser(user: MentionDTO) {
if (!textarea) return;
const displayName = `${user.firstName} ${user.lastName}`;
@@ -99,13 +99,12 @@ function selectUser(user: MentionDTO) {
closePopup();
// Reposition cursor after the inserted mention
setTimeout(() => {
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}, 0);
await tick();
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}
function closePopup() {
@@ -153,7 +152,7 @@ function handleKeydown(e: KeyboardEvent) {
}
}
function handleAtButtonClick() {
async function handleAtButtonClick() {
if (!textarea) return;
const pos = textarea.selectionStart;
const before = value.slice(0, pos);
@@ -163,22 +162,21 @@ function handleAtButtonClick() {
const insertion = needsSpace ? ' @' : '@';
value = before + insertion + after;
setTimeout(() => {
if (!textarea) return;
const newPos = pos + insertion.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
await tick();
if (!textarea) return;
const newPos = pos + insertion.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
// Trigger mention detection after inserting @
const detected = detectMention(value, newPos);
if (detected !== null) {
mentionStart = newPos - 1;
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}, 0);
// Trigger mention detection after inserting @
const detected = detectMention(value, newPos);
if (detected !== null) {
mentionStart = newPos - 1;
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
onDestroy(() => clearTimeout(debounceTimer));
@@ -208,10 +206,11 @@ const popupOpen = $derived(query !== null);
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
{:else}
{#each results as user, i (user.id)}
<button
<div
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
role="option"
aria-selected={i === highlightedIndex}
tabindex="-1"
onmousedown={(e) => {
// Use mousedown to fire before textarea blur
e.preventDefault();
@@ -220,7 +219,7 @@ const popupOpen = $derived(query !== null);
>
{user.firstName}
{user.lastName}
</button>
</div>
{/each}
{/if}
</div>