feat: enable user mentions in task description & comments (#1754)

This commit is contained in:
Weijie Zhao 2025-11-10 02:42:38 +08:00 committed by GitHub
parent bf0fd2885d
commit 43a5ae1309
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 491 additions and 18 deletions

View File

@ -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",

View File

@ -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)

View File

@ -0,0 +1,181 @@
<template>
<div class="mention-items">
<template v-if="items.length">
<button
v-for="(item, index) in items"
:key="item.id"
class="mention-item"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
>
<img
:src="item.avatarUrl"
alt=""
class="mention-avatar"
>
<div class="mention-info">
<p class="mention-name">
{{ item.label }}
</p>
<p
v-if="item.label !== item.username"
class="mention-username"
>
@{{ item.username }}
</p>
</div>
</button>
</template>
<div
v-else
class="mention-item no-results"
>
{{ $t('task.mention.noUsersFound') }}
</div>
</div>
</template>
<script lang="ts">
/* eslint-disable vue/component-api-style */
export default {
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
},
data() {
return {
selectedIndex: 0,
}
},
watch: {
items() {
this.selectedIndex = 0
},
},
methods: {
onKeyDown({event}: {event: KeyboardEvent}) {
if (event.key === 'ArrowUp') {
this.upHandler()
return true
}
if (event.key === 'ArrowDown') {
this.downHandler()
return true
}
if (event.key === 'Enter') {
if (event.isComposing) {
return false
}
this.enterHandler()
return true
}
return false
},
upHandler() {
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
},
downHandler() {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
},
enterHandler() {
this.selectItem(this.selectedIndex)
},
selectItem(index: number) {
const item = this.items[index]
if (item) {
this.command(item)
}
},
},
}
</script>
<style lang="scss" scoped>
.mention-items {
padding: 0.2rem;
position: relative;
border-radius: 0.5rem;
background: var(--white);
color: var(--grey-900);
overflow: hidden;
font-size: 0.9rem;
box-shadow: var(--shadow-md);
min-inline-size: 200px;
max-block-size: 300px;
overflow-y: auto;
}
.mention-item {
display: flex;
align-items: center;
margin: 0;
inline-size: 100%;
text-align: start;
background: transparent;
border-radius: $radius;
border: 0;
padding: 0.4rem 0.6rem;
transition: background-color $transition;
&.is-selected, &:hover {
background: var(--grey-100);
cursor: pointer;
}
&.no-results {
color: var(--grey-500);
cursor: default;
}
}
.mention-avatar {
inline-size: 32px;
block-size: 32px;
border-radius: 50%;
margin-inline-end: 0.75rem;
flex-shrink: 0;
}
.mention-info {
display: flex;
flex-direction: column;
min-inline-size: 0;
flex: 1;
p {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mention-name {
font-size: 0.9rem;
color: var(--grey-800);
font-weight: 500;
}
.mention-username {
font-size: 0.75rem;
color: var(--grey-500);
}
</style>

View File

@ -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);

View File

@ -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<MentionItem[]> {
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<typeof setTimeout> | null = null
return {
char: '@',
items: async ({ query }: { query: string }): Promise<MentionItem[]> => {
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()
},
}
},
}
}

View File

@ -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()"
/>
</div>
@ -231,6 +235,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
const props = withDefaults(defineProps<{
taskId: number,
projectId: number,
canWrite?: boolean
initialComments: ITaskComment[]
}>(), {

View File

@ -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"
/>

View File

@ -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",

View File

@ -404,6 +404,7 @@
<Comments
:can-write="canWrite"
:task-id="taskId"
:project-id="task.projectId"
:initial-comments="task.comments"
/>
</div>

View File

@ -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 <span class="mention" data-id="123"> 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
}

View File

@ -43,31 +43,31 @@ func TestFindMentionedUsersInText(t *testing.T) {
}{
{
name: "no users mentioned",
text: "Lorem Ipsum dolor sit amet",
text: "<p>Lorem Ipsum dolor sit amet</p>",
},
{
name: "one user at the beginning",
text: "@user1 Lorem Ipsum",
text: `<p><span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Lorem Ipsum</p>`,
wantUsers: []*user.User{user1},
},
{
name: "one user at the end",
text: "Lorem Ipsum @user1",
text: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span></p>`,
wantUsers: []*user.User{user1},
},
{
name: "one user in the middle",
text: "Lorem @user1 Ipsum",
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum</p>`,
wantUsers: []*user.User{user1},
},
{
name: "same user multiple times",
text: "Lorem @user1 Ipsum @user1 @user1",
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span></p>`,
wantUsers: []*user.User{user1},
},
{
name: "Multiple users",
text: "Lorem @user1 Ipsum @user2",
text: `<p>Lorem <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
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: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="1" data-label="user1">@user1</span> <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span> <span class="mention" data-type="mention" data-id="3" data-label="user3">@user3</span> <span class="mention" data-type="mention" data-id="4" data-label="user4">@user4</span> <span class="mention" data-type="mention" data-id="5" data-label="user5">@user5</span> <span class="mention" data-type="mention" data-id="6" data-label="user6">@user6</span></p>`,
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: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
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, `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span> <span class="mention" data-type="mention" data-id="3" data-label="user3">@user3</span></p>`, n)
require.NoError(t, err)
// The second time mentioning the user in the same task should not create another notification

View File

@ -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: `<p>Lorem Ipsum <span class="mention" data-type="mention" data-id="2" data-label="user2">@user2</span></p>`,
TaskID: 32, // user2 has access to the project that task belongs to
}
err = tc.Create(s, u)