Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/docs/plugins/comments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ import { CommentThread } from "@btst/stack/plugins/comments/client/components"
| `currentUserId` | `string` | — | Authenticated user ID — enables edit/delete/pending badge |
| `loginHref` | `string` | — | Login page URL shown to unauthenticated users |
| `pageSize` | `number` | — | Comments per page. Falls back to `defaultCommentPageSize` from overrides, then 100. A "Load more" button appears when there are additional pages. |
| `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. |
| `components.Input` | `ComponentType` | — | Custom input component (default: `<textarea>`) |
| `components.Renderer` | `ComponentType` | — | Custom renderer for comment body (default: `<p>`) |

Expand Down Expand Up @@ -528,6 +529,7 @@ Configure the comments plugin behavior from your layout:
| `currentUserId` | `string \| (() => string \| undefined \| Promise<string \| undefined>)` | Authenticated user's ID — used by the User Comments page. Supports async functions for session-based resolution. |
| `loginHref` | `string` | Login route used by comment UIs when user is unauthenticated. |
| `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. |
| `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). |
| `allowPosting` | `boolean` | Hide/show comment form and reply actions globally in `CommentThread` instances (defaults to `true`). |
| `allowEditing` | `boolean` | Hide/show edit affordances globally in `CommentThread` instances (defaults to `true`). |
| `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. |
Expand Down
10 changes: 6 additions & 4 deletions e2e/tests/smoke.comments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,17 @@ async function testLoadMoreComments(
const thread = page.locator('[data-testid="comment-thread"]');
await expect(thread).toBeVisible({ timeout: 8000 });

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

// Comments beyond the first page must NOT be visible yet
for (let i = pageSize + 1; i <= totalCount; i++) {
// Older comments (those that fall on subsequent pages) must NOT be visible yet
for (let i = totalCount - pageSize; i >= 1; i--) {
await expect(
page.getByText(`${bodyPrefix} ${i}`, { exact: true }),
).not.toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "2.11.8",
"version": "2.12.0",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/registry/btst-comments.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/stack/src/plugins/comments/api/query-key-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface CommentsThreadDiscriminator {
parentId: string | null | undefined;
status: string | undefined;
currentUserId: string | undefined;
sort: string | undefined;
limit: number;
}

Expand All @@ -80,6 +81,7 @@ export function commentsThreadDiscriminator(params?: {
parentId?: string | null;
status?: string;
currentUserId?: string;
sort?: string;
limit?: number;
}): CommentsThreadDiscriminator {
return {
Expand All @@ -88,6 +90,7 @@ export function commentsThreadDiscriminator(params?: {
parentId: params?.parentId,
status: params?.status,
currentUserId: params?.currentUserId,
sort: params?.sort,
limit: params?.limit ?? 20,
};
}
Expand Down Expand Up @@ -128,7 +131,7 @@ export const COMMENTS_QUERY_KEYS = {

/**
* Key for the infinite thread query (top-level comments, load-more).
* Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, limit }]
* Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, sort, limit }]
* Offset is excluded — it is driven by `pageParam`, not baked into the key.
*/
commentsThread: (params?: {
Expand All @@ -137,6 +140,7 @@ export const COMMENTS_QUERY_KEYS = {
parentId?: string | null;
status?: string;
currentUserId?: string;
sort?: string;
limit?: number;
}) =>
["commentsThread", "list", commentsThreadDiscriminator(params)] as const,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ export interface CommentThreadProps {
* Defaults to true.
*/
allowEditing?: boolean;
/**
* Sort direction for top-level comments by `createdAt`.
* - `"desc"` (default): newest first.
* - `"asc"`: oldest first.
*
* Replies inside each thread always render chronologically (oldest → newest)
* and are unaffected by this prop.
*
* Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.
*/
sort?: "asc" | "desc";
}

const DEFAULT_RENDERER: ComponentType<CommentRendererProps> = ({ body }) => (
Expand Down Expand Up @@ -325,6 +336,7 @@ function CommentThreadInner({
pageSize: pageSizeProp,
allowPosting: allowPostingProp,
allowEditing: allowEditingProp,
sort: sortProp,
}: CommentThreadProps) {
const overrides = usePluginOverrides<
CommentsPluginOverrides,
Expand All @@ -334,6 +346,7 @@ function CommentThreadInner({
pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;
const allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;
const allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;
const sort = sortProp ?? overrides.defaultCommentSort ?? "desc";
const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(
Expand All @@ -357,6 +370,7 @@ function CommentThreadInner({
status: "approved",
parentId: null,
currentUserId,
sort,
pageSize,
});

Expand All @@ -366,6 +380,7 @@ function CommentThreadInner({
currentUserId,
infiniteKey: threadQueryKey,
pageSize,
sort,
});

const handlePost = async (body: string) => {
Expand Down
29 changes: 26 additions & 3 deletions packages/stack/src/plugins/comments/client/hooks/use-comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ export function useInfiniteComments(
parentId?: string | null;
status?: "pending" | "approved" | "spam";
currentUserId?: string;
/**
* Sort direction by `createdAt`. Default: `"asc"` (oldest first) — matches
* the server-side default. Pass `"desc"` for newest-first threads.
*/
sort?: "asc" | "desc";
pageSize?: number;
},
options?: { enabled?: boolean },
Expand All @@ -178,6 +183,7 @@ export function useInfiniteComments(
parentId: params.parentId ?? null,
status: params.status,
currentUserId: params.currentUserId,
sort: params.sort,
limit: pageSize,
});

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

const isDesc = params.sort === "desc";
queryClient.setQueryData<InfiniteData<CommentListResult>>(
listKey,
(old) => {
Expand All @@ -366,16 +384,21 @@ export function usePostComment(
pageParams: [0],
};
}
const lastIdx = old.pages.length - 1;
// For asc (oldest-first) threads the new comment lives at the end →
// append to the last page. For desc (newest-first) threads it lives
// at the top → prepend to the first page.
const targetIdx = isDesc ? 0 : old.pages.length - 1;
return {
...old,
// Increment `total` on every page so the header count (which reads
// pages[0].total) stays in sync even after multiple pages are loaded.
pages: old.pages.map((page, idx) =>
idx === lastIdx
idx === targetIdx
? {
...page,
items: [...page.items, optimistic],
items: isDesc
? [optimistic, ...page.items]
: [...page.items, optimistic],
total: page.total + 1,
}
: { ...page, total: page.total + 1 },
Expand Down
10 changes: 10 additions & 0 deletions packages/stack/src/plugins/comments/client/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ export interface CommentsPluginOverrides {
*/
defaultCommentPageSize?: number;

/**
* Default sort direction (by `createdAt`) for top-level comments in
* `CommentThread`.
* - `"desc"` (default): newest comments first.
* - `"asc"`: oldest comments first.
*
* Can be overridden per-instance via the `sort` prop on `CommentThread`.
*/
defaultCommentSort?: "asc" | "desc";

/**
* When false, the comment form and reply buttons are hidden in all
* `CommentThread` instances. Users can still read existing comments.
Expand Down
2 changes: 2 additions & 0 deletions packages/stack/src/plugins/comments/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function createCommentsThreadQueries(
parentId?: string | null;
status?: "pending" | "approved" | "spam";
currentUserId?: string;
sort?: "asc" | "desc";
limit?: number;
}) => ({
// Offset is excluded from the key — it is driven by pageParam.
Expand All @@ -162,6 +163,7 @@ function createCommentsThreadQueries(
// The server resolves the caller's identity server-side via the
// resolveCurrentUserId hook. It is still included in the queryKey
// above for client-side cache segregation.
sort: params?.sort,
limit: params?.limit ?? 20,
offset: pageParam ?? 0,
},
Expand Down