- 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>
238 lines
5.6 KiB
Svelte
238 lines
5.6 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, tick } from 'svelte';
|
|
import { detectMention } from '$lib/utils/mention';
|
|
import type { MentionDTO } from '$lib/types';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
|
|
type Props = {
|
|
value: string;
|
|
mentionCandidates: MentionDTO[];
|
|
placeholder?: string;
|
|
rows?: number;
|
|
disabled?: boolean;
|
|
onsubmit?: () => void;
|
|
};
|
|
|
|
let {
|
|
value = $bindable(''),
|
|
mentionCandidates = $bindable([]),
|
|
placeholder = '',
|
|
rows = 3,
|
|
disabled = false,
|
|
onsubmit
|
|
}: Props = $props();
|
|
|
|
let query: string | null = $state(null);
|
|
let results: MentionDTO[] = $state([]);
|
|
let highlightedIndex = $state(0);
|
|
let mentionStart = $state(0);
|
|
|
|
let textarea: HTMLTextAreaElement | null = null;
|
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
function attachTextarea(node: HTMLTextAreaElement) {
|
|
textarea = node;
|
|
return () => {
|
|
textarea = null;
|
|
};
|
|
}
|
|
|
|
function handleInput() {
|
|
if (!textarea) return;
|
|
const cursorPos = textarea.selectionStart;
|
|
const detected = detectMention(value, cursorPos);
|
|
|
|
if (detected === null) {
|
|
closePopup();
|
|
return;
|
|
}
|
|
|
|
// Calculate where the @ starts
|
|
const before = value.slice(0, cursorPos);
|
|
const atIndex = before.lastIndexOf('@');
|
|
mentionStart = atIndex;
|
|
|
|
if (query !== detected) {
|
|
query = detected;
|
|
highlightedIndex = 0;
|
|
scheduleSearch(detected);
|
|
}
|
|
}
|
|
|
|
function scheduleSearch(q: string) {
|
|
clearTimeout(debounceTimer);
|
|
if (!q) {
|
|
results = [];
|
|
return;
|
|
}
|
|
debounceTimer = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
|
|
if (res.ok) {
|
|
const data: MentionDTO[] = await res.json();
|
|
results = data.slice(0, 5);
|
|
} else {
|
|
results = [];
|
|
}
|
|
} catch {
|
|
results = [];
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
async function selectUser(user: MentionDTO) {
|
|
if (!textarea) return;
|
|
|
|
const displayName = `${user.firstName} ${user.lastName}`;
|
|
// Replace @partialQuery with @FirstName LastName (plus trailing space)
|
|
const replacement = `@${displayName} `;
|
|
const cursorPos = textarea.selectionStart;
|
|
const before = value.slice(0, mentionStart);
|
|
const after = value.slice(cursorPos);
|
|
value = before + replacement + after;
|
|
|
|
// Deduplicate and add to candidates
|
|
if (!mentionCandidates.some((c) => c.id === user.id)) {
|
|
mentionCandidates = [...mentionCandidates, user];
|
|
}
|
|
|
|
closePopup();
|
|
|
|
// Reposition cursor after the inserted mention
|
|
await tick();
|
|
if (!textarea) return;
|
|
const pos = mentionStart + replacement.length;
|
|
textarea.selectionStart = pos;
|
|
textarea.selectionEnd = pos;
|
|
textarea.focus();
|
|
}
|
|
|
|
function closePopup() {
|
|
query = null;
|
|
results = [];
|
|
highlightedIndex = 0;
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.ctrlKey && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
onsubmit?.();
|
|
return;
|
|
}
|
|
|
|
if (query === null) return;
|
|
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closePopup();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (results.length > 0) {
|
|
highlightedIndex = (highlightedIndex + 1) % results.length;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (results.length > 0) {
|
|
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter' && results.length > 0) {
|
|
e.preventDefault();
|
|
selectUser(results[highlightedIndex]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
async function handleAtButtonClick() {
|
|
if (!textarea) return;
|
|
const pos = textarea.selectionStart;
|
|
const before = value.slice(0, pos);
|
|
const after = value.slice(pos);
|
|
// Ensure @ is preceded by whitespace or is at the start
|
|
const needsSpace = before.length > 0 && !/\s$/.test(before);
|
|
const insertion = needsSpace ? ' @' : '@';
|
|
value = before + insertion + after;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
onDestroy(() => clearTimeout(debounceTimer));
|
|
|
|
const popupOpen = $derived(query !== null);
|
|
</script>
|
|
|
|
<div class="relative">
|
|
<textarea
|
|
{@attach attachTextarea}
|
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
|
rows={rows}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
bind:value={value}
|
|
oninput={handleInput}
|
|
onkeydown={handleKeydown}
|
|
></textarea>
|
|
|
|
{#if popupOpen}
|
|
<div
|
|
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
|
role="listbox"
|
|
aria-label={m.mention_btn_label()}
|
|
>
|
|
{#if results.length === 0}
|
|
<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)}
|
|
<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();
|
|
selectUser(user);
|
|
}}
|
|
>
|
|
{user.firstName}
|
|
{user.lastName}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<button
|
|
type="button"
|
|
aria-label={m.mention_btn_label()}
|
|
disabled={disabled}
|
|
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
|
|
onclick={handleAtButtonClick}
|
|
>
|
|
@
|
|
</button>
|
|
</div>
|