diff --git a/frontend/package.json b/frontend/package.json index a301fb211..548cb5876 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -65,6 +65,7 @@ "@tiptap/extension-image": "3.10.2", "@tiptap/extension-link": "3.10.2", "@tiptap/extension-list": "3.10.2", + "@tiptap/extension-mention": "3.10.2", "@tiptap/extension-table": "3.10.2", "@tiptap/extension-typography": "3.10.2", "@tiptap/extension-underline": "3.10.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index dbdef1517..124b02353 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -61,6 +61,9 @@ importers: '@tiptap/extension-list': specifier: 3.10.2 version: 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2) + '@tiptap/extension-mention': + specifier: 3.10.2 + version: 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)(@tiptap/suggestion@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)) '@tiptap/extension-table': specifier: 3.10.2 version: 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2) @@ -2184,6 +2187,13 @@ packages: '@tiptap/core': ^3.10.2 '@tiptap/pm': ^3.10.2 + '@tiptap/extension-mention@3.10.2': + resolution: {integrity: sha512-/gyUIMNKBoXOIy0SvjNYJ7G/yiYsnwwbnBo2hQgDza4p53KgnpTzg623/6GHDZSk7zy2S3O+5yWlzEmtrUva3A==} + peerDependencies: + '@tiptap/core': ^3.10.2 + '@tiptap/pm': ^3.10.2 + '@tiptap/suggestion': ^3.10.2 + '@tiptap/extension-ordered-list@3.10.2': resolution: {integrity: sha512-zs8wK1GNVedGENZPJOYUMtiLLPPASvJtabS2HTLPQGnpVeXfF0toftdVYDhGRGoQBBDLDjNyyW5ARzJwwXbTzQ==} peerDependencies: @@ -8961,6 +8971,12 @@ snapshots: '@tiptap/core': 3.10.2(@tiptap/pm@3.10.2) '@tiptap/pm': 3.10.2 + '@tiptap/extension-mention@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2)(@tiptap/suggestion@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2))': + dependencies: + '@tiptap/core': 3.10.2(@tiptap/pm@3.10.2) + '@tiptap/pm': 3.10.2 + '@tiptap/suggestion': 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2) + '@tiptap/extension-ordered-list@3.10.2(@tiptap/extension-list@3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2))': dependencies: '@tiptap/extension-list': 3.10.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2))(@tiptap/pm@3.10.2) diff --git a/frontend/src/components/input/editor/MentionList.vue b/frontend/src/components/input/editor/MentionList.vue new file mode 100644 index 000000000..cc39af22f --- /dev/null +++ b/frontend/src/components/input/editor/MentionList.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/frontend/src/components/input/editor/TipTap.vue b/frontend/src/components/input/editor/TipTap.vue index a33ae49ad..e9ea1ba79 100644 --- a/frontend/src/components/input/editor/TipTap.vue +++ b/frontend/src/components/input/editor/TipTap.vue @@ -158,6 +158,7 @@ import Typography from '@tiptap/extension-typography' import Image from '@tiptap/extension-image' import Underline from '@tiptap/extension-underline' import {Placeholder} from '@tiptap/extensions' +import Mention from '@tiptap/extension-mention' import {TaskItem, TaskList} from '@tiptap/extension-list' import HardBreak from '@tiptap/extension-hard-break' @@ -166,6 +167,7 @@ import {Node} from '@tiptap/pm/model' import Commands from './commands' import suggestionSetup from './suggestion' +import mentionSuggestionSetup from './mentionSuggestion' import {common, createLowlight} from 'lowlight' @@ -190,6 +192,8 @@ const props = withDefaults(defineProps<{ placeholder?: string, editShortcut?: string, enableDiscardShortcut?: boolean, + enableMentions?: boolean, + mentionProjectId?: number, }>(), { uploadCallback: undefined, isEditEnabled: true, @@ -198,6 +202,8 @@ const props = withDefaults(defineProps<{ placeholder: '', editShortcut: '', enableDiscardShortcut: false, + enableMentions: false, + mentionProjectId: 0, }) const emit = defineEmits(['update:modelValue', 'save']) @@ -480,6 +486,18 @@ const extensions : Extensions = [ PasteHandler, ] +// Add mention extension if enabled +if (props.enableMentions && props.mentionProjectId > 0) { + extensions.push( + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: mentionSuggestionSetup(props.mentionProjectId), + }), + ) +} + // Add a custom extension for the Escape key if (props.enableDiscardShortcut) { extensions.push(Extension.create({ @@ -804,6 +822,21 @@ watch( border-radius: $radius; } + // Mention styles + .mention { + background-color: var(--grey-200); + color: var(--grey-800); + border-radius: 0.25rem; + padding: 0.125rem 0.375rem; + font-weight: 400; + text-decoration: none; + cursor: default; + + &:hover { + background-color: var(--grey-300); + } + } + pre { background: var(--grey-200); color: var(--grey-700); diff --git a/frontend/src/components/input/editor/mentionSuggestion.ts b/frontend/src/components/input/editor/mentionSuggestion.ts new file mode 100644 index 000000000..5540b1850 --- /dev/null +++ b/frontend/src/components/input/editor/mentionSuggestion.ts @@ -0,0 +1,188 @@ +import { VueRenderer } from '@tiptap/vue-3' +import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom' +import type { Editor } from '@tiptap/core' + +import MentionList from './MentionList.vue' +import ProjectUserService from '@/services/projectUsers' +import { fetchAvatarBlobUrl, getDisplayName } from '@/models/user' +import type { IUser } from '@/modelTypes/IUser' +import type { MentionNodeAttrs } from '@tiptap/extension-mention' +interface MentionItem extends MentionNodeAttrs { + id: string + label: string + username: string + avatarUrl: string +} + +async function searchUsersForProject(projectId: number, query: string): Promise { + const projectUserService = new ProjectUserService() + + // Use server-side search with the 's' parameter + const users = await projectUserService.getAll({ projectId }, { s: query }) as IUser[] + + // Fetch avatar URLs for all users + const usersWithAvatars = await Promise.all( + users.map(async (user) => { + const avatarUrl = await fetchAvatarBlobUrl(user, 32) + return { + id: String(user.id), + label: getDisplayName(user), + username: user.username, + avatarUrl: avatarUrl as string, + } + }), + ) + + return usersWithAvatars +} + +export default function mentionSuggestionSetup(projectId: number) { + let debounceTimer: ReturnType | null = null + + return { + char: '@', + + items: async ({ query }: { query: string }): Promise => { + if (!projectId) { + return [] + } + + // Clear existing timer + if (debounceTimer) { + clearTimeout(debounceTimer) + } + + // Return a promise that resolves after debounce delay + return new Promise((resolve) => { + debounceTimer = setTimeout(async () => { + try { + // Use server-side search - the backend will handle searching by username and display name + const users = await searchUsersForProject(projectId, query) + + // Limit results to avoid overwhelming the UI + const limit = query ? 10 : 5 + resolve(users.slice(0, limit)) + } catch (error) { + console.error('Failed to fetch users for mentions:', error) + resolve([]) + } + }, 300) // 300ms debounce delay + }) + }, + + render: () => { + let component: VueRenderer + let popupElement: HTMLElement | null = null + let cleanupFloating: (() => void) | null = null + + const virtualReference = { + getBoundingClientRect: () => ({ + width: 0, + height: 0, + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + } as DOMRect), + } + + return { + onStart: (props: { + editor: Editor + clientRect?: (() => DOMRect | null) | null + items: MentionItem[] + command: (item: MentionItem) => void + }) => { + component = new VueRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + // Create popup element + popupElement = document.createElement('div') + popupElement.style.position = 'absolute' + popupElement.style.top = '0' + popupElement.style.left = '0' + popupElement.style.zIndex = '4700' + popupElement.appendChild(component.element!) + document.body.appendChild(popupElement) // Update virtual reference + const rect = props.clientRect() + if (rect) { + virtualReference.getBoundingClientRect = () => rect + // Set up floating positioning + const updatePosition = () => { + computePosition(virtualReference, popupElement!, { + placement: 'bottom-start', + middleware: [ + offset(8), + flip(), + shift({ padding: 8 }), + ], + }).then(({ x, y }) => { + if (popupElement) { + popupElement.style.left = `${x}px` + popupElement.style.top = `${y}px` + } + }) + } + + updatePosition() + cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition) + } + }, + + onUpdate(props: { + editor: Editor + clientRect?: (() => DOMRect | null) | null + items: MentionItem[] + command: (item: MentionItem) => void + }) { + component.updateProps(props) + + if (!props.clientRect || !popupElement) { + return + } + + // Update virtual reference + const rect = props.clientRect() + if (rect) { + virtualReference.getBoundingClientRect = () => rect + } + }, + + onKeyDown(props: { event: KeyboardEvent }) { + if (props.event.key === 'Escape') { + if (props.event.isComposing) { + return false + } + + if (popupElement) { + popupElement.style.display = 'none' + } + + return true + } + + return component.ref?.onKeyDown(props) + }, + + onExit() { + if (cleanupFloating) { + cleanupFloating() + } + if (popupElement) { + document.body.removeChild(popupElement) + popupElement = null + } + component.destroy() + }, + } + }, + } +} diff --git a/frontend/src/components/tasks/partials/Comments.vue b/frontend/src/components/tasks/partials/Comments.vue index c48c8840d..6b2d1c09c 100644 --- a/frontend/src/components/tasks/partials/Comments.vue +++ b/frontend/src/components/tasks/partials/Comments.vue @@ -101,6 +101,8 @@ :bottom-actions="actions[c.id]" :show-save="true" :enable-discard-shortcut="true" + :enable-mentions="true" + :mention-project-id="projectId" initial-mode="preview" @update:modelValue=" () => { @@ -168,6 +170,8 @@ }" :upload-callback="attachmentUpload" :placeholder="$t('task.comment.placeholder')" + :enable-mentions="true" + :mention-project-id="projectId" @save="addComment()" /> @@ -231,6 +235,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard' const props = withDefaults(defineProps<{ taskId: number, + projectId: number, canWrite?: boolean initialComments: ITaskComment[] }>(), { diff --git a/frontend/src/components/tasks/partials/Description.vue b/frontend/src/components/tasks/partials/Description.vue index 593e35d56..56f1314ea 100644 --- a/frontend/src/components/tasks/partials/Description.vue +++ b/frontend/src/components/tasks/partials/Description.vue @@ -31,6 +31,8 @@ :show-save="true" edit-shortcut="e" :enable-discard-shortcut="true" + :enable-mentions="true" + :mention-project-id="modelValue.projectId" @update:modelValue="saveWithDelay" @save="save" /> diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 585915cc9..d6f57ca1e 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -909,6 +909,9 @@ "addedSuccess": "The comment was added successfully.", "permalink": "Copy permalink to this comment" }, + "mention": { + "noUsersFound": "No users found" + }, "deferDueDate": { "title": "Defer due date", "1day": "1 day", diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index d907f1680..f22112083 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -404,6 +404,7 @@ diff --git a/pkg/models/mentions.go b/pkg/models/mentions.go index 05146762a..1fb00ca19 100644 --- a/pkg/models/mentions.go +++ b/pkg/models/mentions.go @@ -17,25 +17,68 @@ package models import ( - "regexp" + "strconv" "strings" "code.vikunja.io/api/pkg/user" + "golang.org/x/net/html" "xorm.io/xorm" ) func FindMentionedUsersInText(s *xorm.Session, text string) (users map[int64]*user.User, err error) { - reg := regexp.MustCompile(`@\w+`) - matches := reg.FindAllString(text, -1) - if matches == nil { + userIDs := extractMentionedUserIDs(text) + if len(userIDs) == 0 { return } - usernames := []string{} - for _, match := range matches { - usernames = append(usernames, strings.TrimPrefix(match, "@")) + return user.GetUsersByIDs(s, userIDs) +} + +// extractMentionedUserIDs parses HTML content and extracts user IDs from mention spans. +// It looks for elements and returns the user IDs. +func extractMentionedUserIDs(htmlText string) []int64 { + doc, err := html.Parse(strings.NewReader(htmlText)) + if err != nil { + return nil } - return user.GetUsersByUsername(s, usernames, true) + var userIDs []int64 + seen := make(map[int64]bool) // Deduplicate user IDs + + var traverse func(*html.Node) + traverse = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "span" { + isMention := false + var dataID string + + // Check if this span has class="mention" and extract data-id + for _, attr := range n.Attr { + if attr.Key == "class" && strings.Contains(attr.Val, "mention") { + isMention = true + } + if attr.Key == "data-id" { + dataID = attr.Val + } + } + + // If this is a mention span with a valid data-id, extract the user ID + if isMention && dataID != "" { + if userID, err := strconv.ParseInt(dataID, 10, 64); err == nil { + if !seen[userID] { + userIDs = append(userIDs, userID) + seen[userID] = true + } + } + } + } + + // Traverse child nodes + for child := n.FirstChild; child != nil; child = child.NextSibling { + traverse(child) + } + } + + traverse(doc) + return userIDs } diff --git a/pkg/models/mentions_test.go b/pkg/models/mentions_test.go index 93b3871df..bc311927a 100644 --- a/pkg/models/mentions_test.go +++ b/pkg/models/mentions_test.go @@ -43,31 +43,31 @@ func TestFindMentionedUsersInText(t *testing.T) { }{ { name: "no users mentioned", - text: "Lorem Ipsum dolor sit amet", + text: "

Lorem Ipsum dolor sit amet

", }, { name: "one user at the beginning", - text: "@user1 Lorem Ipsum", + text: `

@user1 Lorem Ipsum

`, wantUsers: []*user.User{user1}, }, { name: "one user at the end", - text: "Lorem Ipsum @user1", + text: `

Lorem Ipsum @user1

`, wantUsers: []*user.User{user1}, }, { name: "one user in the middle", - text: "Lorem @user1 Ipsum", + text: `

Lorem @user1 Ipsum

`, wantUsers: []*user.User{user1}, }, { name: "same user multiple times", - text: "Lorem @user1 Ipsum @user1 @user1", + text: `

Lorem @user1 Ipsum @user1 @user1

`, wantUsers: []*user.User{user1}, }, { name: "Multiple users", - text: "Lorem @user1 Ipsum @user2", + text: `

Lorem @user1 Ipsum @user2

`, wantUsers: []*user.User{user1, user2}, }, } @@ -103,7 +103,7 @@ func TestSendingMentionNotification(t *testing.T) { task, err := GetTaskByIDSimple(s, 32) require.NoError(t, err) tc := &TaskComment{ - Comment: "Lorem Ipsum @user1 @user2 @user3 @user4 @user5 @user6", + Comment: `

Lorem Ipsum @user1 @user2 @user3 @user4 @user5 @user6

`, TaskID: 32, // user2 has access to the project that task belongs to } err = tc.Create(s, u) @@ -156,7 +156,7 @@ func TestSendingMentionNotification(t *testing.T) { task, err := GetTaskByIDSimple(s, 32) require.NoError(t, err) tc := &TaskComment{ - Comment: "Lorem Ipsum @user2", + Comment: `

Lorem Ipsum @user2

`, TaskID: 32, // user2 has access to the project that task belongs to } err = tc.Create(s, u) @@ -170,7 +170,7 @@ func TestSendingMentionNotification(t *testing.T) { _, err = notifyMentionedUsers(s, &task, tc.Comment, n) require.NoError(t, err) - _, err = notifyMentionedUsers(s, &task, "Lorem Ipsum @user2 @user3", n) + _, err = notifyMentionedUsers(s, &task, `

Lorem Ipsum @user2 @user3

`, n) require.NoError(t, err) // The second time mentioning the user in the same task should not create another notification diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go index e12cd641d..c2a0cf63c 100644 --- a/pkg/models/task_comments_test.go +++ b/pkg/models/task_comments_test.go @@ -75,7 +75,7 @@ func TestTaskComment_Create(t *testing.T) { task, err := GetTaskByIDSimple(s, 32) require.NoError(t, err) tc := &TaskComment{ - Comment: "Lorem Ipsum @user2", + Comment: `

Lorem Ipsum @user2

`, TaskID: 32, // user2 has access to the project that task belongs to } err = tc.Create(s, u)