As a user I want to @mention other users in comments so they are notified and linked to their profile #72
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Background
When writing a comment, users want to draw a specific family member's attention to it. Typing
@triggers a search popup that lets them pick a registered user. The mention is saved as a structured reference, rendered as a clickable link to that user's profile, and triggers a notification.Depends on: #71 (notification system)
Decisions
contenteditablediv (not<textarea>) — allows inline styled chips that delete as one unitAppUseris searched — notPerson/sender/receiver records, since only real accounts can receive notificationsUser Journey
User is writing a comment. They type
@Hand a small popup appears listing users whose name contains "H". As they keep typing the list narrows. They click a name or press Enter. The@Hthey typed is replaced with a styled chip: @Hans Mueller. The rest of the comment continues normally.On submit:
@Hans MuellerinlinementionedUserIdslist is sent alongside the bodyReaders see
@Hans Muellerrendered as a link to his profile.E2E Scenarios
Implementation — contenteditable editor
The comment textarea is replaced with a
contenteditablediv. Key pieces:@ detection (on every
inputevent)Scan backwards from the cursor through the current text node. If there is an
@with no space between it and the cursor, extract the query string and open the popup. A space or another@closes it.Popup positioning
Use
Range.getBoundingClientRect()on the range covering the@querytext to anchor the popup to the exact cursor position.Chip insertion (on user select)
@querytext from the current text node<span contenteditable="false" data-mention-id="{uuid}" class="mention-chip">@Hans Mueller</span>contenteditable="false"on the span is what makes Backspace delete it as a single unit — this is native browser behaviour, no extra code needed.Keyboard navigation in popup
↑/↓to move,Enter/Tabto select,Escapeto dismiss. Prevent default on these keys when the popup is open so they don't move the cursor.Content extraction (on submit)
Walk
childNodesof the editor div:Node.TEXT_NODE→ appendtextContentto body stringSPAN[data-mention-id]→ append@Nameto body string, pushmentionIdtomentionedUserIds[]BR→ append\nPaste normalization
pasteevent:e.preventDefault(), extracttext/plainfromclipboardData, insert viadocument.execCommand('insertText'). Prevents pasted HTML from breaking the chip detection logic.Svelte integration
Use
bind:thisto hold the editor element ref. Do not usebind:innerHTML— Svelte's reconciler would fight with the manual DOM chip insertions. Popup state (query,users,selectedIndex,anchorRect) lives in normal$state. The editor content itself is owned by the DOM.Backend implementation notes
mentionedUserIds: UUID[]alongsidebody: stringcomment_mentionsjoin table (commentId → userId) — parse-free, query-friendlyNotificationService.notifyMention(mentionedUserId, commentId)— delegates to As a user I want to receive notifications for archive activity so I stay informed when family members annotate, comment, or start conversations (#71)GET /api/users/search?q=...endpoint — returns[{ id, firstName, lastName }]for authenticated users onlyFrontend implementation notes
MentionEditor.svelte— self-contained component; emits{ body, mentionedUserIds }on submitMentionPopup.svelte— floating div withrole="listbox", positioned viaposition: fixed+getBoundingClientRect()CommentThread: scan the body for@Namestrings that match known user names and wrap them in<a href="/users/{id}">— or store the rendered form server-side if a lookup table is availableRelated: #71 (notification delivery), #70 (help page — document @mention syntax)
Implementation plan finalised
See
.agent/current-plan.md(Phases 2 + 4) for the full step-by-step plan. Summary of decisions locked in:Mention rendering is server-side. Backend returns
mentionDTOs: [{id, firstName, lastName}]alongside every comment. Frontend maps over that list to replace@Namesubstrings with<a>links — no fragile text parsing, no name-collision ambiguity.contenteditableapproach. The<textarea>inCommentThreadis replaced withMentionEditor.svelte. The editor div is owned entirely by the DOM (bind:this, nobind:innerHTML). Only popup state (query,users,selectedIndex,anchorRect) lives in Svelte$state. Chip insertion usescontenteditable="false"spans — Backspace deletes the entire chip natively.Storage. Mentions are stored in a
comment_mentionsjoin table (comment_id → user_id). TheCreateCommentDTOgains amentionedUserIds: List<UUID>field.Serialization.
DocumentComment.mentions(JPA collection) is@JsonIgnore. A@Transient List<MentionDTO> mentionDTOsfield is populated by the service and serialized instead, exposing only{id, firstName, lastName}.User search.
GET /api/users/search?q=...— max 10 results, authenticated only, searchesAppUser(notPersonrecords), matches against full name and username.No pre-existing comment migration needed — there are no comments in the system yet.
Branch —
feat/71-72-notifications-and-mentions(shared with #71)Architecture review — @mkeller
Two concerns, one hard blocker.
🔴 Blocker:
{@html renderBody()}is an XSS injection pointThe plan says:
renderBody()replaces@Namesubstrings in the comment body with<a href>tags and then hands the result to{@html}. The problem: the rawbodystring comes from user input. If a commenter writes<script>alert(1)</script>or<img src=x onerror=...>, it gets injected straight into the DOM.The fix — escape first, inject links second:
Order matters: escape → inject. Not the other way around. This is non-negotiable before the feature ships. Note that issue #116 (CSP headers) is still open, so there is currently no second line of defence.
🟡 Consider:
contenteditablevs. plain textareaThe chip-based mention editor is well-specified, but
contenteditableis notoriously hostile territory — cross-browser selection quirks, IME composition events, paste normalization edge cases, Safari. You will spend more time debugging the editor than the rest of the feature combined.Ask yourself: does a family archive actually need visual chips that delete as a unit? The functional value is the notification trigger, not the inline chip styling.
The simpler alternative — textarea with regex detection:
<textarea>@queryvia regex oninputevent to trigger the popup (same UX)@queryin the textarea value with@Nameplain textmentionDTOsreturned from/api/users/searchextractContent()becomes a one-liner regex scan — no DOM walking, nochildNodestraversalThis gives identical functional behavior: popup appears, mention is inserted, notification is triggered,
@Namerenders as a link in the thread. The only loss is that Backspace doesn't delete the chip as a unit. For a family commenting tool, that is an acceptable trade-off.If you proceed with
contenteditable, the spec is thorough enough to work from — just budget the extra debugging time honestly.UX Review — @leonievoss
The architect has already flagged the XSS blocker and the
contenteditablequestion. I want to add the UX and accessibility layer to both.🔴 Blocker —
contenteditablehas no ARIA role in the specThe architect's comment correctly raises
contenteditableas an implementation risk. From an accessibility standpoint, it is also a silent failure: acontenteditablediv has no implicit ARIA role. Screen readers announce it as a generic region — users cannot tell it's an editable field.If
contenteditableis used, these attributes are required on the editor div:And
MentionPopupneedsid="mention-popup"role="listbox", each rowrole="option"aria-selected={i === selectedIndex}.Without the full combobox ARIA pattern, this feature is invisible to assistive technology.
🔴 Blocker — Mobile keyboard stability is unspecified
On iOS Safari and Chrome Android, programmatic DOM mutations during chip insertion frequently collapse the virtual keyboard. This is not a theoretical risk — it will happen. The implementation notes must specify that chip insertion is tested on both platforms before merge. This is a showstopper for a family app where a significant portion of users are on mobile.
🟡 Design recommendation — take the textarea path
The architect raises this as a consideration. I'm making it a design recommendation, based on the combined accessibility and implementation risk picture:
contenteditablechips@Nameplain textThe only functional advantage of
contenteditablefor this use case is "Backspace deletes the chip as a unit". For a family commenting tool, that is not worth the accessibility and implementation cost.The
MentionEditor.sveltecomponent interface (onsubmit: { body, mentionedUserIds }) stays identical regardless of which editor is used — it's a clean swap-in if chips are revisited later.Recommendation: implement the textarea path first.
🟡 On the XSS fix — one addition
The architect's escape-first fix is correct and non-negotiable. One addition:
String.replace()only replaces the first occurrence. If the same person is mentioned twice in a comment, the second instance won't be linked:🟡 Medium —
@trigger is non-discoverable for seniorsA 68-year-old typing a comment will not know to type
@. Add a small people-icon button adjacent to the submit button that inserts@at the cursor position and opens the popup. Power users still type@; the button serves users who don't know the convention. This applies equally to both thecontenteditableand textarea implementations.🟡 Medium — Chip deletion must be announced to screen readers
If
contenteditableis implemented: when Backspace removes a chip, the browser removes the DOM node silently. Screen readers will not announce the deletion. Add a visually-hiddenaria-live="polite"announcer div and push"@Hans Mueller entfernt"to it on each deletion.✅ What's good
mentionDTOs) — eliminates fragile client-side name parsing. Correct call.comment_mentionsjoin table — parse-free, query-friendly, clean.GET /api/users/searchscoped toAppUseronly (notPersonrecords) — correct, only real accounts can receive notifications.QA Review — @saraholt
The architect and Leonie have flagged the two biggest risks. I want to add the test coverage dimension to both.
🔴 Blocking — XSS fix needs a regression test before anything else ships
Markus's
renderBodyfix is correct and non-negotiable. A fix without a test is a fix that will regress. This test must exist before merge:The second test encodes Leonie's
replaceAllfix — it will fail ifreplace()is used instead.🔴 Blocking —
contenteditablemakes unit tests very hard; decide before writing any codeMarkus raises this as a consideration. I'm making it a hard requirement to decide before implementation starts, because it affects the test strategy:
contenteditablechip insertion depends onexecCommand,Range, andSelectionAPIs that are not implemented in jsdom. This meansextractContentanddetectMentioncannot be tested in Vitest without browser shims. You either:The textarea path avoids this entirely.
detectMentionandextractContentbecome pure string/regex functions — zero DOM setup, trivially testable in Vitest:The
MentionEditorcomponent interface (onsubmit: { body, mentionedUserIds }) is identical regardless of implementation — it's a clean swap-in if chips are revisited later. Recommendation: implement the textarea path and keep the chip approach as a future enhancement.🟡 Missing unit tests regardless of which editor is chosen
GET /api/users/search?q=Hansreturns max 10 results@WebMvcTestGET /api/users/searchwith emptyqreturns empty list@WebMvcTestGET /api/users/searchreturns 401@WebMvcTest(already in plan ✅)CommentService.postCommentstores mention records incomment_mentions@DataJpaTestwith TestcontainersCommentServicepopulatesmentionDTOson every returned comment🟡 E2E — verify XSS payload does not execute
Once #116 (CSP headers) ships, add to the E2E suite:
Until CSP is in place this is defense-in-depth; after #116 it becomes a regression guard for both layers.
✅ What's solid
mentionDTOsfrom backend) eliminates fragile client-side name parsing — correct callcomment_mentionsjoin table is parse-free and query-friendlyGET /api/users/searchscoped toAppUseronly (notPersonrecords) — only real accounts receive notifications