BLOCKERs: - Remove direct AppUserRepository/CommentRepository access from CommentService and NotificationService — replaced with UserService.findAllById() and UserService (fixes layering contract from CLAUDE.md) - Switch Optional<JavaMailSender> constructor injection — removes @Autowired(required=false) field and ReflectionTestUtils hack in tests - Add @RequirePermission(READ_ALL) to UserSearchController — prevents user enumeration without read access Data bug: - Promote actorName from @Transient to persisted VARCHAR column (V18 migration) - Set actorName in notifyReply and notifyMentions from comment.getAuthorName() Architecture: - Add @RequirePermission(READ_ALL) to NotificationController - Introduce NotificationDTO — controller returns DTO instead of Notification entity, eliminating lazy-load N+1 and AppUser field leakage - Change mentions FetchType to EAGER — fixes LazyInitializationException outside transaction - Add @Transactional(propagation=REQUIRES_NEW) to notifyReply/notifyMentions so a notification failure cannot roll back the parent comment - N+1 fix: replace per-ID findById loops with single findAllById bulk fetch - Move collectParticipantIds to CommentService; notifyReply accepts Set<UUID> directly Security: - Escape displayName before injecting into renderBody HTML span - Replace <a href="#"> with <span class="mention"> — no profile page to link to, and the anchor's scroll-to-top behaviour is harmful Tests added/fixed: - markRead_throwsNotFound, markAllRead_delegatesToRepository, countUnread_delegatesToRepository - markOneRead_returns401, @RequirePermission 403 coverage for both controllers - postComment/replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided - search_returnsAtMostTenResults now asserts $.length() <= 10 - XSS regression test for escaped displayName in mention.spec.ts Frontend minors: - relativeTime() uses Intl.RelativeTimeFormat (locale-aware, not German-hardcoded) - aria-label uses m.notification_unread() Paraglide key (de/en/es added) - <div role="button"> replaced with <button> (native Enter+Space handling) - onDestroy clears debounceTimer in MentionEditor - setTimeout(100) replaced with await tick() + requestAnimationFrame in CommentThread - Notification prefs form uses checkbox name attributes + formData.has() pattern Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
73 lines
2.4 KiB
TypeScript
73 lines
2.4 KiB
TypeScript
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 escapedDisplayName = displayName
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
|
|
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
|
}
|
|
|
|
return escaped.replaceAll('\n', '<br>');
|
|
}
|