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}" > {{ task.commentCount }} + @@ -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{}