Skip to content

Commit 2c22945

Browse files
authored
Merge pull request #122 from better-stack-ai/fix/comments-order
feat: add sort functionality for top-level comments
2 parents 28e6673 + 9eda52a commit 2c22945

9 files changed

Lines changed: 69 additions & 11 deletions

File tree

docs/content/docs/plugins/comments.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ import { CommentThread } from "@btst/stack/plugins/comments/client/components"
274274
| `currentUserId` | `string` || Authenticated user ID — enables edit/delete/pending badge |
275275
| `loginHref` | `string` || Login page URL shown to unauthenticated users |
276276
| `pageSize` | `number` || Comments per page. Falls back to `defaultCommentPageSize` from overrides, then 100. A "Load more" button appears when there are additional pages. |
277+
| `sort` | `"asc" \| "desc"` || Sort direction for top-level comments by `createdAt`. Defaults to `defaultCommentSort` from overrides, then `"desc"` (newest first). Replies inside each thread always render chronologically and are unaffected. |
277278
| `components.Input` | `ComponentType` || Custom input component (default: `<textarea>`) |
278279
| `components.Renderer` | `ComponentType` || Custom renderer for comment body (default: `<p>`) |
279280

@@ -528,6 +529,7 @@ Configure the comments plugin behavior from your layout:
528529
| `currentUserId` | `string \| (() => string \| undefined \| Promise<string \| undefined>)` | Authenticated user's ID — used by the User Comments page. Supports async functions for session-based resolution. |
529530
| `loginHref` | `string` | Login route used by comment UIs when user is unauthenticated. |
530531
| `defaultCommentPageSize` | `number` | Default number of top-level comments per page for all `CommentThread` instances. Overridden per-instance by the `pageSize` prop. Defaults to `100` when not set. |
532+
| `defaultCommentSort` | `"asc" \| "desc"` | Default sort direction for top-level comments in all `CommentThread` instances. Overridden per-instance by the `sort` prop. Defaults to `"desc"` (newest first). |
531533
| `allowPosting` | `boolean` | Hide/show comment form and reply actions globally in `CommentThread` instances (defaults to `true`). |
532534
| `allowEditing` | `boolean` | Hide/show edit affordances globally in `CommentThread` instances (defaults to `true`). |
533535
| `resourceLinks` | `Record<string, (id: string) => string>` | Per-resource-type URL builders for linking comments back to their resource on the User Comments page (e.g. `{ "blog-post": (slug) => "/pages/blog/" + slug }`). The plugin appends `#comments` automatically so the page scrolls to the thread. |

e2e/tests/smoke.comments.spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,17 @@ async function testLoadMoreComments(
7777
const thread = page.locator('[data-testid="comment-thread"]');
7878
await expect(thread).toBeVisible({ timeout: 8000 });
7979

80-
// First page of comments should be visible (comments are asc-sorted by date)
81-
for (let i = 1; i <= pageSize; i++) {
80+
// First page of comments should be visible. CommentThread defaults to
81+
// `sort: "desc"` (newest first), and comments are created sequentially
82+
// 1..totalCount, so the highest-numbered comments appear on page 1.
83+
for (let i = totalCount; i > totalCount - pageSize; i--) {
8284
await expect(
8385
page.getByText(`${bodyPrefix} ${i}`, { exact: true }),
8486
).toBeVisible({ timeout: 5000 });
8587
}
8688

87-
// Comments beyond the first page must NOT be visible yet
88-
for (let i = pageSize + 1; i <= totalCount; i++) {
89+
// Older comments (those that fall on subsequent pages) must NOT be visible yet
90+
for (let i = totalCount - pageSize; i >= 1; i--) {
8991
await expect(
9092
page.getByText(`${bodyPrefix} ${i}`, { exact: true }),
9193
).not.toBeVisible();

packages/stack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/stack",
3-
"version": "2.11.8",
3+
"version": "2.12.0",
44
"description": "A composable, plugin-based library for building full-stack applications.",
55
"repository": {
66
"type": "git",

packages/stack/registry/btst-comments.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

packages/stack/src/plugins/comments/api/query-key-defs.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface CommentsThreadDiscriminator {
7171
parentId: string | null | undefined;
7272
status: string | undefined;
7373
currentUserId: string | undefined;
74+
sort: string | undefined;
7475
limit: number;
7576
}
7677

@@ -80,6 +81,7 @@ export function commentsThreadDiscriminator(params?: {
8081
parentId?: string | null;
8182
status?: string;
8283
currentUserId?: string;
84+
sort?: string;
8385
limit?: number;
8486
}): CommentsThreadDiscriminator {
8587
return {
@@ -88,6 +90,7 @@ export function commentsThreadDiscriminator(params?: {
8890
parentId: params?.parentId,
8991
status: params?.status,
9092
currentUserId: params?.currentUserId,
93+
sort: params?.sort,
9194
limit: params?.limit ?? 20,
9295
};
9396
}
@@ -128,7 +131,7 @@ export const COMMENTS_QUERY_KEYS = {
128131

129132
/**
130133
* Key for the infinite thread query (top-level comments, load-more).
131-
* Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, limit }]
134+
* Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, sort, limit }]
132135
* Offset is excluded — it is driven by `pageParam`, not baked into the key.
133136
*/
134137
commentsThread: (params?: {
@@ -137,6 +140,7 @@ export const COMMENTS_QUERY_KEYS = {
137140
parentId?: string | null;
138141
status?: string;
139142
currentUserId?: string;
143+
sort?: string;
140144
limit?: number;
141145
}) =>
142146
["commentsThread", "list", commentsThreadDiscriminator(params)] as const,

packages/stack/src/plugins/comments/client/components/comment-thread.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ export interface CommentThreadProps {
9898
* Defaults to true.
9999
*/
100100
allowEditing?: boolean;
101+
/**
102+
* Sort direction for top-level comments by `createdAt`.
103+
* - `"desc"` (default): newest first.
104+
* - `"asc"`: oldest first.
105+
*
106+
* Replies inside each thread always render chronologically (oldest → newest)
107+
* and are unaffected by this prop.
108+
*
109+
* Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.
110+
*/
111+
sort?: "asc" | "desc";
101112
}
102113

103114
const DEFAULT_RENDERER: ComponentType<CommentRendererProps> = ({ body }) => (
@@ -325,6 +336,7 @@ function CommentThreadInner({
325336
pageSize: pageSizeProp,
326337
allowPosting: allowPostingProp,
327338
allowEditing: allowEditingProp,
339+
sort: sortProp,
328340
}: CommentThreadProps) {
329341
const overrides = usePluginOverrides<
330342
CommentsPluginOverrides,
@@ -334,6 +346,7 @@ function CommentThreadInner({
334346
pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;
335347
const allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;
336348
const allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;
349+
const sort = sortProp ?? overrides.defaultCommentSort ?? "desc";
337350
const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };
338351
const [replyingTo, setReplyingTo] = useState<string | null>(null);
339352
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(
@@ -357,6 +370,7 @@ function CommentThreadInner({
357370
status: "approved",
358371
parentId: null,
359372
currentUserId,
373+
sort,
360374
pageSize,
361375
});
362376

@@ -366,6 +380,7 @@ function CommentThreadInner({
366380
currentUserId,
367381
infiniteKey: threadQueryKey,
368382
pageSize,
383+
sort,
369384
});
370385

371386
const handlePost = async (body: string) => {

packages/stack/src/plugins/comments/client/hooks/use-comments.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ export function useInfiniteComments(
164164
parentId?: string | null;
165165
status?: "pending" | "approved" | "spam";
166166
currentUserId?: string;
167+
/**
168+
* Sort direction by `createdAt`. Default: `"asc"` (oldest first) — matches
169+
* the server-side default. Pass `"desc"` for newest-first threads.
170+
*/
171+
sort?: "asc" | "desc";
167172
pageSize?: number;
168173
},
169174
options?: { enabled?: boolean },
@@ -178,6 +183,7 @@ export function useInfiniteComments(
178183
parentId: params.parentId ?? null,
179184
status: params.status,
180185
currentUserId: params.currentUserId,
186+
sort: params.sort,
181187
limit: pageSize,
182188
});
183189

@@ -266,6 +272,17 @@ export function usePostComment(
266272
* `nextOffset` from `lastPage.limit` instead of a hardcoded fallback.
267273
*/
268274
pageSize?: number;
275+
/**
276+
* Sort direction of the surrounding infinite thread.
277+
* - `"asc"` (default): newest comments belong on the LAST page → optimistic
278+
* item is appended to `pages[last].items`.
279+
* - `"desc"`: newest comments belong on the FIRST page → optimistic item
280+
* is prepended to `pages[0].items`.
281+
*
282+
* Must match the `sort` passed to `useInfiniteComments` so the optimistic
283+
* item appears in the same position the server will place it.
284+
*/
285+
sort?: "asc" | "desc";
269286
},
270287
) {
271288
const queryClient = useQueryClient();
@@ -350,6 +367,7 @@ export function usePostComment(
350367
const previous =
351368
queryClient.getQueryData<InfiniteData<CommentListResult>>(listKey);
352369

370+
const isDesc = params.sort === "desc";
353371
queryClient.setQueryData<InfiniteData<CommentListResult>>(
354372
listKey,
355373
(old) => {
@@ -366,16 +384,21 @@ export function usePostComment(
366384
pageParams: [0],
367385
};
368386
}
369-
const lastIdx = old.pages.length - 1;
387+
// For asc (oldest-first) threads the new comment lives at the end →
388+
// append to the last page. For desc (newest-first) threads it lives
389+
// at the top → prepend to the first page.
390+
const targetIdx = isDesc ? 0 : old.pages.length - 1;
370391
return {
371392
...old,
372393
// Increment `total` on every page so the header count (which reads
373394
// pages[0].total) stays in sync even after multiple pages are loaded.
374395
pages: old.pages.map((page, idx) =>
375-
idx === lastIdx
396+
idx === targetIdx
376397
? {
377398
...page,
378-
items: [...page.items, optimistic],
399+
items: isDesc
400+
? [optimistic, ...page.items]
401+
: [...page.items, optimistic],
379402
total: page.total + 1,
380403
}
381404
: { ...page, total: page.total + 1 },

packages/stack/src/plugins/comments/client/overrides.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ export interface CommentsPluginOverrides {
7575
*/
7676
defaultCommentPageSize?: number;
7777

78+
/**
79+
* Default sort direction (by `createdAt`) for top-level comments in
80+
* `CommentThread`.
81+
* - `"desc"` (default): newest comments first.
82+
* - `"asc"`: oldest comments first.
83+
*
84+
* Can be overridden per-instance via the `sort` prop on `CommentThread`.
85+
*/
86+
defaultCommentSort?: "asc" | "desc";
87+
7888
/**
7989
* When false, the comment form and reply buttons are hidden in all
8090
* `CommentThread` instances. Users can still read existing comments.

packages/stack/src/plugins/comments/query-keys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ function createCommentsThreadQueries(
142142
parentId?: string | null;
143143
status?: "pending" | "approved" | "spam";
144144
currentUserId?: string;
145+
sort?: "asc" | "desc";
145146
limit?: number;
146147
}) => ({
147148
// Offset is excluded from the key — it is driven by pageParam.
@@ -162,6 +163,7 @@ function createCommentsThreadQueries(
162163
// The server resolves the caller's identity server-side via the
163164
// resolveCurrentUserId hook. It is still included in the queryKey
164165
// above for client-side cache segregation.
166+
sort: params?.sort,
165167
limit: params?.limit ?? 20,
166168
offset: pageParam ?? 0,
167169
},

0 commit comments

Comments
 (0)