diff --git a/frontend/src/components/project/views/ProjectList.vue b/frontend/src/components/project/views/ProjectList.vue
index f12f14585..73cf3c257 100644
--- a/frontend/src/components/project/views/ProjectList.vue
+++ b/frontend/src/components/project/views/ProjectList.vue
@@ -149,8 +149,8 @@ const {
() => props.viewId,
{position: 'asc'},
() => projectId.value === -1
- ? 'comment_count'
- : ['subtasks', 'comment_count'],
+ ? ['comment_count', 'is_unread']
+ : ['subtasks', 'comment_count', 'is_unread'],
)
const taskPositionService = ref(new TaskPositionService())
diff --git a/frontend/src/components/project/views/ProjectTable.vue b/frontend/src/components/project/views/ProjectTable.vue
index 2ad659c0a..7ad836124 100644
--- a/frontend/src/components/project/views/ProjectTable.vue
+++ b/frontend/src/components/project/views/ProjectTable.vue
@@ -347,7 +347,7 @@ const taskList = useTaskList(
() => props.projectId,
() => props.viewId,
sortBy.value,
- () => 'comment_count',
+ () => ['comment_count', 'is_unread'],
)
const {
diff --git a/frontend/src/components/tasks/partials/CommentCount.vue b/frontend/src/components/tasks/partials/CommentCount.vue
index e9eb3be47..eb1557adc 100644
--- a/frontend/src/components/tasks/partials/CommentCount.vue
+++ b/frontend/src/components/tasks/partials/CommentCount.vue
@@ -3,9 +3,14 @@
v-if="task.commentCount && task.commentCount > 0"
v-tooltip="tooltip"
class="comment-count"
+ :class="{'is-unread': task.isUnread}"
>
+
@@ -41,6 +46,21 @@ const tooltip = computed(() => t('task.attributes.comment', props.task.commentCo
&:hover {
color: var(--primary);
}
+
+ &.is-unread {
+ font-weight: 600;
+ color: var(--primary);
+
+ .unread-indicator {
+ display: inline-block;
+ inline-size: 0.375rem;
+ block-size: 0.375rem;
+ background-color: var(--primary);
+ border-radius: 50%;
+ margin-inline-start: 0.125rem;
+ animation: pulse 2s infinite;
+ }
+ }
}
diff --git a/frontend/src/modelTypes/ITask.ts b/frontend/src/modelTypes/ITask.ts
index b321030e5..dc5c60e03 100644
--- a/frontend/src/modelTypes/ITask.ts
+++ b/frontend/src/modelTypes/ITask.ts
@@ -43,6 +43,7 @@ export interface ITask extends IAbstract {
identifier: string
index: number
isFavorite: boolean
+ isUnread?: boolean
subscription: ISubscription
position: number
diff --git a/frontend/src/services/task.ts b/frontend/src/services/task.ts
index 57cb605e8..9d62e9d0c 100644
--- a/frontend/src/services/task.ts
+++ b/frontend/src/services/task.ts
@@ -7,6 +7,7 @@ import LabelService from './label'
import {colorFromHex} from '@/helpers/color/colorFromHex'
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK} from '@/constants/date'
import {objectToSnakeCase} from '@/helpers/case'
+import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
const parseDate = date => {
if (date) {
@@ -123,5 +124,15 @@ export default class TaskService extends AbstractService {
return transformed as ITask
}
+
+ async markTaskAsRead(taskId: ITask['id']): Promise {
+ const cancel = this.setLoading()
+
+ try {
+ await AuthenticatedHTTPFactory().post(`/tasks/${taskId}/read`, {} as ITask)
+ } finally {
+ cancel()
+ }
+ }
}
diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts
index b3ce3bbe4..8edca87d1 100644
--- a/frontend/src/services/taskCollection.ts
+++ b/frontend/src/services/taskCollection.ts
@@ -4,7 +4,7 @@ import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import BucketModel from '@/models/bucket'
-export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | 'comment_count' | null
+export type ExpandTaskFilterParam = 'subtasks' | 'buckets' | 'reactions' | 'comment_count' | 'is_unread' | null
export interface TaskFilterParams {
sort_by: ('start_date' | 'end_date' | 'due_date' | 'done' | 'id' | 'position' | 'title')[],
diff --git a/frontend/src/stores/kanban.ts b/frontend/src/stores/kanban.ts
index a1fb1cba6..168850769 100644
--- a/frontend/src/stores/kanban.ts
+++ b/frontend/src/stores/kanban.ts
@@ -262,7 +262,7 @@ export const useKanbanStore = defineStore('kanban', () => {
try {
const newBuckets = await taskCollectionService.getAll({projectId, viewId}, {
...params,
- expand: 'comment_count',
+ expand: ['comment_count', 'is_unread'],
per_page: TASKS_PER_BUCKET,
})
setBuckets(newBuckets)
@@ -301,7 +301,7 @@ export const useKanbanStore = defineStore('kanban', () => {
params.filter = `${params.filter === '' ? '' : params.filter + ' && '}bucket_id = ${bucketId}`
params.filter_timezone = authStore.settings.timezone
params.per_page = TASKS_PER_BUCKET
- params.expand = 'comment_count'
+ params.expand = ['comment_count', 'is_unread']
const taskService = new TaskCollectionService()
try {
diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts
index b9668ec5c..397ee8d21 100644
--- a/frontend/src/stores/tasks.ts
+++ b/frontend/src/stores/tasks.ts
@@ -512,6 +512,26 @@ export const useTaskStore = defineStore('task', () => {
return task
}
+ async function markTaskAsRead(taskId: ITask['id']) {
+ const taskService = new TaskService()
+ await taskService.markTaskAsRead(taskId)
+
+ const t = kanbanStore.getTaskById(taskId)
+ if (t.task !== null) {
+ kanbanStore.setTaskInBucket({
+ ...t.task,
+ isUnread: false,
+ })
+ }
+
+ if (tasks.value[taskId]) {
+ tasks.value[taskId] = {
+ ...tasks.value[taskId],
+ isUnread: false,
+ }
+ }
+ }
+
return {
tasks,
isLoading,
@@ -533,6 +553,7 @@ export const useTaskStore = defineStore('task', () => {
findProjectId,
ensureLabelsExist,
toggleFavorite,
+ markTaskAsRead,
}
})
diff --git a/frontend/src/views/tasks/ShowTasks.vue b/frontend/src/views/tasks/ShowTasks.vue
index 0327b0909..6c701634d 100644
--- a/frontend/src/views/tasks/ShowTasks.vue
+++ b/frontend/src/views/tasks/ShowTasks.vue
@@ -239,7 +239,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
filter: 'done = false',
filter_include_nulls: props.showNulls,
s: '',
- expand: 'comment_count',
+ expand: ['comment_count', 'is_unread'],
}
if (!showAll.value) {
diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue
index 9c667db25..d6fa7b048 100644
--- a/frontend/src/views/tasks/TaskDetailView.vue
+++ b/frontend/src/views/tasks/TaskDetailView.vue
@@ -806,12 +806,17 @@ watch(
}
try {
- const loaded = await taskService.get({id}, {expand: ['reactions', 'comments']})
+ const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread']})
Object.assign(task.value, loaded)
attachmentStore.set(task.value.attachments)
taskColor.value = task.value.hexColor
setActiveFields()
+ if (task.value.isUnread) {
+ await taskStore.markTaskAsRead(task.value.id)
+ task.value.isUnread = false
+ }
+
if (lastProject.value) {
await baseStore.handleSetCurrentProjectIfNotSet(lastProject.value)
}
diff --git a/pkg/migration/20251118125156.go b/pkg/migration/20251118125156.go
new file mode 100644
index 000000000..bfb4b643c
--- /dev/null
+++ b/pkg/migration/20251118125156.go
@@ -0,0 +1,44 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package migration
+
+import (
+ "src.techknowlogick.com/xormigrate"
+ "xorm.io/xorm"
+)
+
+type TaskUnreadStatus20251118125156 struct {
+ TaskID int64 `xorm:"int(11) not null unique(task_user)"`
+ UserID int64 `xorm:"int(11) not null unique(task_user)"`
+}
+
+func (TaskUnreadStatus20251118125156) TableName() string {
+ return "task_unread_statuses"
+}
+
+func init() {
+ migrations = append(migrations, &xormigrate.Migration{
+ ID: "20251118125156",
+ Description: "",
+ Migrate: func(tx *xorm.Engine) error {
+ return tx.Sync(TaskUnreadStatus20251118125156{})
+ },
+ Rollback: func(tx *xorm.Engine) error {
+ return tx.DropTables(TaskUnreadStatus20251118125156{})
+ },
+ })
+}
diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go
index 03a94fcd1..6112b8f3a 100644
--- a/pkg/models/listeners.go
+++ b/pkg/models/listeners.go
@@ -69,6 +69,7 @@ func RegisterListeners() {
events.RegisterListener((&TaskRelationDeletedEvent{}).Name(), &HandleTaskUpdateLastUpdated{})
events.RegisterListener((&TaskCreatedEvent{}).Name(), &UpdateTaskInSavedFilterViews{})
events.RegisterListener((&TaskUpdatedEvent{}).Name(), &UpdateTaskInSavedFilterViews{})
+ events.RegisterListener((&TaskCommentCreatedEvent{}).Name(), &MarkTaskUnreadOnComment{})
if config.TypesenseEnabled.GetBool() {
events.RegisterListener((&TaskDeletedEvent{}).Name(), &RemoveTaskFromTypesense{})
events.RegisterListener((&TaskCreatedEvent{}).Name(), &AddTaskToTypesense{})
@@ -1181,3 +1182,78 @@ func (s *HandleUserDataExport) Handle(msg *message.Message) (err error) {
err = sess.Commit()
return err
}
+
+type MarkTaskUnreadOnComment struct {
+}
+
+func (s *MarkTaskUnreadOnComment) Name() string {
+ return "task.comment.mark.unread"
+}
+
+func (s *MarkTaskUnreadOnComment) Handle(msg *message.Message) (err error) {
+ event := &TaskCommentCreatedEvent{}
+ err = json.Unmarshal(msg.Payload, event)
+ if err != nil {
+ return err
+ }
+
+ sess := db.NewSession()
+ defer sess.Close()
+
+ err = sess.Begin()
+ if err != nil {
+ return err
+ }
+
+ project, err := GetProjectSimpleByID(sess, event.Task.ProjectID)
+ if err != nil {
+ _ = sess.Rollback()
+ return err
+ }
+
+ users, err := ListUsersFromProject(sess, project, event.Doer, "")
+ if err != nil {
+ _ = sess.Rollback()
+ return err
+ }
+
+ // Get existing unread statuses for this task
+ existingUnreadStatuses := []*TaskUnreadStatus{}
+ err = sess.
+ Where("task_id = ?", event.Task.ID).
+ Find(&existingUnreadStatuses)
+ if err != nil {
+ _ = sess.Rollback()
+ return err
+ }
+
+ // Create a set of existing user IDs for quick lookup
+ existingUserIDs := make(map[int64]bool)
+ for _, status := range existingUnreadStatuses {
+ existingUserIDs[status.UserID] = true
+ }
+
+ // Build list of new unread statuses
+ unreadStatuses := []*TaskUnreadStatus{}
+ for _, u := range users {
+ // Skip the comment author and users who already have unread status
+ if u.ID == event.Doer.ID || existingUserIDs[u.ID] {
+ continue
+ }
+ unreadStatuses = append(unreadStatuses, &TaskUnreadStatus{
+ TaskID: event.Task.ID,
+ UserID: u.ID,
+ })
+ }
+
+ // Bulk insert new unread statuses
+ if len(unreadStatuses) > 0 {
+ _, err = sess.Insert(&unreadStatuses)
+ if err != nil {
+ _ = sess.Rollback()
+ return err
+ }
+ }
+
+ return sess.Commit()
+}
diff --git a/pkg/models/models.go b/pkg/models/models.go
index dc954b07b..2c1ae43cf 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -65,6 +65,7 @@ func GetTables() []interface{} {
&ProjectView{},
&TaskPosition{},
&TaskBucket{},
+ &TaskUnreadStatus{},
}
}
diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go
index b66aa2970..9eef9177f 100644
--- a/pkg/models/task_collection.go
+++ b/pkg/models/task_collection.go
@@ -71,6 +71,7 @@ const TaskCollectionExpandBuckets TaskCollectionExpandable = `buckets`
const TaskCollectionExpandReactions TaskCollectionExpandable = `reactions`
const TaskCollectionExpandComments TaskCollectionExpandable = `comments`
const TaskCollectionExpandCommentCount TaskCollectionExpandable = `comment_count`
+const TaskCollectionExpandIsUnread TaskCollectionExpandable = `is_unread`
// Validate validates if the TaskCollectionExpandable value is valid.
func (t TaskCollectionExpandable) Validate() error {
@@ -85,9 +86,11 @@ func (t TaskCollectionExpandable) Validate() error {
return nil
case TaskCollectionExpandCommentCount:
return nil
+ case TaskCollectionExpandIsUnread:
+ return nil
}
- return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count")
+ return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count, is_unread")
}
func validateTaskField(fieldName string) error {
diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go
index 8d41afc60..2a23fa0ec 100644
--- a/pkg/models/task_comments_test.go
+++ b/pkg/models/task_comments_test.go
@@ -93,6 +93,39 @@ func TestTaskComment_Create(t *testing.T) {
"name": (&TaskCommentNotification{}).Name(),
}, false)
})
+ t.Run("should mark task unread for project members on comment", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ task, err := GetTaskByIDSimple(s, 32)
+ require.NoError(t, err)
+
+ tc := &TaskComment{
+ Comment: "test comment",
+ TaskID: 32,
+ }
+ err = tc.Create(s, u)
+ require.NoError(t, err)
+
+ ev := &TaskCommentCreatedEvent{
+ Task: &task,
+ Doer: u,
+ Comment: tc,
+ }
+
+ events.TestListener(t, ev, &MarkTaskUnreadOnComment{})
+
+ db.AssertExists(t, "task_unread_statuses", map[string]interface{}{
+ "task_id": task.ID,
+ "user_id": 2,
+ }, false)
+
+ db.AssertMissing(t, "task_unread_statuses", map[string]interface{}{
+ "task_id": task.ID,
+ "user_id": u.ID,
+ })
+ })
}
func TestTaskComment_Delete(t *testing.T) {
diff --git a/pkg/models/task_unread_statuses.go b/pkg/models/task_unread_statuses.go
new file mode 100644
index 000000000..3d286758d
--- /dev/null
+++ b/pkg/models/task_unread_statuses.go
@@ -0,0 +1,60 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package models
+
+import (
+ "code.vikunja.io/api/pkg/web"
+ "xorm.io/xorm"
+)
+
+type TaskUnreadStatus struct {
+ TaskID int64 `xorm:"bigint not null unique(task_user)" param:"projecttask"`
+ UserID int64 `xorm:"bigint not null unique(task_user)"`
+ web.CRUDable `xorm:"-" json:"-"`
+ web.Permissions `xorm:"-" json:"-"`
+}
+
+func (*TaskUnreadStatus) TableName() string {
+ return "task_unread_statuses"
+}
+
+func (t *TaskUnreadStatus) CanUpdate(_ *xorm.Session, _ web.Auth) (bool, error) {
+ return true, nil
+}
+
+// Update marks a task as read
+// @Summary Mark a task as read
+// @Description Marks a task as read for the current user by removing the unread status entry.
+// @tags task
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param projecttask path int true "Task ID"
+// @Success 200 {object} models.TaskUnreadStatus "The task unread status object."
+// @Failure 403 {object} web.HTTPError "The user does not have access to the task"
+// @Failure 500 {object} models.Message "Internal error"
+// @Router /tasks/{projecttask}/read [post]
+func (t *TaskUnreadStatus) Update(s *xorm.Session, a web.Auth) error {
+ return markTaskAsRead(s, t.TaskID, a)
+}
+
+func markTaskAsRead(s *xorm.Session, taskID int64, a web.Auth) error {
+ _, err := s.Where("task_id = ? AND user_id = ?", taskID, a.GetID()).
+ Delete(&TaskUnreadStatus{})
+
+ return err
+}
diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go
index e0532f088..87acdd318 100644
--- a/pkg/models/tasks.go
+++ b/pkg/models/tasks.go
@@ -106,6 +106,8 @@ type Task struct {
// True if a task is a favorite task. Favorite tasks show up in a separate "Important" project. This value depends on the user making the call to the api.
IsFavorite bool `xorm:"-" json:"is_favorite"`
+ IsUnread *bool `xorm:"-" json:"is_unread,omitempty"`
+
// The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it.
// Will only returned when retrieving one task.
Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
@@ -420,6 +422,29 @@ func (t *Task) setIdentifier(project *Project) {
t.Identifier = project.Identifier + "-" + strconv.FormatInt(t.Index, 10)
}
+func addIsUnreadToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Task, a web.Auth) (err error) {
+ if len(taskIDs) == 0 {
+ return nil
+ }
+
+ unreadStatuses := []*TaskUnreadStatus{}
+ err = s.In("task_id", taskIDs).
+ Where("user_id = ?", a.GetID()).
+ Find(&unreadStatuses)
+ if err != nil {
+ return err
+ }
+
+ b := true
+ for _, status := range unreadStatuses {
+ if task, exists := taskMap[status.TaskID]; exists {
+ task.IsUnread = &b
+ }
+ }
+
+ return nil
+}
+
// Get all assignees
func addAssigneesToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64]*Task) (err error) {
taskAssignees, err := getRawTaskAssigneesForTasks(s, taskIDs)
@@ -689,6 +714,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, vi
if err != nil {
return err
}
+ case TaskCollectionExpandIsUnread:
+ err = addIsUnreadToTasks(s, taskIDs, taskMap, a)
+ if err != nil {
+ return
+ }
}
expanded[expandable] = true
}
@@ -1747,6 +1777,12 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
return
}
+ // Delete all task unread statuses
+ _, err = s.Where("task_id = ?", t.ID).Delete(&TaskUnreadStatus{})
+ if err != nil {
+ return err
+ }
+
// Delete all relations
_, err = s.Where("task_id = ? OR other_task_id = ?", t.ID, t.ID).Delete(&TaskRelation{})
if err != nil {
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 831367b90..11b993cad 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -429,6 +429,14 @@ func registerAPIRoutes(a *echo.Group) {
a.DELETE("/tasks/:projecttask", taskHandler.DeleteWeb)
a.POST("/tasks/:projecttask", taskHandler.UpdateWeb)
+ taskUnreadStatusHandler := &handler.WebHandler{
+ EmptyStruct: func() handler.CObject {
+ return &models.TaskUnreadStatus{}
+ },
+ }
+
+ a.POST("/tasks/:projecttask/read", taskUnreadStatusHandler.UpdateWeb)
+
taskPositionHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.TaskPosition{}