feat: task unread tracking (#1857)

---------

Co-authored-by: Mithilesh Gupta <guptamithilesh@protonmail.com>
Co-authored-by: kolaente <k@knt.li>
This commit is contained in:
Mithilesh Gupta
2025-11-27 19:44:42 +05:30
committed by GitHub
parent 2976d6f676
commit 7dddc5dfa2
18 changed files with 328 additions and 9 deletions

View File

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

View File

@@ -347,7 +347,7 @@ const taskList = useTaskList(
() => props.projectId,
() => props.viewId,
sortBy.value,
() => 'comment_count',
() => ['comment_count', 'is_unread'],
)
const {

View File

@@ -3,9 +3,14 @@
v-if="task.commentCount && task.commentCount > 0"
v-tooltip="tooltip"
class="comment-count"
:class="{'is-unread': task.isUnread}"
>
<Icon :icon="['far', 'comments']" />
<span class="comment-count-badge">{{ task.commentCount }}</span>
<span
v-if="task.isUnread"
class="unread-indicator"
/>
</span>
</template>
@@ -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;
}
}
}
</style>

View File

@@ -43,6 +43,7 @@ export interface ITask extends IAbstract {
identifier: string
index: number
isFavorite: boolean
isUnread?: boolean
subscription: ISubscription
position: number

View File

@@ -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<ITask> {
return transformed as ITask
}
async markTaskAsRead(taskId: ITask['id']): Promise<void> {
const cancel = this.setLoading()
try {
await AuthenticatedHTTPFactory().post(`/tasks/${taskId}/read`, {} as ITask)
} finally {
cancel()
}
}
}

View File

@@ -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')[],

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
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{})
},
})
}

View File

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

View File

@@ -65,6 +65,7 @@ func GetTables() []interface{} {
&ProjectView{},
&TaskPosition{},
&TaskBucket{},
&TaskUnreadStatus{},
}
}

View File

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

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

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

View File

@@ -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{}