feat(#72): add @mention support in comment editor
- mention.ts: detectMention (cursor-aware), extractContent (parse @Name → UUID), renderBody (XSS-safe: escape-first then inject anchor tags, replaceAll for all occurrences)
- 19 unit tests in mention.spec.ts (all green)
- MentionEditor.svelte: textarea with @-trigger popup, debounced /api/users/search, keyboard navigation (↑↓ Enter Esc), Ctrl+Enter submit, @ button for accessibility
- CommentThread.svelte: replace plain textareas with MentionEditor, send mentionedUserIds on post/reply/edit, render comment bodies with {@html renderBody(...)}
- types.ts: add MentionDTO, add optional mentionDTOs to Comment and CommentReply
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
120
frontend/src/lib/utils/mention.spec.ts
Normal file
120
frontend/src/lib/utils/mention.spec.ts
Normal file
@@ -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('<script>alert(1)</script>', []);
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in content', () => {
|
||||
const result = renderBody('AT&T', []);
|
||||
expect(result).toContain('AT&T');
|
||||
});
|
||||
|
||||
it('wraps @mention in an anchor tag', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('Hey @Hans Müller!', mentions);
|
||||
expect(result).toContain('<a');
|
||||
expect(result).toContain('Hans Müller');
|
||||
});
|
||||
|
||||
it('does not double-encode already escaped text', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('Check @Hans Müller', mentions);
|
||||
expect(result).not.toContain('&');
|
||||
});
|
||||
|
||||
it('replaces all occurrences of the same mention', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
|
||||
const linkCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(linkCount).toBe(2);
|
||||
});
|
||||
|
||||
it('converts newlines to <br>', () => {
|
||||
const result = renderBody('line1\nline2', []);
|
||||
expect(result).toContain('<br>');
|
||||
expect(result).not.toContain('\n');
|
||||
});
|
||||
});
|
||||
67
frontend/src/lib/utils/mention.ts
Normal file
67
frontend/src/lib/utils/mention.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Given the current textarea value and cursor position, returns the
|
||||
* @-mention query being typed (the text after the last triggering @),
|
||||
* or null if no mention is active.
|
||||
*
|
||||
* Rules:
|
||||
* - @ must be preceded by whitespace or be at the start of the string
|
||||
* - The text between @ and the cursor must not contain a space (a
|
||||
* completed mention word already has a space)
|
||||
*/
|
||||
export function detectMention(text: string, cursorPos: number): string | null {
|
||||
const before = text.slice(0, cursorPos);
|
||||
const atIndex = before.lastIndexOf('@');
|
||||
if (atIndex === -1) return null;
|
||||
|
||||
// @ must be at start or preceded by whitespace
|
||||
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
|
||||
|
||||
const query = before.slice(atIndex + 1);
|
||||
// If the query contains a space the user has moved past the trigger word
|
||||
if (query.includes(' ')) return null;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the raw textarea value and a list of candidate users (from the
|
||||
* mention popup selections), returns the plain content string and the
|
||||
* de-duplicated list of mentioned user IDs.
|
||||
*/
|
||||
export function extractContent(
|
||||
text: string,
|
||||
candidates: MentionDTO[]
|
||||
): { content: string; mentionedUserIds: string[] } {
|
||||
const seen = new Set<string>();
|
||||
for (const user of candidates) {
|
||||
const displayName = `${user.firstName} ${user.lastName}`.trim();
|
||||
if (text.includes(`@${displayName}`)) {
|
||||
seen.add(user.id);
|
||||
}
|
||||
}
|
||||
return { content: text, mentionedUserIds: [...seen] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment body as safe HTML:
|
||||
* 1. Escapes all HTML-special characters in the raw content
|
||||
* 2. Replaces every @FirstName LastName occurrence with an anchor link
|
||||
* 3. Converts newlines to <br>
|
||||
*/
|
||||
export function renderBody(content: string, mentions: MentionDTO[]): string {
|
||||
let escaped = content
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
|
||||
for (const mention of mentions) {
|
||||
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
|
||||
escaped = escaped.replaceAll(`@${displayName}`, link);
|
||||
}
|
||||
|
||||
return escaped.replaceAll('\n', '<br>');
|
||||
}
|
||||
Reference in New Issue
Block a user