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.
This commit is contained in:
kolaente 2026-02-19 14:13:08 +01:00
parent c12bbbe93e
commit 6555595a5c
6 changed files with 86 additions and 7 deletions

View File

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

View File

@ -6,12 +6,23 @@
>
<h3
v-if="canWrite || comments.length > 0"
class="comments-heading"
:class="{'d-print-none': comments.length === 0}"
>
<span class="icon is-grey">
<Icon :icon="['far', 'comments']" />
<span>
<span class="icon is-grey">
<Icon :icon="['far', 'comments']" />
</span>
{{ $t('task.comment.title') }}
</span>
{{ $t('task.comment.title') }}
<BaseButton
v-if="comments.length > 0"
class="comment-sort-button"
@click="toggleSortOrder"
>
<Icon :icon="commentSortOrder === 'asc' ? 'arrow-down-short-wide' : 'arrow-up-short-wide'" />
{{ commentSortOrder === 'asc' ? $t('task.comment.sortOldestFirst') : $t('task.comment.sortNewestFirst') }}
</BaseButton>
</h3>
<div class="comments">
<span
@ -214,6 +225,7 @@
import {ref, reactive, computed, shallowReactive, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import Editor from '@/components/input/AsyncEditor'
import PaginationEmit from '@/components/misc/PaginationEmit.vue'
@ -250,6 +262,9 @@ const {t} = useI18n({useScope: 'global'})
const configStore = useConfigStore()
const authStore = useAuthStore()
const localSortOrder = ref<'asc' | 'desc' | null>(null)
const commentSortOrder = computed(() => localSortOrder.value ?? authStore.settings.frontendSettings.commentSortOrder ?? 'asc')
const comments = ref<ITaskComment[]>([])
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;
}

View File

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

View File

@ -24,6 +24,7 @@ export interface IFrontendSettings {
backgroundBrightness: number | null
alwaysShowBucketTaskCount: boolean
sidebarWidth: number | null
commentSortOrder: 'asc' | 'desc'
}
export interface IExtraSettingsLink {

View File

@ -32,6 +32,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
defaultTaskRelationType: RELATION_KIND.RELATED,
alwaysShowBucketTaskCount: false,
sidebarWidth: null,
commentSortOrder: 'asc',
}
extraSettingsLinks = {}

View File

@ -139,6 +139,7 @@ export const useAuthStore = defineStore('auth', () => {
defaultTaskRelationType: RELATION_KIND.RELATED,
backgroundBrightness: 100,
sidebarWidth: null,
commentSortOrder: 'asc',
...newSettings.frontendSettings,
},
})