diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 5fa6bbbf..f908d7a3 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -2,6 +2,9 @@ import { onMount, untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import type { Comment, CommentReply } from '$lib/types'; +import MentionEditor from '$lib/components/MentionEditor.svelte'; +import { renderBody, extractContent } from '$lib/utils/mention'; +import type { MentionDTO } from '$lib/types'; type Props = { documentId: string; @@ -32,6 +35,9 @@ let replyText: string = $state(''); let editingId: string | null = $state(null); let editText: string = $state(''); let posting: boolean = $state(false); +let newMentionCandidates: MentionDTO[] = $state([]); +let replyMentionCandidates: MentionDTO[] = $state([]); +let editMentionCandidates: MentionDTO[] = $state([]); const commentsBase = $derived( annotationId @@ -76,13 +82,15 @@ async function postComment() { if (!text || posting) return; posting = true; try { + const { content, mentionedUserIds } = extractContent(text, newMentionCandidates); const res = await fetch(commentsBase, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: text }) + body: JSON.stringify({ content, mentionedUserIds }) }); if (res.ok) { newText = ''; + newMentionCandidates = []; await reload(); } } finally { @@ -95,13 +103,15 @@ async function postReply(threadId: string) { if (!text || posting) return; posting = true; try { + const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates); const res = await fetch(`${commentsBase}/${threadId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: text }) + body: JSON.stringify({ content, mentionedUserIds }) }); if (res.ok) { replyText = ''; + replyMentionCandidates = []; replyingTo = null; await reload(); } @@ -115,13 +125,15 @@ async function saveEdit(commentId: string) { if (!text || posting) return; posting = true; try { + const { content, mentionedUserIds } = extractContent(text, editMentionCandidates); const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: text }) + body: JSON.stringify({ content, mentionedUserIds }) }); if (res.ok) { editingId = null; + editMentionCandidates = []; await reload(); } } finally { @@ -147,6 +159,7 @@ async function deleteComment(commentId: string) { function startEdit(comment: Comment | CommentReply) { editingId = comment.id; editText = comment.content; + editMentionCandidates = []; } function cancelEdit() { @@ -181,11 +194,13 @@ onMount(() => { {#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)} {#if editingId === comment.id}
- + bind:mentionCandidates={editMentionCandidates} + rows={3} + disabled={posting} + onsubmit={() => saveEdit(comment.id)} + />
-

{comment.content}

+

+ + {@html renderBody(comment.content, comment.mentionDTOs ?? [])} +

{#if canModify(comment)}
@@ -283,12 +301,14 @@ onMount(() => { {#if replyingTo === thread.id}
- + disabled={posting} + onsubmit={() => postReply(thread.id)} + />
+ {/each} + {/if} +
+ {/if} + + +
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 28e9da0b..a2144e40 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,3 +1,9 @@ +export type MentionDTO = { + id: string; + firstName: string; + lastName: string; +}; + export type CommentReply = { id: string; authorId: string | null; @@ -5,6 +11,7 @@ export type CommentReply = { content: string; createdAt: string; updatedAt: string; + mentionDTOs?: MentionDTO[]; }; export type Comment = { @@ -15,6 +22,7 @@ export type Comment = { createdAt: string; updatedAt: string; replies: CommentReply[]; + mentionDTOs?: MentionDTO[]; }; export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history'; diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts new file mode 100644 index 00000000..2da73497 --- /dev/null +++ b/frontend/src/lib/utils/mention.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { detectMention, extractContent, renderBody } from './mention'; +import type { MentionDTO } from '$lib/types'; + +// ─── detectMention ──────────────────────────────────────────────────────────── + +describe('detectMention', () => { + it('returns null when text has no @', () => { + expect(detectMention('hello world', 11)).toBeNull(); + }); + + it('returns null when @ is not the most recent trigger word', () => { + // cursor is past a completed mention (next word started) + expect(detectMention('hello @Hans Müller more', 22)).toBeNull(); + }); + + it('returns empty string immediately after @', () => { + expect(detectMention('hello @', 7)).toBe(''); + }); + + it('returns query text after @', () => { + expect(detectMention('hello @Han', 10)).toBe('Han'); + }); + + it('returns null when @ is preceded by a letter (email address pattern)', () => { + expect(detectMention('user@example', 12)).toBeNull(); + }); + + it('returns query for @ at the very start of string', () => { + expect(detectMention('@Hans', 5)).toBe('Hans'); + }); + + it('returns null when cursor is before the @', () => { + expect(detectMention('@Hans', 0)).toBeNull(); + }); +}); + +// ─── extractContent ─────────────────────────────────────────────────────────── + +describe('extractContent', () => { + it('returns empty arrays for empty string', () => { + const result = extractContent('', []); + expect(result.content).toBe(''); + expect(result.mentionedUserIds).toEqual([]); + }); + + it('returns plain content unchanged when no candidates', () => { + const result = extractContent('Hello world', []); + expect(result.content).toBe('Hello world'); + expect(result.mentionedUserIds).toEqual([]); + }); + + it('extracts user id when @FirstName LastName is in content', () => { + const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; + const result = extractContent('Hey @Hans Müller how are you?', candidates); + expect(result.mentionedUserIds).toContain('uuid-1'); + }); + + it('deduplicates user ids when same user mentioned twice', () => { + const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; + const result = extractContent('@Hans Müller and @Hans Müller again', candidates); + expect(result.mentionedUserIds).toHaveLength(1); + expect(result.mentionedUserIds).toContain('uuid-1'); + }); + + it('collects multiple distinct users', () => { + const candidates: MentionDTO[] = [ + { id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }, + { id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' } + ]; + const result = extractContent('@Hans Müller and @Anna Schmidt', candidates); + expect(result.mentionedUserIds).toContain('uuid-1'); + expect(result.mentionedUserIds).toContain('uuid-2'); + }); +}); + +// ─── renderBody ─────────────────────────────────────────────────────────────── + +describe('renderBody', () => { + it('returns escaped plain text when no mentions', () => { + expect(renderBody('Hello world', [])).toBe('Hello world'); + }); + + it('escapes < and > in content', () => { + const result = renderBody('', []); + expect(result).toContain('<script>'); + expect(result).not.toContain('