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:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -6,6 +6,8 @@ let {
|
||||
groups: { id: string; name: string }[];
|
||||
selectedGroupIds?: string[];
|
||||
} = $props();
|
||||
|
||||
let selected = $derived([...selectedGroupIds]);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@@ -15,7 +17,7 @@ let {
|
||||
type="checkbox"
|
||||
name="groupIds"
|
||||
value={group.id}
|
||||
checked={selectedGroupIds.includes(group.id)}
|
||||
bind:group={selected}
|
||||
class="rounded border-line text-ink focus:ring-accent"
|
||||
/>
|
||||
{group.name}
|
||||
|
||||
@@ -5,6 +5,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
||||
canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL'))
|
||||
canAnnotate: groups.some(
|
||||
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user