From 6555595a5c59cf34a40cf8b013b42a0fa46b0ed0 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 19 Feb 2026 14:13:08 +0100 Subject: [PATCH] feat(comments): add sort order toggle for task comments Allow users to switch between oldest-first and newest-first comment ordering via a toggle button in the comments heading. The preference is persisted as a frontend setting (commentSortOrder) and passed to the API as the order_by query parameter so pagination works correctly. When all comments fit on a single page, toggling reverses them client-side to avoid an extra API call. New comments are inserted at the correct position based on the active sort order, and in newest-first mode the view scrolls to the top after adding a comment. Link-share users fall back to a local (non-persisted) sort order since they cannot save user settings. --- frontend/src/components/misc/Icon.ts | 4 + .../components/tasks/partials/Comments.vue | 82 +++++++++++++++++-- frontend/src/i18n/lang/en.json | 4 +- frontend/src/modelTypes/IUserSettings.ts | 1 + frontend/src/models/userSettings.ts | 1 + frontend/src/stores/auth.ts | 1 + 6 files changed, 86 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/misc/Icon.ts b/frontend/src/components/misc/Icon.ts index db333c0dd..8a5cab738 100644 --- a/frontend/src/components/misc/Icon.ts +++ b/frontend/src/components/misc/Icon.ts @@ -5,8 +5,10 @@ import { faAnglesUp, faArchive, faArrowLeft, + faArrowDownShortWide, faArrowUpFromBracket, faArrowUpRightFromSquare, + faArrowUpShortWide, faBold, faItalic, faStrikethrough, @@ -183,7 +185,9 @@ library.add(faTimesCircle) library.add(faTrashAlt) library.add(faUser) library.add(faUsers) +library.add(faArrowDownShortWide) library.add(faArrowUpFromBracket) +library.add(faArrowUpShortWide) library.add(faX) library.add(faAnglesUp) library.add(faBolt) diff --git a/frontend/src/components/tasks/partials/Comments.vue b/frontend/src/components/tasks/partials/Comments.vue index 2d25e0df4..336092f34 100644 --- a/frontend/src/components/tasks/partials/Comments.vue +++ b/frontend/src/components/tasks/partials/Comments.vue @@ -6,12 +6,23 @@ >

- - + + + + + {{ $t('task.comment.title') }} - {{ $t('task.comment.title') }} + + + {{ commentSortOrder === 'asc' ? $t('task.comment.sortOldestFirst') : $t('task.comment.sortNewestFirst') }} +

(null) +const commentSortOrder = computed(() => localSortOrder.value ?? authStore.settings.frontendSettings.commentSortOrder ?? 'asc') + const comments = ref([]) const showDeleteModal = ref(false) @@ -338,14 +353,14 @@ async function loadComments(taskId: ITask['id']) { commentEdit.taskId = taskId commentToDelete.taskId = taskId - if (typeof props.initialComments !== 'undefined' && currentPage.value === 1) { + if (commentSortOrder.value === 'asc' && typeof props.initialComments !== 'undefined' && currentPage.value === 1) { if (props.initialComments.length < configStore.maxItemsPerPage) { comments.value = props.initialComments return } } - comments.value = await taskCommentService.getAll({taskId}, {}, currentPage.value) + comments.value = await taskCommentService.getAll({taskId}, {order_by: commentSortOrder.value}, currentPage.value) } async function changePage(page: number) { @@ -354,6 +369,30 @@ async function changePage(page: number) { await loadComments(props.taskId) } +async function toggleSortOrder() { + const newOrder = commentSortOrder.value === 'asc' ? 'desc' : 'asc' + if (!authStore.isLinkShareAuth) { + await authStore.saveUserSettings({ + settings: { + ...authStore.settings, + frontendSettings: { + ...authStore.settings.frontendSettings, + commentSortOrder: newOrder, + }, + }, + showMessage: false, + }) + } else { + localSortOrder.value = newOrder + } + if (taskCommentService.totalPages > 1) { + currentPage.value = 1 + await loadComments(props.taskId) + } else { + comments.value.reverse() + } +} + watch( () => [props.taskId, props.initialComments], () => { @@ -378,12 +417,24 @@ async function addComment() { newComment.taskId = props.taskId newComment.comment = newCommentText.value const comment = await taskCommentService.create(newComment) - comments.value.push(comment) + + if (commentSortOrder.value === 'desc' && currentPage.value > 1) { + currentPage.value = 1 + await loadComments(props.taskId) + } else if (commentSortOrder.value === 'desc') { + comments.value.unshift(comment) + } else { + comments.value.push(comment) + } newCommentText.value = '' // Ensure draft is cleared from localStorage clearEditorDraft(commentStorageKey.value) + if (commentSortOrder.value === 'desc') { + commentsRef.value?.scrollIntoView({behavior: 'smooth', block: 'start', inline: 'nearest'}) + } + success({message: t('task.comment.addedSuccess')}) } finally { creating.value = false @@ -510,6 +561,25 @@ function getCommentUrl(commentId: string) { inline-size: calc(100% - 48px - 2rem); } +.comments-heading { + display: flex; + align-items: center; + justify-content: space-between; +} + +.comment-sort-button { + font-size: .75rem; + font-weight: normal; + color: var(--grey-500); + display: inline-flex; + align-items: center; + gap: .25rem; + + &:hover { + color: var(--grey-700); + } +} + .comments-container { scroll-margin-block-start: 4rem; } diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index b8390c31f..93d63561c 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -928,7 +928,9 @@ "deleteText1": "Are you sure you want to delete this comment?", "deleteSuccess": "The comment was deleted successfully.", "addedSuccess": "The comment was added successfully.", - "permalink": "Copy permalink to this comment" + "permalink": "Copy permalink to this comment", + "sortNewestFirst": "Newest first", + "sortOldestFirst": "Oldest first" }, "mention": { "noUsersFound": "No users found" diff --git a/frontend/src/modelTypes/IUserSettings.ts b/frontend/src/modelTypes/IUserSettings.ts index d66412a57..8edbd438f 100644 --- a/frontend/src/modelTypes/IUserSettings.ts +++ b/frontend/src/modelTypes/IUserSettings.ts @@ -24,6 +24,7 @@ export interface IFrontendSettings { backgroundBrightness: number | null alwaysShowBucketTaskCount: boolean sidebarWidth: number | null + commentSortOrder: 'asc' | 'desc' } export interface IExtraSettingsLink { diff --git a/frontend/src/models/userSettings.ts b/frontend/src/models/userSettings.ts index d79d0b852..7b61a50ed 100644 --- a/frontend/src/models/userSettings.ts +++ b/frontend/src/models/userSettings.ts @@ -32,6 +32,7 @@ export default class UserSettingsModel extends AbstractModel impl defaultTaskRelationType: RELATION_KIND.RELATED, alwaysShowBucketTaskCount: false, sidebarWidth: null, + commentSortOrder: 'asc', } extraSettingsLinks = {} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 21c3b9f63..a947b2ab1 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -139,6 +139,7 @@ export const useAuthStore = defineStore('auth', () => { defaultTaskRelationType: RELATION_KIND.RELATED, backgroundBrightness: 100, sidebarWidth: null, + commentSortOrder: 'asc', ...newSettings.frontendSettings, }, })