mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 14:15:18 +00:00
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:
@@ -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())
|
||||
|
||||
@@ -347,7 +347,7 @@ const taskList = useTaskList(
|
||||
() => props.projectId,
|
||||
() => props.viewId,
|
||||
sortBy.value,
|
||||
() => 'comment_count',
|
||||
() => ['comment_count', 'is_unread'],
|
||||
)
|
||||
|
||||
const {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface ITask extends IAbstract {
|
||||
identifier: string
|
||||
index: number
|
||||
isFavorite: boolean
|
||||
isUnread?: boolean
|
||||
subscription: ISubscription
|
||||
|
||||
position: number
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')[],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
44
pkg/migration/20251118125156.go
Normal file
44
pkg/migration/20251118125156.go
Normal 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{})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func GetTables() []interface{} {
|
||||
&ProjectView{},
|
||||
&TaskPosition{},
|
||||
&TaskBucket{},
|
||||
&TaskUnreadStatus{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
60
pkg/models/task_unread_statuses.go
Normal file
60
pkg/models/task_unread_statuses.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user