Skip to content

[SE-641] @ mention users in documents - #274

Closed
ihordubas99 wants to merge 7 commits into
devfrom
feature/se-641-mention-users-in-documents
Closed

ihordubas99 wants to merge 7 commits into
devfrom
feature/se-641-mention-users-in-documents

Conversation

@ihordubas99

@ihordubas99 ihordubas99 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Ticket

https://community.openproject.org/wp/SE-641

What are you trying to accomplish?

Users can mention people in a document. Typing @ opens a menu with the active members of the document's project. Picking someone inserts an inline mention with their avatar and name, linking to their profile.

The mention is stored in the document markdown in exactly the form CKEditor uses. OpenProject therefore renders it as a user link with a hover card and can send notifications for it, without special cases. Notifications themselves are handled separately in OpenProject.

Inside the editor a mention behaves like an inline work package. Clicking it selects it and opens a popover to open the profile or remove the mention. A selected mention can be removed with Backspace and moved with drag and drop.

Screenshots

image image image

What approach did you choose and why?

Stored format. BlockNote's markdown export keeps only the text of elements it does not know, so a real <mention> element would be saved as a plain "@Name" and lose its id. Text nodes are copied verbatim, so the server-side spec writes the canonical tag as text. A markdown test pins the exact output in case BlockNote ever starts escaping text. In the browser the clipboard gets a real <mention> element, and a mention copied from a CKEditor field pastes as a mention.

Search. The menu asks /api/v3/principals for active users who are members of the document's project, matching name, login or email. Like CKEditor, it only opens where a word starts, so typing an email address does not open it.

Shared building blocks. A mention needs what the inline work package already had, such as the options popover, node selection, closing on an outside press and a suggestion menu. Instead of copying these, they moved into lib/components/shared and a few generic hooks, and the work package code now uses them too. That is why the diff touches many work package files. The first three commits are this refactoring without behaviour changes and can be reviewed on their own. The last two add the feature.

Two small fixes come with the refactoring. Clicking an open inline work package again now closes its popover inside a shadow root, as in OpenProject, instead of reopening it. A blank # search cancels a pending search again.

Public API. Only additions. The client gets openProjectUserMentionSpec, OpenProjectMentionMenu and the trigger constants. The server entry gets openProjectUserMentionStaticSpec and the stored-format helpers for hocuspocus. The OpenProject side is a separate PR opf/openproject#25917

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Active-user filtering, repeated-query races, hash-search cancellation, keyboard activation, and public setup documentation remain unresolved.

5 open findings
What changed in this PR

Adds project-member @ mentions with OpenProject-compatible serialization, profile links, avatars, menus, and inline-node interactions.

Changes:

  • Adds mention search, rendering, clipboard/markdown serialization, and public APIs.
  • Extracts shared inline-node, popover, search, avatar, and styling infrastructure.
  • Adds comprehensive unit and browser coverage.
File Description
.gitignore Ignores Vitest artifacts.
src/​App.tsx Enables mentions in the demo.
lib/​index.ts Exports mention APIs and triggers.
lib/​server.ts Exports server-safe mention support.
lib/​openProjectTypes.ts Adds the principal type.
lib/​locales/​en.ts Adds mention translations.
lib/​services/​openProjectApi.ts Adds user search, links, and avatars.
lib/​hooks/​useDebouncedSearch.ts Adds reusable debounced search.
lib/​hooks/​useInlineNodeOptions.ts Adds shared inline-node options behavior.
lib/​hooks/​usePressOutside.ts Adds shadow-DOM-aware outside presses.
lib/​hooks/​useSuggestionSearch.ts Generalizes suggestion searches.
lib/​hooks/​useWorkPackageSearch.ts Uses shared debouncing.
lib/​utils/​html.ts Adds HTML escaping helpers.
lib/​utils/​inlineChipActions.ts Reuses generic inline-node operations.
lib/​utils/​inlineNodes.ts Adds generic atom-node operations.
lib/​utils/​insertInline.ts Adds shared inline insertion helpers.
lib/​utils/​links.ts Adds new-tab link props.
lib/​utils/​nodeTypes.ts Centralizes node type constants.
lib/​utils/​suggestionTrigger.ts Adds trigger-text inspection.
lib/​utils/​tapActivation.ts Preserves nested link activation.
lib/​components/​index.ts Exports the mention spec.
lib/​components/​UserMention/​index.ts Exports browser mention support.
lib/​components/​UserMention/​externalHtml.ts Serializes and parses mention markup.
lib/​components/​UserMention/​spec.tsx Defines the React mention spec.
lib/​components/​UserMention/​staticSpec.ts Defines server-side serialization.
lib/​components/​UserMention/​userMentionConfig.ts Defines mention schema properties.
lib/​components/​UserMention/​UserMentionPill.tsx Renders interactive mention pills.
lib/​components/​MentionMenu/​index.ts Exports mention-menu APIs.
lib/​components/​MentionMenu/​MentionMenu.tsx Renders mention results.
lib/​components/​MentionMenu/​mentionTrigger.ts Restricts valid @ triggers.
lib/​components/​MentionMenu/​OpenProjectMentionMenu.tsx Wires the menu controller.
lib/​components/​MentionMenu/​useMentionMenu.ts Searches and inserts users.
lib/​components/​shared/​anchoredPopover.tsx Centralizes anchored popovers.
lib/​components/​shared/​Avatar/​index.ts Exports avatars.
lib/​components/​shared/​Avatar/​Avatar.tsx Renders pictures or initials.
lib/​components/​shared/​Avatar/​avatars.ts Computes initials and colors.
lib/​components/​shared/​inlineNodeStyles.ts Shares inline-node styling.
lib/​components/​shared/​OptionsPopover.tsx Shares open/remove controls.
lib/​components/​shared/​SearchMessage.tsx Shares search status styling.
lib/​components/​shared/​SuggestionListbox.tsx Shares suggestion-list rendering.
lib/​components/​shared/​theme.ts Centralizes theme tokens.
lib/​components/​WorkPackage/​atoms.tsx Reuses shared links and styling.
lib/​components/​WorkPackage/​index.ts Removes relocated style exports.
lib/​components/​WorkPackage/​OptionsPopover.tsx Uses shared option controls.
lib/​components/​WorkPackage/​PreviewPopover.tsx Uses shared popover infrastructure.
lib/​components/​WorkPackage/​SizeMenu.tsx Uses relocated shared utilities.
lib/​components/​WorkPackage/​tokens.ts Removes relocated tokens.
lib/​components/​WorkPackage/​UnavailableCard.tsx Preserves card-link behavior.
lib/​components/​InlineWorkPackage/​chipLayouts.tsx Uses shared inline-node styles.
lib/​components/​InlineWorkPackage/​externalHtml.ts Uses centralized node constants.
lib/​components/​InlineWorkPackage/​inlineConfig.ts Uses centralized node constants.
lib/​components/​InlineWorkPackage/​InlineWorkPackageChip.tsx Uses shared option behavior.
lib/​components/​BlockWorkPackage/​blockConfig.ts Uses centralized node constants.
lib/​components/​BlockWorkPackage/​BlockCards.tsx Uses shared theme variables.
lib/​components/​BlockWorkPackage/​BlockWorkPackage.tsx Reuses outside-press handling.
lib/​components/​CreateWorkPackage/​atoms.ts Uses shared theme variables.
lib/​components/​CreateWorkPackage/​Suggestions.tsx Uses shared anchored popovers.
lib/​components/​HashMenu/​editorUtils.ts Reuses inline insertion.
lib/​components/​HashMenu/​hashTrigger.ts Reuses trigger-text inspection.
lib/​components/​HashMenu/​HashWpMenu.tsx Uses the shared listbox.
lib/​components/​HashMenu/​index.ts Exports the hash trigger.
lib/​components/​HashMenu/​OpenProjectHashMenu.tsx Uses shared menu callbacks.
lib/​components/​HashMenu/​types.ts Reuses suggestion item types.
lib/​components/​HashMenu/​useHashWpMenu.ts Uses generic suggestion search.
lib/​components/​Search/​SearchContainer.tsx Uses shared theme and messages.
lib/​components/​Search/​SearchDropdown.tsx Uses shared search messaging.
lib/​components/​Search/​WorkPackageSearchPopover.tsx Uses shared anchored popovers.
test/​helpers/​content.ts Adds reusable content fixtures.
test/​helpers/​editorHelpers.ts Adds tap and navigation helpers.
test/​helpers/​headlessEditor.ts Adds mention-aware editor helpers.
test/​helpers/​renderEditor.tsx Registers mention support in tests.
test/​mocks/​handlers.ts Mocks avatar responses.
test/​lib/​components/​HashMenu/​editorUtils.test.ts Updates fixture imports.
test/​lib/​components/​HashMenu/​hashTrigger.test.ts Updates fixture imports.
test/​lib/​components/​MentionMenu/​mentionTrigger.test.ts Tests valid mention positions.
test/​lib/​components/​UserMention/​externalHtml.test.ts Tests mention HTML conversion.
test/​lib/​components/​UserMention/​markdown.test.ts Pins canonical markdown output.
test/​lib/​components/​shared/​Avatar/​avatar.browser.test.tsx Tests avatar rendering.
test/​lib/​components/​shared/​Avatar/​avatars.test.ts Tests initials and colors.
test/​lib/​components/​integration/​editor.drag.browser.test.tsx Tests mention dragging.
test/​lib/​components/​integration/​inlineNodeOptionsShadowDom.browser.test.tsx Tests shadow-root popovers.
test/​lib/​components/​integration/​userMention.browser.test.tsx Tests rendering and paste.
test/​lib/​components/​integration/​userMentionGeometry.browser.test.tsx Tests pill geometry.
test/​lib/​components/​integration/​userMentionMenu.browser.test.tsx Tests mention searching and insertion.
test/​lib/​components/​integration/​userMentionOptions.browser.test.tsx Tests mention actions.
test/​lib/​hooks/​useDebouncedSearch.test.ts Tests debounce semantics.
test/​lib/​hooks/​useSuggestionSearch.test.ts Tests stale search handling.
test/​lib/​hooks/​useWorkPackageSearch.test.ts Tests imperative search cancellation.
test/​lib/​services/​openProjectApi.test.ts Tests user API behavior.
test/​lib/​utils/​html.test.ts Tests HTML escaping.
test/​lib/​utils/​inlineChipActions.test.ts Moves generic removal coverage.
test/​lib/​utils/​inlineNodes.test.ts Tests generic inline-node actions.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/components/HashMenu/useHashWpMenu.ts
Comment thread lib/components/UserMention/UserMentionPill.tsx
Comment thread lib/hooks/useSuggestionSearch.ts Outdated
Comment thread lib/services/openProjectApi.ts
Comment thread lib/index.ts
@ihordubas99
ihordubas99 marked this pull request as ready for review October 9, 2026 12:15
@ihordubas99
ihordubas99 requested a review from judithroth October 9, 2026 12:16
@ihordubas99

Copy link
Copy Markdown
Collaborator Author

Description of changes in the new commit:

Why. Hocuspocus saves the aggregated changes of everyone editing with the token of the first user who connected, so OpenProject can't tell from the save who added a mention. The notification would then name the wrong person. As agreed with Judith, the mention carries its author and the backend reads it from the stored tag.

Stored format. The tag gets one more attribute, last and only when an author is known. Without it the tag is unchanged from what CKEditor writes.
<mention class="mention" data-id="5" data-type="user" data-text="@Judith Roth" data-mentioned-by="4">@Judith Roth</mention>

Who gets recorded. initializeOpBlockNoteExtensions takes an optional currentUserId. The new UserMentionAuthorExtension stamps that user on every mention inserted locally, whether picked from the @ menu, pasted, pasted from another document or duplicated. The prop travels with the node, so without restamping a copied mention would keep the first author.

  • Mentions coming in from collaborators through Yjs are left alone. They're recognised by the same y-sync$ meta BlockNote itself uses.
  • Moving a mention or its block, by drag or Ctrl+Shift+Up/Down, keeps the author. Cut and paste counts as a new insert.
  • Without a currentUserId an inserted mention gets no author rather than a wrong one.

The extension has to be passed in extensions because BlockNote 0.54 doesn't register extensions of inline content specs. The README covers the setup.

The author is set by the client, so the backend should check that this user can edit the document.

@github-actions github-actions Bot locked and limited conversation to collaborators Oct 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants