From 3b2cf0eb4256f060c970260a8fdf7ca0156663a4 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:55:26 +0000 Subject: [PATCH 1/2] feat: migrate consumers to provider-only setup --- docs/content/docs/plugins/comments.mdx | 70 +++++----- e2e/tests/smoke.comments.spec.ts | 4 +- .../templates/nextjs/form-demo-page.tsx.hbs | 18 +-- .../src/templates/nextjs/pages-layout.tsx.hbs | 19 ++- .../templates/nextjs/preview-client.tsx.hbs | 12 +- .../templates/nextjs/public-chat-page.tsx.hbs | 8 +- .../react-router/form-demo-route.tsx.hbs | 19 +-- .../react-router/pages-layout.tsx.hbs | 24 ++-- .../react-router/preview-route.tsx.hbs | 15 +-- .../react-router/public-chat-route.tsx.hbs | 8 +- .../tanstack/form-demo-route.tsx.hbs | 19 +-- .../templates/tanstack/pages-layout.tsx.hbs | 21 ++- .../templates/tanstack/preview-route.tsx.hbs | 15 +-- .../tanstack/public-chat-route.tsx.hbs | 8 +- .../src/utils/__tests__/scaffold-plan.test.ts | 121 ++++++++++++++++-- packages/cli/src/utils/scaffold-plan.ts | 52 +------- packages/stack/registry/btst-blog.json | 6 + packages/stack/registry/btst-cms.json | 12 ++ packages/stack/registry/btst-comments.json | 18 ++- .../stack/registry/btst-form-builder.json | 12 ++ packages/stack/registry/btst-ui-builder.json | 12 ++ packages/stack/scripts/build-registry.ts | 11 ++ packages/stack/scripts/test-registry.sh | 31 ++++- packages/stack/src/context/provider.tsx | 16 ++- .../comments/__tests__/client-sweep.test.tsx | 121 ++++++++++++++++++ .../client/components/comment-thread.tsx | 47 +++++-- .../src/plugins/comments/client/overrides.ts | 6 +- .../src/plugins/comments/client/utils.ts | 19 ++- .../codegen/files/nextjs/app/pages/layout.tsx | 27 ++-- .../react-router/app/routes/pages/_layout.tsx | 26 ++-- .../files/tanstack/src/routes/pages/route.tsx | 26 ++-- 31 files changed, 514 insertions(+), 309 deletions(-) diff --git a/docs/content/docs/plugins/comments.mdx b/docs/content/docs/plugins/comments.mdx index 58eaff82..ce9d4374 100644 --- a/docs/content/docs/plugins/comments.mdx +++ b/docs/content/docs/plugins/comments.mdx @@ -243,6 +243,7 @@ overrides={{ ## Embedding Comments The `CommentThread` component can be embedded anywhere — below a blog post, inside a Kanban task dialog, or on a custom page. +It uses the `api` and `auth` values from the nearest `StackProvider`. ```tsx import { CommentThread } from "@btst/stack/plugins/comments/client/components" @@ -250,10 +251,6 @@ import { CommentThread } from "@btst/stack/plugins/comments/client/components" `) | @@ -291,10 +288,6 @@ overrides={{ ), } @@ -314,10 +307,6 @@ overrides={{ ), } @@ -451,32 +440,37 @@ The comments plugin registers a `/comments` route that shows the current user's - Prev / Next pagination (20 per page) - Resource link column — click through to the original resource when `resourceLinks` is configured (links automatically include `#comments` so the page scrolls to the comment thread) - Delete button with confirmation dialog — calls `DELETE /comments/:id` (governed by `onBeforeDelete`) -- Login prompt when `currentUserId` is not configured +- Login prompt when the top-level auth provider resolves no identity ### Setup -Configure the overrides in your layout and the security hook in your backend: +Configure top-level API/auth once, keep only comments-specific overrides in +your layout, and configure the security hook in your backend: ```tsx title="app/pages/layout.tsx" -overrides={{ - comments: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - - // Provide the current user's ID so the page can scope the query - currentUserId: session?.user?.id, + (await getSession())?.user ?? null, + loginPath: "/login", + }} + overrides={{ + comments: { - // Map resource types to URLs so comments link back to their resource - resourceLinks: { - "blog-post": (slug) => `/pages/blog/${slug}`, - "kanban-task": (id) => `/pages/kanban?task=${id}`, - }, + // Map resource types to URLs so comments link back to their resource + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + "kanban-task": (id) => `/pages/kanban?task=${id}`, + }, - onBeforeUserCommentsPageRendered: (context) => { - if (!session?.user) throw new Error("Authentication required") + onBeforeUserCommentsPageRendered: (context) => { + if (!session?.user) throw new Error("Authentication required") + }, }, - } -}} + }} +> + {children} + ``` ```ts title="lib/stack.ts" @@ -522,12 +516,12 @@ Configure the comments plugin behavior from your layout: | Field | Type | Description | |-------|------|-------------| | `localization` | `Partial` | Override any UI string in the plugin. Import `COMMENTS_LOCALIZATION` from `@btst/stack/plugins/comments/client` to see all available keys. | -| `apiBaseURL` | `string` | Base URL for API requests | -| `apiBasePath` | `string` | Path prefix for the API | +| `apiBaseURL` | `string` | Legacy per-plugin base URL override; prefer top-level `StackProvider.api`. | +| `apiBasePath` | `string` | Legacy per-plugin API path override; prefer top-level `StackProvider.api`. | | `headers` | `Record` | Optional headers for authenticated plugin API calls. | | `showAttribution` | `boolean` | Show/hide the "Powered by BTST" attribution on plugin pages (defaults to `true`). | -| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | 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. | +| `currentUserId` | `string \| (() => string \| undefined \| Promise)` | Legacy identity override. When omitted, comments use the top-level auth provider identity. | +| `loginHref` | `string` | Legacy login route override. When omitted, comments use `StackProvider.auth.loginPath`. | | `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`). | diff --git a/e2e/tests/smoke.comments.spec.ts b/e2e/tests/smoke.comments.spec.ts index 21be9c1c..9f5f8c0c 100644 --- a/e2e/tests/smoke.comments.spec.ts +++ b/e2e/tests/smoke.comments.spec.ts @@ -690,8 +690,8 @@ test.describe("Own pending comments — visible after refresh (server-side fix)" // ─── My Comments Page ──────────────────────────────────────────────────────── // // The example app's onBeforePost returns authorId "olliethedev" for every POST, -// and the layout wires currentUserId: "olliethedev". All tests in this block -// rely on that fixture so they can verify comments appear on the my-comments page. +// and its top-level client auth provider resolves the same identity. All tests +// in this block rely on that fixture to verify the my-comments page. test.describe("My Comments Page", () => { const AUTHOR_ID = "olliethedev"; diff --git a/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs b/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs index bac77322..8b203eb3 100644 --- a/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs +++ b/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs @@ -1,10 +1,10 @@ "use client" import { useState } from "react" -import { useParams, useRouter } from "next/navigation" -import Link from "next/link" +import { useParams } from "next/navigation" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" @@ -25,7 +25,6 @@ type PluginOverrides = { */ export default function FormDemoPage() { const params = useParams() - const router = useRouter() const slug = params.slug as string const [queryClient] = useState(() => getOrCreateQueryClient()) const baseURL = getBaseURL() @@ -34,17 +33,8 @@ export default function FormDemoPage() { basePath="" - overrides={ - { - "form-builder": { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), - refresh: () => router.refresh(), - Link: ({ href, ...props }) => , - }, - } - } + router={nextRouter()} + api={{{providerApiLiteral}}} >
diff --git a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs index 87eeb8d8..1be87801 100644 --- a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs @@ -1,14 +1,12 @@ "use client" -{{#if pagesLayoutOverrides}} import { StackProvider } from "@btst/stack/context" -{{/if}} +import { nextRouter } from "@btst/stack/next" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" {{/if}} import { QueryClientProvider } from "@tanstack/react-query" -{{#if pagesLayoutOverrides}} -import Link from "next/link" +{{#if hasBetterAuthUi}} import { useRouter{{#if hasAiChat}}, usePathname{{/if}} } from "next/navigation" {{else}} {{#if hasAiChat}} @@ -17,7 +15,6 @@ import { usePathname } from "next/navigation" {{/if}} import { getOrCreateQueryClient } from "{{alias}}lib/query-client" -{{#if pagesLayoutOverrides}} function getBaseURL() { if (typeof window !== "undefined") { return window.location.origin @@ -29,14 +26,13 @@ function getBaseURL() { return "http://localhost:3000" } -{{/if}} export default function BtstPagesLayout({ children, }: { children: React.ReactNode }) { -{{#if pagesLayoutOverrides}} +{{#if hasBetterAuthUi}} const router = useRouter() {{/if}} const queryClient = getOrCreateQueryClient() @@ -45,17 +41,21 @@ export default function BtstPagesLayout({ const pathname = usePathname() const showChatWidget = !pathname.startsWith("/pages/chat") {{/if}} -{{#if pagesLayoutOverrides}} const baseURL = getBaseURL() + return ( {{#if hasAiChat}} {!hasApiKey && ( @@ -83,7 +83,4 @@ export default function BtstPagesLayout({ ) -{{else}} - return {children} -{{/if}} } diff --git a/packages/cli/src/templates/nextjs/preview-client.tsx.hbs b/packages/cli/src/templates/nextjs/preview-client.tsx.hbs index 3c1f0733..ea52445f 100644 --- a/packages/cli/src/templates/nextjs/preview-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/preview-client.tsx.hbs @@ -1,10 +1,10 @@ "use client" import { useState } from "react" -import { useRouter } from "next/navigation" import Link from "next/link" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { PageRenderer, @@ -31,22 +31,18 @@ interface PreviewPageClientProps { */ export default function PreviewPageClient({ slug }: PreviewPageClientProps) { const [queryClient] = useState(() => getOrCreateQueryClient()) - const router = useRouter() const baseURL = getBaseURL() return ( basePath="/preview" + router={nextRouter()} + api={{{providerApiLiteral}}} overrides={ - { + { "ui-builder": { - apiBaseURL: baseURL, - apiBasePath: "/api/data", componentRegistry: defaultComponentRegistry, - navigate: (path) => router.push(path), - refresh: () => router.refresh(), - Link: ({ href, ...props }) => , }, } } diff --git a/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs b/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs index 6a0688d8..000df835 100644 --- a/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs +++ b/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs @@ -2,6 +2,7 @@ import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" import { QueryClientProvider } from "@tanstack/react-query" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" @@ -27,13 +28,12 @@ export default function PublicChatPage() { basePath="" + router={nextRouter()} + api={{{providerApiLiteral}}} overrides={ - { + { "ai-chat": { mode: "public", - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: () => {}, }, } } diff --git a/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs b/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs index 851dd6d5..d0a88b55 100644 --- a/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs @@ -1,7 +1,8 @@ import { useState } from "react" -import { Link, useNavigate, useParams } from "react-router" +import { useParams } from "react-router" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" +import { reactRouter } from "@btst/stack/react-router" import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" @@ -25,7 +26,6 @@ type PluginOverrides = { */ export default function FormDemoPage() { const { slug } = useParams() - const navigate = useNavigate() const [queryClient] = useState(() => getOrCreateQueryClient()) const baseURL = getBaseURL() @@ -33,19 +33,8 @@ export default function FormDemoPage() { basePath="" - overrides={ - { - "form-builder": { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => navigate(path), - refresh: () => window.location.reload(), - Link: ({ href, to, ...props }) => ( - - ), - }, - } - } + router={reactRouter()} + api={{{providerApiLiteral}}} >
diff --git a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs index 7b86d47e..7a247094 100644 --- a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs @@ -1,18 +1,12 @@ -{{#if pagesLayoutOverrides}} import { StackProvider } from "@btst/stack/context" -{{/if}} +import { reactRouter } from "@btst/stack/react-router" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" {{/if}} import { QueryClientProvider } from "@tanstack/react-query" -{{#if pagesLayoutOverrides}} -import { Link as RouterLink, Outlet, useNavigate{{#if hasAiChat}}, useLocation{{/if}} } from "react-router" -{{else}} -import { Outlet{{#if hasAiChat}}, useLocation{{/if}} } from "react-router" -{{/if}} +import { Outlet{{#if hasBetterAuthUi}}, useNavigate{{/if}}{{#if hasAiChat}}, useLocation{{/if}} } from "react-router" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" -{{#if pagesLayoutOverrides}} function getBaseURL() { if (typeof window !== "undefined") { return window.location.origin @@ -24,10 +18,9 @@ function getBaseURL() { return "http://localhost:5173" } -{{/if}} export default function BtstPagesLayout() { -{{#if pagesLayoutOverrides}} +{{#if hasBetterAuthUi}} const navigate = useNavigate() {{/if}} const queryClient = getOrCreateQueryClient() @@ -36,17 +29,21 @@ export default function BtstPagesLayout() { const location = useLocation() const showChatWidget = !location.pathname.startsWith("/pages/chat") {{/if}} -{{#if pagesLayoutOverrides}} const baseURL = getBaseURL() + return ( {{#if hasAiChat}} {!hasApiKey && ( @@ -55,8 +52,6 @@ export default function BtstPagesLayout() { .env to enable AI chat.
)} -{{/if}} -{{#if hasAiChat}} {showChatWidget && (
) -{{else}} - return -{{/if}} } diff --git a/packages/cli/src/templates/react-router/preview-route.tsx.hbs b/packages/cli/src/templates/react-router/preview-route.tsx.hbs index 54162d8d..f9ef33c5 100644 --- a/packages/cli/src/templates/react-router/preview-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/preview-route.tsx.hbs @@ -1,7 +1,8 @@ import { useState } from "react" -import { Link, useNavigate, useParams } from "react-router" +import { Link, useParams } from "react-router" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" +import { reactRouter } from "@btst/stack/react-router" import { PageRenderer, defaultComponentRegistry, @@ -27,7 +28,6 @@ type PluginOverrides = { */ export default function PreviewPage() { const { slug = "" } = useParams() - const navigate = useNavigate() const [queryClient] = useState(() => getOrCreateQueryClient()) const baseURL = getBaseURL() @@ -35,17 +35,12 @@ export default function PreviewPage() { basePath="/preview" + router={reactRouter()} + api={{{providerApiLiteral}}} overrides={ - { + { "ui-builder": { - apiBaseURL: baseURL, - apiBasePath: "/api/data", componentRegistry: defaultComponentRegistry, - navigate: (path) => navigate(path), - refresh: () => window.location.reload(), - Link: ({ href, to, ...props }) => ( - - ), }, } } diff --git a/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs b/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs index d96637ae..d26521dd 100644 --- a/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs @@ -2,6 +2,7 @@ import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import { StackProvider } from "@btst/stack/context" +import { reactRouter } from "@btst/stack/react-router" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" import { QueryClientProvider } from "@tanstack/react-query" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" @@ -29,13 +30,12 @@ export default function PublicChatPage() { basePath="" + router={reactRouter()} + api={{{providerApiLiteral}}} overrides={ - { + { "ai-chat": { mode: "public", - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: () => {}, }, } } diff --git a/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs b/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs index 636c5f60..0540c7e8 100644 --- a/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs @@ -1,7 +1,8 @@ -import { createFileRoute, useNavigate, Link } from "@tanstack/react-router" +import { createFileRoute } from "@tanstack/react-router" import { useState } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" +import { tanstackRouter } from "@btst/stack/tanstack" import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" @@ -26,7 +27,6 @@ type PluginOverrides = { */ function FormDemoPage() { const { slug } = Route.useParams() - const navigate = useNavigate() const [queryClient] = useState(() => getOrCreateQueryClient()) const baseURL = getBaseURL() @@ -34,19 +34,8 @@ function FormDemoPage() { basePath="" - overrides={ - { - "form-builder": { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => navigate({ to: path }), - refresh: () => window.location.reload(), - Link: ({ href, to, ...props }) => ( - - ), - }, - } - } + router={tanstackRouter()} + api={{{providerApiLiteral}}} >
diff --git a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs index 56fbe4ff..dd38305f 100644 --- a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs @@ -1,8 +1,6 @@ -import { createFileRoute, Outlet{{#if pagesLayoutOverrides}}, useNavigate{{/if}}{{#if hasAiChat}}, useLocation{{/if}} } from "@tanstack/react-router" -{{#if pagesLayoutOverrides}} -import { Link as RouterLink } from "@tanstack/react-router" +import { createFileRoute, Outlet{{#if hasBetterAuthUi}}, useNavigate{{/if}}{{#if hasAiChat}}, useLocation{{/if}} } from "@tanstack/react-router" import { StackProvider } from "@btst/stack/context" -{{/if}} +import { tanstackRouter } from "@btst/stack/tanstack" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" {{/if}} @@ -13,7 +11,6 @@ export const Route = createFileRoute("/pages")({ component: BtstPagesLayout, }) -{{#if pagesLayoutOverrides}} function getBaseURL() { if (typeof window !== "undefined") { return window.location.origin @@ -25,10 +22,9 @@ function getBaseURL() { return "http://localhost:3000" } -{{/if}} function BtstPagesLayout() { -{{#if pagesLayoutOverrides}} +{{#if hasBetterAuthUi}} const navigate = useNavigate() {{/if}} const queryClient = getOrCreateQueryClient() @@ -37,17 +33,21 @@ function BtstPagesLayout() { const location = useLocation() const showChatWidget = !location.pathname.startsWith("/pages/chat") {{/if}} -{{#if pagesLayoutOverrides}} const baseURL = getBaseURL() + return ( {{#if hasAiChat}} {!hasApiKey && ( @@ -56,8 +56,6 @@ function BtstPagesLayout() { .env to enable AI chat.
)} -{{/if}} -{{#if hasAiChat}} {showChatWidget && (
) -{{else}} - return -{{/if}} } diff --git a/packages/cli/src/templates/tanstack/preview-route.tsx.hbs b/packages/cli/src/templates/tanstack/preview-route.tsx.hbs index b979a85e..75fa5c00 100644 --- a/packages/cli/src/templates/tanstack/preview-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/preview-route.tsx.hbs @@ -1,7 +1,8 @@ -import { createFileRoute, useNavigate, Link } from "@tanstack/react-router" +import { createFileRoute, Link } from "@tanstack/react-router" import { useState } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" +import { tanstackRouter } from "@btst/stack/tanstack" import { PageRenderer, defaultComponentRegistry, @@ -28,7 +29,6 @@ type PluginOverrides = { */ function PreviewPage() { const { slug } = Route.useParams() - const navigate = useNavigate() const [queryClient] = useState(() => getOrCreateQueryClient()) const baseURL = getBaseURL() @@ -36,17 +36,12 @@ function PreviewPage() { basePath="/preview" + router={tanstackRouter()} + api={{{providerApiLiteral}}} overrides={ - { + { "ui-builder": { - apiBaseURL: baseURL, - apiBasePath: "/api/data", componentRegistry: defaultComponentRegistry, - navigate: (path) => navigate({ to: path }), - refresh: () => window.location.reload(), - Link: ({ href, to, ...props }) => ( - - ), }, } } diff --git a/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs b/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs index bf1b77fe..9a4e89a1 100644 --- a/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router" import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import { StackProvider } from "@btst/stack/context" +import { tanstackRouter } from "@btst/stack/tanstack" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" import { QueryClientProvider } from "@tanstack/react-query" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" @@ -29,13 +30,12 @@ function PublicChatPage() { basePath="" + router={tanstackRouter()} + api={{{providerApiLiteral}}} overrides={ - { + { "ai-chat": { mode: "public", - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: () => {}, }, } } diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 2694a876..cb3e078f 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -46,11 +46,16 @@ describe("scaffold plan", () => { 'import { StackProvider } from "@btst/stack/context"', ); expect(pagesLayoutFile?.content).toContain( - "navigate: (path: string) => router.push(path)", + 'import { nextRouter } from "@btst/stack/next"', ); + expect(pagesLayoutFile?.content).toContain("router={nextRouter()}"); expect(pagesLayoutFile?.content).toContain( - 'Link: ({ href, ...props }: any) => ', + 'api={{ baseURL, basePath: "/api/data" }}', ); + expect(pagesLayoutFile?.content).not.toContain("navigate: (path"); + expect(pagesLayoutFile?.content).not.toContain("Link: ("); + expect(pagesLayoutFile?.content).not.toContain("apiBaseURL:"); + expect(pagesLayoutFile?.content).not.toContain("apiBasePath:"); expect(plan.pagesLayoutPath).toBe("app/pages/layout.tsx"); }); @@ -67,7 +72,7 @@ describe("scaffold plan", () => { }); it.each(["nextjs", "react-router", "tanstack"] as const)( - "does not emit baseURL declarations when no plugins are selected (%s)", + "emits the provider shell without client plugin entries (%s)", async (framework) => { const plan = await buildScaffoldPlan({ framework, @@ -97,10 +102,17 @@ describe("scaffold plan", () => { file.path.endsWith(layoutSuffix), ); expect(pagesLayoutFile?.content).toBeDefined(); - expect(pagesLayoutFile?.content).not.toContain("StackProvider"); - if (framework === "nextjs") { - expect(pagesLayoutFile?.content).not.toContain("useRouter"); - } + expect(pagesLayoutFile?.content).toContain("StackProvider"); + expect(pagesLayoutFile?.content).toContain( + 'api={{ baseURL, basePath: "/api/data" }}', + ); + const routerFactory = + framework === "nextjs" + ? "nextRouter()" + : framework === "react-router" + ? "reactRouter()" + : "tanstackRouter()"; + expect(pagesLayoutFile?.content).toContain(`router={${routerFactory}}`); }, ); @@ -476,8 +488,15 @@ describe("scaffold plan", () => { expect(layoutFile?.content).toContain( 'import { StackProvider } from "@btst/stack/context"', ); - expect(layoutFile?.content).toContain("navigate(path)"); - expect(layoutFile?.content).toContain("RouterLink"); + expect(layoutFile?.content).toContain( + 'import { reactRouter } from "@btst/stack/react-router"', + ); + expect(layoutFile?.content).toContain("router={reactRouter()}"); + expect(layoutFile?.content).toContain( + 'api={{ baseURL, basePath: "/api/data" }}', + ); + expect(layoutFile?.content).not.toContain("navigate: (path"); + expect(layoutFile?.content).not.toContain("RouterLink"); expect(layoutFile?.content).not.toContain("router.push"); expect(layoutFile?.content).not.toContain("router.replace"); expect(layoutFile?.content).not.toContain("router.refresh"); @@ -514,8 +533,15 @@ describe("scaffold plan", () => { expect(layoutFile?.content).toContain( 'import { StackProvider } from "@btst/stack/context"', ); - expect(layoutFile?.content).toContain("navigate({ to: path })"); - expect(layoutFile?.content).toContain("RouterLink"); + expect(layoutFile?.content).toContain( + 'import { tanstackRouter } from "@btst/stack/tanstack"', + ); + expect(layoutFile?.content).toContain("router={tanstackRouter()}"); + expect(layoutFile?.content).toContain( + 'api={{ baseURL, basePath: "/api/data" }}', + ); + expect(layoutFile?.content).not.toContain("navigate: (path"); + expect(layoutFile?.content).not.toContain("RouterLink"); expect(layoutFile?.content).toContain('createFileRoute("/pages")'); expect(layoutFile?.content).not.toContain("router.push"); expect(layoutFile?.content).not.toContain("router.replace"); @@ -558,6 +584,79 @@ describe("scaffold plan", () => { expect(allKeys).toContain("better-auth-ui"); }); + it.each(["nextjs", "react-router", "tanstack"] as const)( + "uses entry factories and shared provider wiring in every %s scaffold", + async (framework) => { + const plan = await buildScaffoldPlan({ + framework, + adapter: "memory", + plugins: [ + "blog", + "ai-chat", + "cms", + "form-builder", + "ui-builder", + "kanban", + "comments", + "media", + ], + alias: framework === "react-router" ? "~/" : "@/", + cssFile: + framework === "nextjs" ? "app/globals.css" : "src/styles/globals.css", + }); + + const routerFactory = + framework === "nextjs" + ? "nextRouter()" + : framework === "react-router" + ? "reactRouter()" + : "tanstackRouter()"; + const pageFactory = + framework === "nextjs" + ? "createNextPage" + : framework === "react-router" + ? "createReactRouterPage" + : "createTanStackPageOptions"; + const apiFactory = + framework === "nextjs" + ? "toNextRouteHandlers" + : framework === "react-router" + ? "toReactRouterHandlers" + : "toTanStackHandlers"; + + const pageRoute = plan.files.find( + (file) => + file.path.includes("routes/pages/$.tsx") || + file.path.includes("app/pages/[[...all]]/page.tsx"), + ); + const apiRoute = plan.files.find( + (file) => + file.path.includes("api/data") && + (file.path.endsWith("route.ts") || file.path.endsWith("$.ts")), + ); + expect(pageRoute?.content).toContain(pageFactory); + expect(pageRoute?.content).not.toContain(".router.getRoute("); + expect(apiRoute?.content).toContain(apiFactory); + + const providerFiles = plan.files.filter((file) => + file.content.includes("'; - return ''; -} - function getPagesLayoutFilePath(framework: Framework): string { if (framework === "nextjs") return "app/pages/layout.tsx"; if (framework === "react-router") return "app/routes/pages/_layout.tsx"; @@ -223,65 +212,42 @@ function buildPluginTemplateContext( if (m.key === "route-docs") { return ""; } - const nav = getNavigateExpr(framework); const rep = getReplaceExpr(framework); const ses = getSessionChangeExpr(framework); - const link = getLinkJsx(framework); const layoutFile = getPagesLayoutFilePath(framework); - const linkPropDestructure = - framework === "nextjs" - ? "{ href, ...props }" - : "{ href, to, ...props }"; if (m.key === "better-auth-ui") { return `\t\t\t\t\tauth: { \t\t\t\t\t\tauthClient: undefined as any, -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, \t\t\t\t\t\treplace: (path: string) => ${rep}, \t\t\t\t\t\tonSessionChange: () => ${ses}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, \t\t\t\t\t\tbasePath: "/pages/auth", \t\t\t\t\t\tredirectTo: "/pages/account/settings", \t\t\t\t\t}, \t\t\t\t\taccount: { \t\t\t\t\t\tauthClient: undefined as any, -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, \t\t\t\t\t\treplace: (path: string) => ${rep}, \t\t\t\t\t\tonSessionChange: () => ${ses}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, \t\t\t\t\t\tbasePath: "/pages/account", \t\t\t\t\t\taccount: { fields: ["image", "name"] }, \t\t\t\t\t}, \t\t\t\t\torganization: { \t\t\t\t\t\tauthClient: undefined as any, -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, \t\t\t\t\t\treplace: (path: string) => ${rep}, \t\t\t\t\t\tonSessionChange: () => ${ses}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, \t\t\t\t\t\tbasePath: "/pages/org", \t\t\t\t\t\torganization: { basePath: "/pages/org" }, \t\t\t\t\t},`; } if (m.key === "comments") { - return `\t\t\t\t\t"${m.key}": { -\t\t\t\t\t\tapiBaseURL: baseURL, -\t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t},`; + return ""; } if (m.key === "media") { return `\t\t\t\t\t"${m.key}": { -\t\t\t\t\t\tapiBaseURL: baseURL, -\t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tqueryClient, -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, \t\t\t\t\t},`; } if (m.key === "blog") { return `\t\t\t\t\t"${m.key}": { -\t\t\t\t\t\tapiBaseURL: baseURL, -\t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement blog.uploadImage override in ${layoutFile}") \t\t\t\t\t\t}, @@ -289,10 +255,6 @@ function buildPluginTemplateContext( } if (m.key === "kanban") { return `\t\t\t\t\t"${m.key}": { -\t\t\t\t\t\tapiBaseURL: baseURL, -\t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement kanban.uploadImage override in ${layoutFile}") \t\t\t\t\t\t}, @@ -302,19 +264,10 @@ function buildPluginTemplateContext( } if (m.key === "ai-chat") { return `\t\t\t\t\t"${m.key}": { -\t\t\t\t\t\tapiBaseURL: baseURL, -\t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tmode: "public" as const, -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, \t\t\t\t\t},`; } - return `\t\t\t\t\t"${m.key}": { -\t\t\t\t\t\tapiBaseURL: baseURL, -\t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t\tnavigate: (path: string) => ${nav}, -\t\t\t\t\t\tLink: (${linkPropDestructure}: any) => ${link}, -\t\t\t\t\t},`; + return ""; }) .filter(Boolean) .join("\n"), @@ -396,6 +349,7 @@ export async function buildScaffoldPlan( const sharedContext = { alias: input.alias, + providerApiLiteral: '{{ baseURL, basePath: "/api/data" }}', publicSiteURLVar: getPublicSiteURLVar(input.framework), useGlobalSingleton: input.framework === "nextjs" && input.adapter === "memory", diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 6b92d1e6..fa2b3eee 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -414,6 +414,12 @@ "type": "registry:hook", "content": "import { useEffect, useState } from \"react\";\n\nexport function useDebounce(value: T, delay?: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n\n useEffect(() => {\n const timer = setTimeout(() => setDebouncedValue(value), delay || 500);\n\n return () => {\n clearTimeout(timer);\n };\n }, [value, delay]);\n\n return debouncedValue;\n}", "target": "src/hooks/use-debounce.ts" + }, + { + "path": "ui/components/stack-attribution.tsx", + "type": "registry:component", + "content": "export function StackAttribution() {\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\tPowered by{\" \"}\n\t\t\t\t\n\t\t\t\t\tBTST\n\t\t\t\t\n\t\t\t

\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/stack-attribution.tsx" } ], "docs": "https://better-stack.ai/docs/plugins/blog" diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index d7be191f..5a925945 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -273,6 +273,18 @@ "type": "registry:lib", "content": "import { z } from \"zod\";\n\n// ============================================================================\n// TYPES\n// ============================================================================\n\nexport interface FormStep {\n id: string;\n title: string;\n}\n\nexport interface FormSchemaMetadata {\n /** Multi-step form step definitions */\n steps?: FormStep[];\n /** Map of field names to their step indices */\n stepGroupMap?: Record;\n}\n\ninterface JsonSchemaProperty {\n type?: string;\n format?: string;\n formatMinimum?: string;\n formatMaximum?: string;\n stepGroup?: number;\n properties?: Record;\n [key: string]: unknown;\n}\n\ninterface FormJsonSchema {\n type?: string;\n properties?: Record;\n required?: string[];\n steps?: FormStep[];\n stepGroupMap?: Record;\n [key: string]: unknown;\n}\n\n// ============================================================================\n// ZOD → JSON SCHEMA (for storage/transport)\n// ============================================================================\n\n/**\n * Convert a Zod schema to JSON Schema with proper handling for:\n * - z.date() → { type: \"string\", format: \"date-time\" }\n * - Date min/max constraints → formatMinimum/formatMaximum\n * - Steps metadata (if provided via schema.meta() or explicit metadata param)\n * \n * @param schema - The Zod schema to convert\n * @param metadata - Optional explicit metadata to include (overrides schema meta)\n */\nexport function zodToFormSchema(\n schema: T,\n metadata?: FormSchemaMetadata\n): FormJsonSchema {\n const jsonSchema = z.toJSONSchema(schema, {\n unrepresentable: \"any\",\n override: (ctx) => {\n const def = (ctx.zodSchema as any)?._zod?.def;\n if (def?.type === \"date\") {\n ctx.jsonSchema.type = \"string\";\n ctx.jsonSchema.format = \"date-time\";\n \n // Preserve min/max date constraints\n // In Zod v4, these are available as minDate/maxDate on the schema object\n const zodSchema = ctx.zodSchema as any;\n if (zodSchema.minDate) {\n ctx.jsonSchema.formatMinimum = zodSchema.minDate;\n }\n if (zodSchema.maxDate) {\n ctx.jsonSchema.formatMaximum = zodSchema.maxDate;\n }\n }\n },\n }) as FormJsonSchema;\n \n // If explicit metadata is provided, use it\n if (metadata?.steps) {\n jsonSchema.steps = metadata.steps;\n }\n if (metadata?.stepGroupMap) {\n jsonSchema.stepGroupMap = metadata.stepGroupMap;\n }\n \n return jsonSchema;\n}\n\n// ============================================================================\n// JSON SCHEMA → ZOD (for validation)\n// ============================================================================\n\n/**\n * Extract step group map from JSON Schema properties.\n * Looks for stepGroup property on each field.\n */\nfunction extractStepGroupMap(jsonSchema: FormJsonSchema): Record {\n const stepGroupMap: Record = {};\n const properties = jsonSchema.properties;\n \n if (!properties) return stepGroupMap;\n \n for (const [fieldName, fieldSchema] of Object.entries(properties)) {\n if (typeof fieldSchema.stepGroup === \"number\") {\n stepGroupMap[fieldName] = fieldSchema.stepGroup;\n }\n }\n \n return stepGroupMap;\n}\n\n/**\n * Find date fields with min/max constraints that need validation.\n */\nfunction findDateFieldsWithConstraints(\n jsonSchema: FormJsonSchema\n): Record {\n const dateFields: Record = {};\n const properties = jsonSchema.properties;\n \n if (!properties) return dateFields;\n \n for (const [key, prop] of Object.entries(properties)) {\n if (prop.type === \"string\" && prop.format === \"date-time\") {\n if (prop.formatMinimum || prop.formatMaximum) {\n dateFields[key] = {\n min: prop.formatMinimum,\n max: prop.formatMaximum,\n };\n }\n }\n }\n \n return dateFields;\n}\n\n/**\n * Add date constraint validations to a schema via superRefine.\n */\nfunction addDateValidations(\n schema: z.ZodType,\n dateFieldsWithConstraints: Record\n): z.ZodType {\n if (Object.keys(dateFieldsWithConstraints).length === 0) {\n return schema;\n }\n \n return schema.superRefine((data: any, ctx) => {\n for (const [key, constraints] of Object.entries(dateFieldsWithConstraints)) {\n const value = data[key];\n if (value === undefined || value === null || value === \"\") continue;\n \n const dateValue = new Date(value);\n if (isNaN(dateValue.getTime())) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: \"Invalid date\",\n path: [key],\n });\n continue;\n }\n \n if (constraints.min) {\n const minDate = new Date(constraints.min);\n if (dateValue < minDate) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Date must be after ${minDate.toLocaleDateString()}`,\n path: [key],\n });\n }\n }\n \n if (constraints.max) {\n const maxDate = new Date(constraints.max);\n if (dateValue > maxDate) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Date must be before ${maxDate.toLocaleDateString()}`,\n path: [key],\n });\n }\n }\n }\n });\n}\n\n/**\n * Re-attach steps metadata to a Zod schema via .meta().\n * This is necessary because z.fromJSONSchema() doesn't preserve custom properties.\n */\nfunction attachStepsMetadata(\n schema: z.ZodType,\n jsonSchema: FormJsonSchema\n): z.ZodType {\n const steps = jsonSchema.steps;\n if (!steps || steps.length === 0) {\n return schema;\n }\n \n // Get stepGroupMap from either root level or extract from properties\n const stepGroupMap = jsonSchema.stepGroupMap ?? extractStepGroupMap(jsonSchema);\n \n return schema.meta({\n steps,\n stepGroupMap,\n });\n}\n\n/**\n * Convert JSON Schema to Zod schema with proper handling for:\n * - { type: \"string\", format: \"date-time\" } → date field (with constraints)\n * - Steps metadata re-attachment (preserved via .meta())\n * - Step group mapping\n * \n * @param jsonSchema - The JSON Schema to convert\n * @returns A Zod schema ready for validation, with all metadata preserved\n */\nexport function formSchemaToZod(jsonSchema: FormJsonSchema): z.ZodType {\n // 1. Create base schema from JSON Schema\n let schema = z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);\n\n // 2. z.fromJSONSchema creates strict ZodObjects that reject unknown keys with\n // an 'unrecognized_keys' error. This breaks form save when parsedData\n // contains fields added to the Zod schema after the content type was last\n // synced to the DB. Apply passthrough() so unknown keys are preserved\n // rather than rejected. We deliberately do NOT use strip() here: stripping\n // would silently drop values for fields that exist in the live Zod schema\n // but not yet in the stale stored JSON schema, losing data on save.\n if (schema && typeof (schema as z.ZodObject).passthrough === \"function\") {\n schema = (schema as z.ZodObject).passthrough();\n }\n \n // 3. Add date constraint validations\n const dateFieldsWithConstraints = findDateFieldsWithConstraints(jsonSchema);\n schema = addDateValidations(schema, dateFieldsWithConstraints);\n \n // 4. Re-attach steps metadata so SteppedAutoForm can extract it\n schema = attachStepsMetadata(schema, jsonSchema);\n \n return schema;\n}\n\n// ============================================================================\n// UTILITY FUNCTIONS\n// ============================================================================\n\n/**\n * Check if a JSON Schema has multi-step configuration.\n */\nexport function hasSteps(jsonSchema: FormJsonSchema): boolean {\n return Array.isArray(jsonSchema.steps) && jsonSchema.steps.length > 0;\n}\n\n/**\n * Get steps from a JSON Schema.\n */\nexport function getSteps(jsonSchema: FormJsonSchema): FormStep[] {\n return jsonSchema.steps ?? [];\n}\n\n/**\n * Get the step group map from a JSON Schema.\n * Returns a map of field names to step indices.\n */\nexport function getStepGroupMap(jsonSchema: FormJsonSchema): Record {\n return jsonSchema.stepGroupMap ?? extractStepGroupMap(jsonSchema);\n}\n\n", "target": "src/lib/schema-converter.ts" + }, + { + "path": "ui/components/page-layout.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PageLayoutProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\t\"data-testid\"?: string;\n}\n\n/**\n * Shared page layout component providing consistent container styling\n * for plugin pages. Used by blog, CMS, and other plugins.\n */\nexport function PageLayout({\n\tchildren,\n\tclassName,\n\t\"data-testid\": dataTestId,\n}: PageLayoutProps) {\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/page-layout.tsx" + }, + { + "path": "ui/components/stack-attribution.tsx", + "type": "registry:component", + "content": "export function StackAttribution() {\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\tPowered by{\" \"}\n\t\t\t\t\n\t\t\t\t\tBTST\n\t\t\t\t\n\t\t\t

\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/stack-attribution.tsx" } ], "docs": "https://better-stack.ai/docs/plugins/cms" diff --git a/packages/stack/registry/btst-comments.json b/packages/stack/registry/btst-comments.json index 3533cbcc..5ca1d1c9 100644 --- a/packages/stack/registry/btst-comments.json +++ b/packages/stack/registry/btst-comments.json @@ -48,7 +48,7 @@ { "path": "btst/comments/client/components/comment-thread.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../localization\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls */\n\tapiBaseURL: string;\n\t/** Path where the API is mounted */\n\tapiBasePath: string;\n\t/** Currently authenticated user ID. Omit for read-only / unauthenticated. */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n\t/**\n\t * Sort direction for top-level comments by `createdAt`.\n\t * - `\"desc\"` (default): newest first.\n\t * - `\"asc\"`: oldest first.\n\t *\n\t * Replies inside each thread always render chronologically (oldest → newest)\n\t * and are unaffected by this prop.\n\t *\n\t * Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.\n\t */\n\tsort?: \"asc\" | \"desc\";\n}\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t

{body}

\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tlocalization,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tconst confirmMessage =\n\t\t\tlocalization?.COMMENTS_DELETE_CONFIRM ??\n\t\t\tt(\"comments.thread.deleteConfirm\", \"Delete this comment?\");\n\t\tif (!window.confirm(confirmMessage)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_EDITED_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.editedBadge\", \"(edited)\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_PENDING_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.pendingBadge\", \"Pending approval\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_REPLY_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.replyButton\", \"Reply\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_EDIT_BUTTON ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.editButton\", \"Edit\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_DELETE_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.deleteButton\", \"Delete\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n\tsort: sortProp,\n}: CommentThreadProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst sort = sortProp ?? overrides.defaultCommentSort ?? \"desc\";\n\t// Per-instance prop wins over the plugin-level override strings; missing\n\t// keys fall through to `t()` inside each child component.\n\tconst localization = { ...overrides.localization, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tsort,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t\tsort,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{(() => {\n\t\t\t\t\t\tconst title =\n\t\t\t\t\t\t\tlocalization?.COMMENTS_TITLE ??\n\t\t\t\t\t\t\tt(\"comments.thread.title\", \"Comments\");\n\t\t\t\t\t\treturn total === 0 ? title : `${total} ${title}`;\n\t\t\t\t\t})()}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{isLoading && (\n\t\t\t\t
\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_EMPTY ??\n\t\t\t\t\t\tt(\"comments.thread.empty\", \"Be the first to comment.\")}\n\t\t\t\t

\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t
\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_PROMPT ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.thread.loginPrompt\",\n\t\t\t\t\t\t\t\t\t\t\"Please sign in to leave a comment.\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_LINK ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loginLink\", \"Sign in\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tlocalization,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t
\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? (localization?.COMMENTS_HIDE_REPLIES ??\n\t\t\t\t\t\tt(\"comments.thread.hideReplies\", \"Hide replies\"))\n\t\t\t\t\t: `${displayCount} ${\n\t\t\t\t\t\t\tdisplayCount === 1\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_REPLIES_SINGULAR ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesSingular\", \"reply\"))\n\t\t\t\t\t\t\t\t: (localization?.COMMENTS_REPLIES_PLURAL ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesPlural\", \"replies\"))\n\t\t\t\t\t\t}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Requires `currentUserId` to allow posting; shows a \"Please login\" prompt otherwise.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t
\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\treturn (\n\t\t
\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials, useResolvedCurrentUserId } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../localization\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls. Defaults to the top-level StackProvider API. */\n\tapiBaseURL?: string;\n\t/** Path where the API is mounted. Defaults to the top-level StackProvider API. */\n\tapiBasePath?: string;\n\t/**\n\t * Currently authenticated user ID. Defaults to the top-level StackProvider\n\t * identity. Omit for read-only / unauthenticated when no auth provider exists.\n\t */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n\t/**\n\t * Sort direction for top-level comments by `createdAt`.\n\t * - `\"desc\"` (default): newest first.\n\t * - `\"asc\"`: oldest first.\n\t *\n\t * Replies inside each thread always render chronologically (oldest → newest)\n\t * and are unaffected by this prop.\n\t *\n\t * Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.\n\t */\n\tsort?: \"asc\" | \"desc\";\n}\n\ntype ResolvedCommentThreadProps = Omit<\n\tCommentThreadProps,\n\t\"apiBaseURL\" | \"apiBasePath\"\n> & {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n};\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t

{body}

\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tlocalization,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tconst confirmMessage =\n\t\t\tlocalization?.COMMENTS_DELETE_CONFIRM ??\n\t\t\tt(\"comments.thread.deleteConfirm\", \"Delete this comment?\");\n\t\tif (!window.confirm(confirmMessage)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_EDITED_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.editedBadge\", \"(edited)\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_PENDING_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.pendingBadge\", \"Pending approval\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_REPLY_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.replyButton\", \"Reply\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_EDIT_BUTTON ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.editButton\", \"Edit\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_DELETE_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.deleteButton\", \"Delete\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n\tsort: sortProp,\n}: ResolvedCommentThreadProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst sort = sortProp ?? overrides.defaultCommentSort ?? \"desc\";\n\t// Per-instance prop wins over the plugin-level override strings; missing\n\t// keys fall through to `t()` inside each child component.\n\tconst localization = { ...overrides.localization, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tsort,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t\tsort,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{(() => {\n\t\t\t\t\t\tconst title =\n\t\t\t\t\t\t\tlocalization?.COMMENTS_TITLE ??\n\t\t\t\t\t\t\tt(\"comments.thread.title\", \"Comments\");\n\t\t\t\t\t\treturn total === 0 ? title : `${total} ${title}`;\n\t\t\t\t\t})()}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{isLoading && (\n\t\t\t\t
\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_EMPTY ??\n\t\t\t\t\t\tt(\"comments.thread.empty\", \"Be the first to comment.\")}\n\t\t\t\t

\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t
\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_PROMPT ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.thread.loginPrompt\",\n\t\t\t\t\t\t\t\t\t\t\"Please sign in to leave a comment.\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_LINK ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loginLink\", \"Sign in\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tlocalization,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t
\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? (localization?.COMMENTS_HIDE_REPLIES ??\n\t\t\t\t\t\tt(\"comments.thread.hideReplies\", \"Hide replies\"))\n\t\t\t\t\t: `${displayCount} ${\n\t\t\t\t\t\t\tdisplayCount === 1\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_REPLIES_SINGULAR ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesSingular\", \"reply\"))\n\t\t\t\t\t\t\t\t: (localization?.COMMENTS_REPLIES_PLURAL ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesPlural\", \"replies\"))\n\t\t\t\t\t\t}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Uses the top-level StackProvider API and auth configuration by default.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t
\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst currentUserId = useResolvedCurrentUserId(props.currentUserId);\n\tconst resolvedProps: ResolvedCommentThreadProps = {\n\t\t...props,\n\t\tapiBaseURL: props.apiBaseURL ?? overrides.apiBaseURL ?? \"\",\n\t\tapiBasePath: props.apiBasePath ?? overrides.apiBasePath ?? \"\",\n\t\tcurrentUserId,\n\t\tloginHref: props.loginHref ?? overrides.loginHref,\n\t\theaders: props.headers ?? overrides.headers,\n\t};\n\n\treturn (\n\t\t
\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/comments/client/components/comment-thread.tsx" }, { @@ -126,13 +126,13 @@ { "path": "btst/comments/client/overrides.ts", "type": "registry:lib", - "content": "/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { resourceId: \"my-post\", resourceType: \"blog-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\nimport type { CommentsLocalization } from \"./localization\";\n\n/**\n * Overridable configuration and hooks for the Comments plugin.\n *\n * Provide these in the layout wrapping your pages via `PluginOverridesProvider`.\n */\nexport interface CommentsPluginOverrides {\n\t/**\n\t * Localization strings for all Comments plugin UI.\n\t * Defaults to English when not provided.\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Base URL for API calls (e.g., \"https://example.com\")\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * Path where the API is mounted (e.g., \"/api/data\")\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Optional headers for authenticated API calls (e.g., forwarding cookies)\n\t */\n\theaders?: Record;\n\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution on plugin pages.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * The ID of the currently authenticated user.\n\t *\n\t * Used by the User Comments page and the per-resource comments admin view to\n\t * scope the comment list to the current user and to enable posting.\n\t * Can be a static string or an async function (useful when the user ID must\n\t * be resolved from a session cookie at render time).\n\t *\n\t * When absent both pages show a \"Please log in\" prompt.\n\t */\n\tcurrentUserId?:\n\t\t| string\n\t\t| (() => string | undefined | Promise);\n\n\t/**\n\t * URL to redirect unauthenticated users to when they try to post a comment.\n\t *\n\t * Forwarded to every embedded `CommentThread` (including the one on the\n\t * per-resource admin comments view). When absent no login link is shown.\n\t */\n\tloginHref?: string;\n\n\t/**\n\t * Default number of top-level comments to load per page in `CommentThread`.\n\t * Can be overridden per-instance via the `pageSize` prop.\n\t * Defaults to 100 when not set.\n\t */\n\tdefaultCommentPageSize?: number;\n\n\t/**\n\t * Default sort direction (by `createdAt`) for top-level comments in\n\t * `CommentThread`.\n\t * - `\"desc\"` (default): newest comments first.\n\t * - `\"asc\"`: oldest comments first.\n\t *\n\t * Can be overridden per-instance via the `sort` prop on `CommentThread`.\n\t */\n\tdefaultCommentSort?: \"asc\" | \"desc\";\n\n\t/**\n\t * When false, the comment form and reply buttons are hidden in all\n\t * `CommentThread` instances. Users can still read existing comments.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowPosting` prop on `CommentThread`.\n\t */\n\tallowPosting?: boolean;\n\n\t/**\n\t * When false, the edit button is hidden on all comment cards in all\n\t * `CommentThread` instances.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowEditing` prop on `CommentThread`.\n\t */\n\tallowEditing?: boolean;\n\n\t/**\n\t * Per-resource-type URL builders used to link each comment back to its\n\t * original resource on the User Comments page.\n\t *\n\t * @example\n\t * ```ts\n\t * resourceLinks: {\n\t * \"blog-post\": (slug) => `/pages/blog/${slug}`,\n\t * \"kanban-task\": (id) => `/pages/kanban?task=${id}`,\n\t * }\n\t * ```\n\t *\n\t * When a resource type has no entry the ID is shown as plain text.\n\t */\n\tresourceLinks?: Record string>;\n\n\t// ============ Access Control Hooks ============\n\n\t/**\n\t * Called before the moderation dashboard page is rendered.\n\t * Return false to block rendering (e.g., redirect to login or show 403).\n\t * @param context - Route context\n\t */\n\tonBeforeModerationPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the per-resource comments page is rendered.\n\t * Return false to block rendering (e.g., for authorization).\n\t * @param resourceType - The type of resource (e.g., \"blog-post\")\n\t * @param resourceId - The ID of the resource\n\t * @param context - Route context\n\t */\n\tonBeforeResourceCommentsRendered?: (\n\t\tresourceType: string,\n\t\tresourceId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the User Comments page is rendered.\n\t * Throw to block rendering (e.g., when the user is not authenticated).\n\t * @param context - Route context\n\t */\n\tonBeforeUserCommentsPageRendered?: (context: RouteContext) => boolean | void;\n\n\t// ============ Lifecycle Hooks ============\n\n\t/**\n\t * Called when a route is rendered.\n\t * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments')\n\t * @param context - Route context\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error.\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "content": "/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { resourceId: \"my-post\", resourceType: \"blog-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\nimport type { CommentsLocalization } from \"./localization\";\n\n/**\n * Overridable configuration and hooks for the Comments plugin.\n *\n * Provide these in the layout wrapping your pages via `PluginOverridesProvider`.\n */\nexport interface CommentsPluginOverrides {\n\t/**\n\t * Localization strings for all Comments plugin UI.\n\t * Defaults to English when not provided.\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Base URL for API calls (e.g., \"https://example.com\")\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * Path where the API is mounted (e.g., \"/api/data\")\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Optional headers for authenticated API calls (e.g., forwarding cookies)\n\t */\n\theaders?: Record;\n\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution on plugin pages.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * The ID of the currently authenticated user.\n\t *\n\t * Used by the User Comments page and the per-resource comments admin view to\n\t * scope the comment list to the current user and to enable posting.\n\t * Can be a static string or an async function (useful when the user ID must\n\t * be resolved from a session cookie at render time).\n\t *\n\t * When absent, defaults to the identity from the top-level auth provider.\n\t * Without either value both pages show a \"Please log in\" prompt.\n\t */\n\tcurrentUserId?:\n\t\t| string\n\t\t| (() => string | undefined | Promise);\n\n\t/**\n\t * URL to redirect unauthenticated users to when they try to post a comment.\n\t *\n\t * Forwarded to every embedded `CommentThread` (including the one on the\n\t * per-resource admin comments view). When absent, defaults to the top-level\n\t * auth provider's `loginPath`.\n\t */\n\tloginHref?: string;\n\n\t/**\n\t * Default number of top-level comments to load per page in `CommentThread`.\n\t * Can be overridden per-instance via the `pageSize` prop.\n\t * Defaults to 100 when not set.\n\t */\n\tdefaultCommentPageSize?: number;\n\n\t/**\n\t * Default sort direction (by `createdAt`) for top-level comments in\n\t * `CommentThread`.\n\t * - `\"desc\"` (default): newest comments first.\n\t * - `\"asc\"`: oldest comments first.\n\t *\n\t * Can be overridden per-instance via the `sort` prop on `CommentThread`.\n\t */\n\tdefaultCommentSort?: \"asc\" | \"desc\";\n\n\t/**\n\t * When false, the comment form and reply buttons are hidden in all\n\t * `CommentThread` instances. Users can still read existing comments.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowPosting` prop on `CommentThread`.\n\t */\n\tallowPosting?: boolean;\n\n\t/**\n\t * When false, the edit button is hidden on all comment cards in all\n\t * `CommentThread` instances.\n\t * Defaults to true.\n\t *\n\t * Can be overridden per-instance via the `allowEditing` prop on `CommentThread`.\n\t */\n\tallowEditing?: boolean;\n\n\t/**\n\t * Per-resource-type URL builders used to link each comment back to its\n\t * original resource on the User Comments page.\n\t *\n\t * @example\n\t * ```ts\n\t * resourceLinks: {\n\t * \"blog-post\": (slug) => `/pages/blog/${slug}`,\n\t * \"kanban-task\": (id) => `/pages/kanban?task=${id}`,\n\t * }\n\t * ```\n\t *\n\t * When a resource type has no entry the ID is shown as plain text.\n\t */\n\tresourceLinks?: Record string>;\n\n\t// ============ Access Control Hooks ============\n\n\t/**\n\t * Called before the moderation dashboard page is rendered.\n\t * Return false to block rendering (e.g., redirect to login or show 403).\n\t * @param context - Route context\n\t */\n\tonBeforeModerationPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the per-resource comments page is rendered.\n\t * Return false to block rendering (e.g., for authorization).\n\t * @param resourceType - The type of resource (e.g., \"blog-post\")\n\t * @param resourceId - The ID of the resource\n\t * @param context - Route context\n\t */\n\tonBeforeResourceCommentsRendered?: (\n\t\tresourceType: string,\n\t\tresourceId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the User Comments page is rendered.\n\t * Throw to block rendering (e.g., when the user is not authenticated).\n\t * @param context - Route context\n\t */\n\tonBeforeUserCommentsPageRendered?: (context: RouteContext) => boolean | void;\n\n\t// ============ Lifecycle Hooks ============\n\n\t/**\n\t * Called when a route is rendered.\n\t * @param routeName - Name of the route (e.g., 'moderation', 'resourceComments')\n\t * @param context - Route context\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error.\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", "target": "src/components/btst/comments/client/overrides.ts" }, { "path": "btst/comments/client/utils.ts", "type": "registry:lib", - "content": "import { useState, useEffect } from \"react\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\n\n/**\n * Resolves `currentUserId` from the plugin overrides, supporting both a static\n * string and a sync/async function. Returns `undefined` until resolution completes.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): string | undefined {\n\tconst [resolved, setResolved] = useState(\n\t\ttypeof raw === \"string\" ? raw : undefined,\n\t);\n\n\tuseEffect(() => {\n\t\tif (typeof raw === \"function\") {\n\t\t\tvoid Promise.resolve(raw())\n\t\t\t\t.then((id) => setResolved(id ?? undefined))\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\"[btst/comments] Failed to resolve currentUserId:\",\n\t\t\t\t\t\terr,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t} else {\n\t\t\tsetResolved(raw ?? undefined);\n\t\t}\n\t}, [raw]);\n\n\treturn resolved;\n}\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", + "content": "import { useState, useEffect } from \"react\";\nimport { useIdentity } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\n\n/**\n * Resolves the legacy `currentUserId` override when provided, otherwise uses\n * the identity from the top-level Stack auth provider.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): string | undefined {\n\tconst { identity } = useIdentity();\n\tconst providerUserId = identity?.id;\n\tconst [resolved, setResolved] = useState(\n\t\ttypeof raw === \"string\"\n\t\t\t? raw\n\t\t\t: raw === undefined\n\t\t\t\t? providerUserId\n\t\t\t\t: undefined,\n\t);\n\n\tuseEffect(() => {\n\t\tif (typeof raw === \"function\") {\n\t\t\tvoid Promise.resolve(raw())\n\t\t\t\t.then((id) => setResolved(id ?? undefined))\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\"[btst/comments] Failed to resolve currentUserId:\",\n\t\t\t\t\t\terr,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t} else if (typeof raw === \"string\") {\n\t\t\tsetResolved(raw);\n\t\t} else {\n\t\t\tsetResolved(providerUserId);\n\t\t}\n\t}, [providerUserId, raw]);\n\n\treturn resolved;\n}\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", "target": "src/components/btst/comments/client/utils.ts" }, { @@ -158,6 +158,18 @@ "type": "registry:hook", "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/**\n * Base route context interface that plugins can extend\n */\nexport interface BaseRouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Minimum interface required for route lifecycle hooks\n * Plugin overrides should implement these optional hooks\n */\nexport interface RouteLifecycleOverrides {\n\t/** Called when a route is rendered */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: TContext,\n\t) => void | Promise;\n\t/** Called when a route encounters an error */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: TContext,\n\t) => void | Promise;\n}\n\n/**\n * Hook to handle route lifecycle events\n * - Calls authorization check before render\n * - Calls onRouteRender on mount\n * - Handles errors with onRouteError\n *\n * @example\n * ```tsx\n * const overrides = usePluginOverrides(\"myPlugin\");\n *\n * useRouteLifecycle({\n * routeName: \"dashboard\",\n * context: { path: \"/dashboard\", isSSR: typeof window === \"undefined\" },\n * overrides,\n * beforeRenderHook: (overrides, context) => {\n * if (overrides.onBeforeDashboardRendered) {\n * return overrides.onBeforeDashboardRendered(context);\n * }\n * return true;\n * },\n * });\n * ```\n */\nexport function useRouteLifecycle<\n\tTContext extends BaseRouteContext,\n\tTOverrides extends RouteLifecycleOverrides,\n>({\n\trouteName,\n\tcontext,\n\toverrides,\n\tbeforeRenderHook,\n}: {\n\trouteName: string;\n\tcontext: TContext;\n\toverrides: TOverrides;\n\tbeforeRenderHook?: (overrides: TOverrides, context: TContext) => boolean;\n}) {\n\t// Authorization check - runs synchronously before render\n\tif (beforeRenderHook) {\n\t\tconst canRender = beforeRenderHook(overrides, context);\n\t\tif (!canRender) {\n\t\t\tconst error = new Error(`Unauthorized: Cannot render ${routeName}`);\n\t\t\t// Call error hook synchronously\n\t\t\tif (overrides.onRouteError) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = overrides.onRouteError(routeName, error, context);\n\t\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\t\tresult.catch(() => {}); // Ignore promise rejection\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore errors in error hook\n\t\t\t\t}\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t// Lifecycle hook - runs on mount\n\tuseEffect(() => {\n\t\tif (overrides.onRouteRender) {\n\t\t\ttry {\n\t\t\t\tconst result = overrides.onRouteRender(routeName, context);\n\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\tresult.catch((error) => {\n\t\t\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\t\t\toverrides.onRouteError(routeName, error, context);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\toverrides.onRouteError(routeName, error as Error, context);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [routeName, overrides, context]);\n}\n", "target": "src/hooks/use-route-lifecycle.ts" + }, + { + "path": "ui/components/page-layout.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PageLayoutProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\t\"data-testid\"?: string;\n}\n\n/**\n * Shared page layout component providing consistent container styling\n * for plugin pages. Used by blog, CMS, and other plugins.\n */\nexport function PageLayout({\n\tchildren,\n\tclassName,\n\t\"data-testid\": dataTestId,\n}: PageLayoutProps) {\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/page-layout.tsx" + }, + { + "path": "ui/components/stack-attribution.tsx", + "type": "registry:component", + "content": "export function StackAttribution() {\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\tPowered by{\" \"}\n\t\t\t\t\n\t\t\t\t\tBTST\n\t\t\t\t\n\t\t\t

\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/stack-attribution.tsx" } ], "docs": "https://better-stack.ai/docs/plugins/comments" diff --git a/packages/stack/registry/btst-form-builder.json b/packages/stack/registry/btst-form-builder.json index 50ea6708..5caa3819 100644 --- a/packages/stack/registry/btst-form-builder.json +++ b/packages/stack/registry/btst-form-builder.json @@ -231,6 +231,18 @@ "type": "registry:lib", "content": "import { z } from \"zod\";\n\n// ============================================================================\n// TYPES\n// ============================================================================\n\nexport interface FormStep {\n id: string;\n title: string;\n}\n\nexport interface FormSchemaMetadata {\n /** Multi-step form step definitions */\n steps?: FormStep[];\n /** Map of field names to their step indices */\n stepGroupMap?: Record;\n}\n\ninterface JsonSchemaProperty {\n type?: string;\n format?: string;\n formatMinimum?: string;\n formatMaximum?: string;\n stepGroup?: number;\n properties?: Record;\n [key: string]: unknown;\n}\n\ninterface FormJsonSchema {\n type?: string;\n properties?: Record;\n required?: string[];\n steps?: FormStep[];\n stepGroupMap?: Record;\n [key: string]: unknown;\n}\n\n// ============================================================================\n// ZOD → JSON SCHEMA (for storage/transport)\n// ============================================================================\n\n/**\n * Convert a Zod schema to JSON Schema with proper handling for:\n * - z.date() → { type: \"string\", format: \"date-time\" }\n * - Date min/max constraints → formatMinimum/formatMaximum\n * - Steps metadata (if provided via schema.meta() or explicit metadata param)\n * \n * @param schema - The Zod schema to convert\n * @param metadata - Optional explicit metadata to include (overrides schema meta)\n */\nexport function zodToFormSchema(\n schema: T,\n metadata?: FormSchemaMetadata\n): FormJsonSchema {\n const jsonSchema = z.toJSONSchema(schema, {\n unrepresentable: \"any\",\n override: (ctx) => {\n const def = (ctx.zodSchema as any)?._zod?.def;\n if (def?.type === \"date\") {\n ctx.jsonSchema.type = \"string\";\n ctx.jsonSchema.format = \"date-time\";\n \n // Preserve min/max date constraints\n // In Zod v4, these are available as minDate/maxDate on the schema object\n const zodSchema = ctx.zodSchema as any;\n if (zodSchema.minDate) {\n ctx.jsonSchema.formatMinimum = zodSchema.minDate;\n }\n if (zodSchema.maxDate) {\n ctx.jsonSchema.formatMaximum = zodSchema.maxDate;\n }\n }\n },\n }) as FormJsonSchema;\n \n // If explicit metadata is provided, use it\n if (metadata?.steps) {\n jsonSchema.steps = metadata.steps;\n }\n if (metadata?.stepGroupMap) {\n jsonSchema.stepGroupMap = metadata.stepGroupMap;\n }\n \n return jsonSchema;\n}\n\n// ============================================================================\n// JSON SCHEMA → ZOD (for validation)\n// ============================================================================\n\n/**\n * Extract step group map from JSON Schema properties.\n * Looks for stepGroup property on each field.\n */\nfunction extractStepGroupMap(jsonSchema: FormJsonSchema): Record {\n const stepGroupMap: Record = {};\n const properties = jsonSchema.properties;\n \n if (!properties) return stepGroupMap;\n \n for (const [fieldName, fieldSchema] of Object.entries(properties)) {\n if (typeof fieldSchema.stepGroup === \"number\") {\n stepGroupMap[fieldName] = fieldSchema.stepGroup;\n }\n }\n \n return stepGroupMap;\n}\n\n/**\n * Find date fields with min/max constraints that need validation.\n */\nfunction findDateFieldsWithConstraints(\n jsonSchema: FormJsonSchema\n): Record {\n const dateFields: Record = {};\n const properties = jsonSchema.properties;\n \n if (!properties) return dateFields;\n \n for (const [key, prop] of Object.entries(properties)) {\n if (prop.type === \"string\" && prop.format === \"date-time\") {\n if (prop.formatMinimum || prop.formatMaximum) {\n dateFields[key] = {\n min: prop.formatMinimum,\n max: prop.formatMaximum,\n };\n }\n }\n }\n \n return dateFields;\n}\n\n/**\n * Add date constraint validations to a schema via superRefine.\n */\nfunction addDateValidations(\n schema: z.ZodType,\n dateFieldsWithConstraints: Record\n): z.ZodType {\n if (Object.keys(dateFieldsWithConstraints).length === 0) {\n return schema;\n }\n \n return schema.superRefine((data: any, ctx) => {\n for (const [key, constraints] of Object.entries(dateFieldsWithConstraints)) {\n const value = data[key];\n if (value === undefined || value === null || value === \"\") continue;\n \n const dateValue = new Date(value);\n if (isNaN(dateValue.getTime())) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: \"Invalid date\",\n path: [key],\n });\n continue;\n }\n \n if (constraints.min) {\n const minDate = new Date(constraints.min);\n if (dateValue < minDate) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Date must be after ${minDate.toLocaleDateString()}`,\n path: [key],\n });\n }\n }\n \n if (constraints.max) {\n const maxDate = new Date(constraints.max);\n if (dateValue > maxDate) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Date must be before ${maxDate.toLocaleDateString()}`,\n path: [key],\n });\n }\n }\n }\n });\n}\n\n/**\n * Re-attach steps metadata to a Zod schema via .meta().\n * This is necessary because z.fromJSONSchema() doesn't preserve custom properties.\n */\nfunction attachStepsMetadata(\n schema: z.ZodType,\n jsonSchema: FormJsonSchema\n): z.ZodType {\n const steps = jsonSchema.steps;\n if (!steps || steps.length === 0) {\n return schema;\n }\n \n // Get stepGroupMap from either root level or extract from properties\n const stepGroupMap = jsonSchema.stepGroupMap ?? extractStepGroupMap(jsonSchema);\n \n return schema.meta({\n steps,\n stepGroupMap,\n });\n}\n\n/**\n * Convert JSON Schema to Zod schema with proper handling for:\n * - { type: \"string\", format: \"date-time\" } → date field (with constraints)\n * - Steps metadata re-attachment (preserved via .meta())\n * - Step group mapping\n * \n * @param jsonSchema - The JSON Schema to convert\n * @returns A Zod schema ready for validation, with all metadata preserved\n */\nexport function formSchemaToZod(jsonSchema: FormJsonSchema): z.ZodType {\n // 1. Create base schema from JSON Schema\n let schema = z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);\n\n // 2. z.fromJSONSchema creates strict ZodObjects that reject unknown keys with\n // an 'unrecognized_keys' error. This breaks form save when parsedData\n // contains fields added to the Zod schema after the content type was last\n // synced to the DB. Apply passthrough() so unknown keys are preserved\n // rather than rejected. We deliberately do NOT use strip() here: stripping\n // would silently drop values for fields that exist in the live Zod schema\n // but not yet in the stale stored JSON schema, losing data on save.\n if (schema && typeof (schema as z.ZodObject).passthrough === \"function\") {\n schema = (schema as z.ZodObject).passthrough();\n }\n \n // 3. Add date constraint validations\n const dateFieldsWithConstraints = findDateFieldsWithConstraints(jsonSchema);\n schema = addDateValidations(schema, dateFieldsWithConstraints);\n \n // 4. Re-attach steps metadata so SteppedAutoForm can extract it\n schema = attachStepsMetadata(schema, jsonSchema);\n \n return schema;\n}\n\n// ============================================================================\n// UTILITY FUNCTIONS\n// ============================================================================\n\n/**\n * Check if a JSON Schema has multi-step configuration.\n */\nexport function hasSteps(jsonSchema: FormJsonSchema): boolean {\n return Array.isArray(jsonSchema.steps) && jsonSchema.steps.length > 0;\n}\n\n/**\n * Get steps from a JSON Schema.\n */\nexport function getSteps(jsonSchema: FormJsonSchema): FormStep[] {\n return jsonSchema.steps ?? [];\n}\n\n/**\n * Get the step group map from a JSON Schema.\n * Returns a map of field names to step indices.\n */\nexport function getStepGroupMap(jsonSchema: FormJsonSchema): Record {\n return jsonSchema.stepGroupMap ?? extractStepGroupMap(jsonSchema);\n}\n\n", "target": "src/lib/schema-converter.ts" + }, + { + "path": "ui/components/page-layout.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PageLayoutProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\t\"data-testid\"?: string;\n}\n\n/**\n * Shared page layout component providing consistent container styling\n * for plugin pages. Used by blog, CMS, and other plugins.\n */\nexport function PageLayout({\n\tchildren,\n\tclassName,\n\t\"data-testid\": dataTestId,\n}: PageLayoutProps) {\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/page-layout.tsx" + }, + { + "path": "ui/components/stack-attribution.tsx", + "type": "registry:component", + "content": "export function StackAttribution() {\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\tPowered by{\" \"}\n\t\t\t\t\n\t\t\t\t\tBTST\n\t\t\t\t\n\t\t\t

\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/stack-attribution.tsx" } ], "docs": "https://better-stack.ai/docs/plugins/form-builder" diff --git a/packages/stack/registry/btst-ui-builder.json b/packages/stack/registry/btst-ui-builder.json index 43883db4..77aceb22 100644 --- a/packages/stack/registry/btst-ui-builder.json +++ b/packages/stack/registry/btst-ui-builder.json @@ -126,6 +126,18 @@ "type": "registry:component", "content": "\"use client\";\n\nimport { PageLayout } from \"./page-layout\";\nimport { StackAttribution } from \"./stack-attribution\";\n\nexport interface PageWrapperProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n}\n\n/**\n * Shared page wrapper component providing consistent layout and optional attribution\n * for plugin pages. Used by blog, CMS, and other plugins.\n *\n * @example\n * ```tsx\n * \n *
\n *

My Page

\n *
\n *
\n * ```\n */\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n\tshowAttribution = true,\n}: PageWrapperProps) {\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\n\t\t\t{showAttribution && }\n\t\t\n\t);\n}\n", "target": "src/components/ui/page-wrapper.tsx" + }, + { + "path": "ui/components/page-layout.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface PageLayoutProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\t\"data-testid\"?: string;\n}\n\n/**\n * Shared page layout component providing consistent container styling\n * for plugin pages. Used by blog, CMS, and other plugins.\n */\nexport function PageLayout({\n\tchildren,\n\tclassName,\n\t\"data-testid\": dataTestId,\n}: PageLayoutProps) {\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/page-layout.tsx" + }, + { + "path": "ui/components/stack-attribution.tsx", + "type": "registry:component", + "content": "export function StackAttribution() {\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\tPowered by{\" \"}\n\t\t\t\t\n\t\t\t\t\tBTST\n\t\t\t\t\n\t\t\t

\n\t\t
\n\t);\n}\n", + "target": "src/components/ui/stack-attribution.tsx" } ], "docs": "https://better-stack.ai/docs/plugins/ui-builder" diff --git a/packages/stack/scripts/build-registry.ts b/packages/stack/scripts/build-registry.ts index c20278b2..299a1684 100644 --- a/packages/stack/scripts/build-registry.ts +++ b/packages/stack/scripts/build-registry.ts @@ -67,6 +67,12 @@ const EXTERNAL_REGISTRY_COMPONENTS: Record = { // them external lets consumers and this monorepo sync from upstream cleanly. const EXTERNAL_ONLY_REGISTRY_COMPONENTS = new Set(["ui-builder"]); +// Single-file workspace components can still depend on sibling components via +// relative imports, which the @workspace/ui import scanner cannot discover. +const EMBEDDED_COMPONENT_DEPENDENCIES: Record = { + "page-wrapper": ["page-layout", "stack-attribution"], +}; + // --------------------------------------------------------------------------- // Standard shadcn component names // These go into registryDependencies, not as embedded files. @@ -873,6 +879,11 @@ async function resolveWorkspaceUiDeps( pendingComponents.delete(comp); if (processedComponents.has(comp)) continue; processedComponents.add(comp); + for (const dependency of EMBEDDED_COMPONENT_DEPENDENCIES[comp] ?? []) { + if (!processedComponents.has(dependency)) { + pendingComponents.add(dependency); + } + } // Deep path (e.g. "auto-form/stepped-auto-form"): // - The top-level name (e.g. "auto-form") may be an external registry item. diff --git a/packages/stack/scripts/test-registry.sh b/packages/stack/scripts/test-registry.sh index 0ae086ba..2f356c5d 100755 --- a/packages/stack/scripts/test-registry.sh +++ b/packages/stack/scripts/test-registry.sh @@ -346,14 +346,37 @@ import { PageListPage } from "@/components/btst/ui-builder/client/components/pag import { LibraryPageComponent } from "@/components/btst/media/client/components/pages/library-page"; // Suppress unused-import warnings while still forcing TS to resolve everything. -void [HomePageComponent, ChatPageComponent, DashboardPageComponent, - FormListPageComponent, BoardsListPageComponent, ModerationPageComponent, PageListPage, - LibraryPageComponent]; +void HomePageComponent; +void ChatPageComponent; +void DashboardPageComponent; +void FormListPageComponent; +void BoardsListPageComponent; +void ModerationPageComponent; +void PageListPage; +void LibraryPageComponent; export default function SmokeTestPage() { return
Registry smoke test — all plugin imports resolved.
; } SMOKE_EOF + + # Registry installs with unavailable external dependencies are explicitly + # non-critical above. Do not leave their missing imports in the smoke page; + # every registry item that did install is still compiled by the build. + for FAILED_PLUGIN in "${INSTALL_FAILURES[@]}"; do + case "$FAILED_PLUGIN" in + ui-builder) FAILED_SYMBOL="PageListPage" ;; + blog) FAILED_SYMBOL="HomePageComponent" ;; + ai-chat) FAILED_SYMBOL="ChatPageComponent" ;; + cms) FAILED_SYMBOL="DashboardPageComponent" ;; + form-builder) FAILED_SYMBOL="FormListPageComponent" ;; + kanban) FAILED_SYMBOL="BoardsListPageComponent" ;; + comments) FAILED_SYMBOL="ModerationPageComponent" ;; + media) FAILED_SYMBOL="LibraryPageComponent" ;; + *) continue ;; + esac + sed -i "/${FAILED_SYMBOL}/d" src/app/btst-smoke-test/page.tsx + done success "Smoke-import page created at src/app/btst-smoke-test/page.tsx" # ------------------------------------------------------------------ @@ -373,4 +396,4 @@ SMOKE_EOF echo -e "Test project: ${YELLOW}$TEST_DIR/test-app${NC}" } -main "$@" \ No newline at end of file +main "$@" diff --git a/packages/stack/src/context/provider.tsx b/packages/stack/src/context/provider.tsx index a48e81ba..4803208a 100644 --- a/packages/stack/src/context/provider.tsx +++ b/packages/stack/src/context/provider.tsx @@ -35,6 +35,8 @@ interface StackContextValue> { * Top-level API config applied to all plugins. */ api?: StackApiConfig; + /** Top-level auth provider applied to plugin compatibility fields. */ + auth?: StackAuthProvider; } const StackContext = createContext | null>(null); @@ -190,6 +192,7 @@ export function StackProvider< overrides: overrides ?? {}, basePath, api, + auth, }; const content = auth ? ( @@ -303,17 +306,17 @@ export function usePluginOverrides< const pluginOverrides = context.overrides[pluginName]; // Resolution order (lowest to highest precedence): - // hook defaults -> top-level router/api -> per-plugin overrides - const { router, api } = context; - if (!router && !api) { - // No top-level router/api configured — behave exactly as before + // hook defaults -> top-level router/api/auth -> per-plugin overrides + const { router, api, auth } = context; + if (!router && !api && !auth) { + // No top-level provider config — behave exactly as before const overrides = defaultValues ? { ...defaultValues, ...pluginOverrides } : pluginOverrides; return overrides as OverridesResult; } - const routerApiLayer = stripUndefined({ + const providerLayer = stripUndefined({ Link: router?.Link, Image: router?.Image, navigate: router?.navigate, @@ -322,11 +325,12 @@ export function usePluginOverrides< setSearchParams: router?.setSearchParams, apiBaseURL: api?.baseURL, apiBasePath: api?.basePath, + loginHref: auth?.loginPath, }); const overrides = { ...defaultValues, - ...routerApiLayer, + ...providerLayer, ...pluginOverrides, }; diff --git a/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx index f5d33df8..179e6df7 100644 --- a/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx @@ -14,6 +14,7 @@ import { import { ModerationPage } from "../client/components/pages/moderation-page.internal"; import { UserCommentsPage } from "../client/components/pages/my-comments-page.internal"; import { CommentForm } from "../client/components/comment-form"; +import { CommentThread } from "../client/components/comment-thread"; import type { SerializedComment } from "../types"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -82,6 +83,19 @@ beforeEach(() => { total: 1, refetch: vi.fn(), }); + hooks.useInfiniteComments.mockReturnValue({ + comments: [], + total: 0, + isLoading: false, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + queryKey: ["comments", "infinite"], + }); + hooks.usePostComment.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); hooks.useUpdateCommentStatus.mockReturnValue({ mutateAsync: vi.fn().mockResolvedValue(comment), isPending: false, @@ -328,12 +342,14 @@ describe("UserCommentsPage (login gate + useNotify + useListState)", () => { error: ReturnType; }, router = createMockRouter(), + auth?: StackAuthProvider, ) { return render( @@ -362,6 +378,33 @@ describe("UserCommentsPage (login gate + useNotify + useListState)", () => { ); }); + it("uses the top-level auth identity when the legacy user prop is omitted", async () => { + await renderUserComments(undefined, undefined, createMockRouter(), { + getIdentity: () => ({ id: "provider-user" }), + }); + await act(async () => {}); + + expect(hooks.useSuspenseComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ authorId: "provider-user" }), + ); + expect( + container.querySelector('[data-testid="my-comments-login-prompt"]'), + ).toBeNull(); + }); + + it("keeps the explicit legacy user prop above the provider identity", async () => { + await renderUserComments("legacy-user", undefined, createMockRouter(), { + getIdentity: () => ({ id: "provider-user" }), + }); + await act(async () => {}); + + expect(hooks.useSuspenseComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ authorId: "legacy-user" }), + ); + }); + it("notifies success through the notify provider after deleting", async () => { const notify = { success: vi.fn(), error: vi.fn() }; @@ -393,6 +436,84 @@ describe("UserCommentsPage (login gate + useNotify + useListState)", () => { }); }); +describe("CommentThread provider defaults", () => { + it("uses top-level API and auth when legacy props are omitted", async () => { + await render( + ({ id: "provider-user" }), + loginPath: "/sign-in", + }} + > + + , + ); + await act(async () => {}); + + expect(hooks.useInfiniteComments).toHaveBeenLastCalledWith( + { + apiBaseURL: "http://provider.local", + apiBasePath: "/api/stack", + headers: undefined, + }, + expect.objectContaining({ currentUserId: "provider-user" }), + ); + expect( + container.querySelector('[data-testid="comment-form-wrapper"]'), + ).toBeTruthy(); + }); + + it("keeps explicit legacy props above provider defaults", async () => { + await render( + ({ id: "provider-user" }) }} + > + + , + ); + await act(async () => {}); + + expect(hooks.useInfiniteComments).toHaveBeenLastCalledWith( + { + apiBaseURL: "http://legacy.local", + apiBasePath: "/api/legacy", + headers: { "x-test-user": "legacy-user" }, + }, + expect.objectContaining({ currentUserId: "legacy-user" }), + ); + }); + + it("uses the top-level auth login path when unauthenticated", async () => { + await render( + null, loginPath: "/sign-in" }} + > + + , + ); + await act(async () => {}); + + expect( + container + .querySelector('[data-testid="login-link"]') + ?.getAttribute("href"), + ).toBe("/sign-in"); + }); +}); + describe("CommentForm inline field errors (StackError)", () => { function renderForm(onSubmit: (body: string) => Promise) { return render( diff --git a/packages/stack/src/plugins/comments/client/components/comment-thread.tsx b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx index 1dfc51eb..30ae4225 100644 --- a/packages/stack/src/plugins/comments/client/components/comment-thread.tsx +++ b/packages/stack/src/plugins/comments/client/components/comment-thread.tsx @@ -21,7 +21,7 @@ import { } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import type { SerializedComment } from "../../types"; -import { getInitials } from "../utils"; +import { getInitials, useResolvedCurrentUserId } from "../utils"; import { CommentForm } from "./comment-form"; import { useComments, @@ -59,11 +59,14 @@ export interface CommentThreadProps { resourceId: string; /** Discriminates resources across plugins (e.g. "blog-post", "kanban-task") */ resourceType: string; - /** Base URL for API calls */ - apiBaseURL: string; - /** Path where the API is mounted */ - apiBasePath: string; - /** Currently authenticated user ID. Omit for read-only / unauthenticated. */ + /** Base URL for API calls. Defaults to the top-level StackProvider API. */ + apiBaseURL?: string; + /** Path where the API is mounted. Defaults to the top-level StackProvider API. */ + apiBasePath?: string; + /** + * Currently authenticated user ID. Defaults to the top-level StackProvider + * identity. Omit for read-only / unauthenticated when no auth provider exists. + */ currentUserId?: string; /** * URL to redirect unauthenticated users to. @@ -108,6 +111,14 @@ export interface CommentThreadProps { sort?: "asc" | "desc"; } +type ResolvedCommentThreadProps = Omit< + CommentThreadProps, + "apiBaseURL" | "apiBasePath" +> & { + apiBaseURL: string; + apiBasePath: string; +}; + const DEFAULT_RENDERER: ComponentType = ({ body }) => (

{body}

); @@ -348,7 +359,7 @@ function CommentThreadInner({ allowPosting: allowPostingProp, allowEditing: allowEditingProp, sort: sortProp, -}: CommentThreadProps) { +}: ResolvedCommentThreadProps) { const t = useTranslate(); const overrides = usePluginOverrides< CommentsPluginOverrides, @@ -792,17 +803,13 @@ function RepliesSection({ * Embeddable threaded comment section. * * Lazy-mounts when the component scrolls into the viewport (via WhenVisible). - * Requires `currentUserId` to allow posting; shows a "Please login" prompt otherwise. + * Uses the top-level StackProvider API and auth configuration by default. * * @example * ```tsx * * ``` */ @@ -849,10 +856,24 @@ function CommentThreadSkeleton() { } export function CommentThread(props: CommentThreadProps) { + const overrides = usePluginOverrides< + CommentsPluginOverrides, + Partial + >("comments", {}); + const currentUserId = useResolvedCurrentUserId(props.currentUserId); + const resolvedProps: ResolvedCommentThreadProps = { + ...props, + apiBaseURL: props.apiBaseURL ?? overrides.apiBaseURL ?? "", + apiBasePath: props.apiBasePath ?? overrides.apiBasePath ?? "", + currentUserId, + loginHref: props.loginHref ?? overrides.loginHref, + headers: props.headers ?? overrides.headers, + }; + return (
} rootMargin="300px"> - +
); diff --git a/packages/stack/src/plugins/comments/client/overrides.ts b/packages/stack/src/plugins/comments/client/overrides.ts index badf5557..4a497372 100644 --- a/packages/stack/src/plugins/comments/client/overrides.ts +++ b/packages/stack/src/plugins/comments/client/overrides.ts @@ -54,7 +54,8 @@ export interface CommentsPluginOverrides { * Can be a static string or an async function (useful when the user ID must * be resolved from a session cookie at render time). * - * When absent both pages show a "Please log in" prompt. + * When absent, defaults to the identity from the top-level auth provider. + * Without either value both pages show a "Please log in" prompt. */ currentUserId?: | string @@ -64,7 +65,8 @@ export interface CommentsPluginOverrides { * URL to redirect unauthenticated users to when they try to post a comment. * * Forwarded to every embedded `CommentThread` (including the one on the - * per-resource admin comments view). When absent no login link is shown. + * per-resource admin comments view). When absent, defaults to the top-level + * auth provider's `loginPath`. */ loginHref?: string; diff --git a/packages/stack/src/plugins/comments/client/utils.ts b/packages/stack/src/plugins/comments/client/utils.ts index 39495a8f..d1affb2c 100644 --- a/packages/stack/src/plugins/comments/client/utils.ts +++ b/packages/stack/src/plugins/comments/client/utils.ts @@ -1,15 +1,22 @@ import { useState, useEffect } from "react"; +import { useIdentity } from "@btst/stack/context"; import type { CommentsPluginOverrides } from "./overrides"; /** - * Resolves `currentUserId` from the plugin overrides, supporting both a static - * string and a sync/async function. Returns `undefined` until resolution completes. + * Resolves the legacy `currentUserId` override when provided, otherwise uses + * the identity from the top-level Stack auth provider. */ export function useResolvedCurrentUserId( raw: CommentsPluginOverrides["currentUserId"], ): string | undefined { + const { identity } = useIdentity(); + const providerUserId = identity?.id; const [resolved, setResolved] = useState( - typeof raw === "string" ? raw : undefined, + typeof raw === "string" + ? raw + : raw === undefined + ? providerUserId + : undefined, ); useEffect(() => { @@ -22,10 +29,12 @@ export function useResolvedCurrentUserId( err, ); }); + } else if (typeof raw === "string") { + setResolved(raw); } else { - setResolved(raw ?? undefined); + setResolved(providerUserId); } - }, [raw]); + }, [providerUserId, raw]); return resolved; } diff --git a/scripts/codegen/files/nextjs/app/pages/layout.tsx b/scripts/codegen/files/nextjs/app/pages/layout.tsx index 2ace5f6f..c34b7d44 100644 --- a/scripts/codegen/files/nextjs/app/pages/layout.tsx +++ b/scripts/codegen/files/nextjs/app/pages/layout.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useState } from "react"; -import { StackProvider } from "@btst/stack/context"; +import { StackProvider, type StackAuthProvider } from "@btst/stack/context"; import { nextRouter } from "@btst/stack/next"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -48,6 +48,11 @@ type PluginOverrides = { media: MediaPluginOverrides; }; +const authProvider = { + getIdentity: () => ({ id: "olliethedev", name: "Ollie" }), + loginPath: "/login", +} satisfies StackAuthProvider; + export default function ExampleLayout({ children, }: { @@ -93,6 +98,7 @@ export default function ExampleLayout({ basePath="/pages" router={nextRouter()} api={{ baseURL, basePath: "/api/data" }} + auth={authProvider} overrides={{ // Only genuinely plugin-specific overrides remain — the shared // Link/navigate/refresh/Image and API wiring come from the @@ -106,11 +112,6 @@ export default function ExampleLayout({ ), @@ -142,27 +143,17 @@ export default function ExampleLayout({ searchUsers, // Wire comments into the bottom of each task detail dialog taskDetailBottomSlot: (task) => ( - + ), }, comments: { - // In production: derive from your auth session - currentUserId: "olliethedev", defaultCommentPageSize: 5, resourceLinks: { "blog-post": (slug) => `/pages/blog/${slug}`, }, }, media: { - ...mediaClientConfig, + uploadMode: "direct", queryClient, }, }} diff --git a/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx b/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx index 8621b9c6..ce5b4ac5 100644 --- a/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx +++ b/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { Outlet } from "react-router"; -import { StackProvider } from "@btst/stack/context"; +import { StackProvider, type StackAuthProvider } from "@btst/stack/context"; import { reactRouter } from "@btst/stack/react-router"; import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"; import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client"; @@ -46,6 +46,11 @@ type PluginOverrides = { media: MediaPluginOverrides; }; +const authProvider = { + getIdentity: () => ({ id: "olliethedev", name: "Ollie" }), + loginPath: "/login", +} satisfies StackAuthProvider; + export default function Layout() { const baseURL = getBaseURL(); const [queryClient] = useState(() => getOrCreateQueryClient()); @@ -84,6 +89,7 @@ export default function Layout() { basePath="/pages" router={reactRouter()} api={{ baseURL, basePath: "/api/data" }} + auth={authProvider} overrides={{ // Only genuinely plugin-specific overrides remain — the shared // Link/navigate/refresh and API wiring come from the top-level @@ -100,11 +106,6 @@ export default function Layout() { ), @@ -125,26 +126,17 @@ export default function Layout() { searchUsers, // Wire comments into task detail dialogs taskDetailBottomSlot: (task) => ( - + ), }, comments: { - currentUserId: "olliethedev", defaultCommentPageSize: 5, resourceLinks: { "blog-post": (slug) => `/pages/blog/${slug}`, }, }, media: { - ...mediaClientConfig, + uploadMode: "direct", queryClient, }, }} diff --git a/scripts/codegen/files/tanstack/src/routes/pages/route.tsx b/scripts/codegen/files/tanstack/src/routes/pages/route.tsx index 709e8784..757f0c1c 100644 --- a/scripts/codegen/files/tanstack/src/routes/pages/route.tsx +++ b/scripts/codegen/files/tanstack/src/routes/pages/route.tsx @@ -1,4 +1,4 @@ -import { StackProvider } from "@btst/stack/context"; +import { StackProvider, type StackAuthProvider } from "@btst/stack/context"; import { tanstackRouter } from "@btst/stack/tanstack"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -47,6 +47,11 @@ type PluginOverrides = { media: MediaPluginOverrides; }; +const authProvider = { + getIdentity: () => ({ id: "olliethedev", name: "Ollie" }), + loginPath: "/login", +} satisfies StackAuthProvider; + export const Route = createFileRoute("/pages")({ component: Layout, notFoundComponent: () => { @@ -94,6 +99,7 @@ function Layout() { basePath="/pages" router={tanstackRouter()} api={{ baseURL, basePath: "/api/data" }} + auth={authProvider} overrides={{ // Only genuinely plugin-specific overrides remain — the shared // Link/navigate/refresh and API wiring come from the top-level @@ -110,11 +116,6 @@ function Layout() { ), @@ -135,26 +136,17 @@ function Layout() { searchUsers, // Wire comments into task detail dialogs taskDetailBottomSlot: (task) => ( - + ), }, comments: { - currentUserId: "olliethedev", defaultCommentPageSize: 5, resourceLinks: { "blog-post": (slug) => `/pages/blog/${slug}`, }, }, media: { - ...mediaClientConfig, + uploadMode: "direct", queryClient: routeContext.queryClient, }, }} From 55bd22e27c0a807c2fcbd02ce6eeee8a7562b745 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:00:52 +0000 Subject: [PATCH 2/2] fix: preserve comments identity precedence --- packages/stack/registry/btst-comments.json | 8 +-- .../comments/__tests__/client-sweep.test.tsx | 54 ++++++++++++++++ .../client/components/comment-thread.tsx | 9 ++- .../pages/my-comments-page.internal.tsx | 15 ++++- .../pages/resource-comments-page.tsx | 10 ++- .../src/plugins/comments/client/utils.ts | 64 +++++++++++-------- 6 files changed, 126 insertions(+), 34 deletions(-) diff --git a/packages/stack/registry/btst-comments.json b/packages/stack/registry/btst-comments.json index 5ca1d1c9..03bbcd31 100644 --- a/packages/stack/registry/btst-comments.json +++ b/packages/stack/registry/btst-comments.json @@ -48,7 +48,7 @@ { "path": "btst/comments/client/components/comment-thread.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials, useResolvedCurrentUserId } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../localization\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls. Defaults to the top-level StackProvider API. */\n\tapiBaseURL?: string;\n\t/** Path where the API is mounted. Defaults to the top-level StackProvider API. */\n\tapiBasePath?: string;\n\t/**\n\t * Currently authenticated user ID. Defaults to the top-level StackProvider\n\t * identity. Omit for read-only / unauthenticated when no auth provider exists.\n\t */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n\t/**\n\t * Sort direction for top-level comments by `createdAt`.\n\t * - `\"desc\"` (default): newest first.\n\t * - `\"asc\"`: oldest first.\n\t *\n\t * Replies inside each thread always render chronologically (oldest → newest)\n\t * and are unaffected by this prop.\n\t *\n\t * Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.\n\t */\n\tsort?: \"asc\" | \"desc\";\n}\n\ntype ResolvedCommentThreadProps = Omit<\n\tCommentThreadProps,\n\t\"apiBaseURL\" | \"apiBasePath\"\n> & {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n};\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t

{body}

\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tlocalization,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tconst confirmMessage =\n\t\t\tlocalization?.COMMENTS_DELETE_CONFIRM ??\n\t\t\tt(\"comments.thread.deleteConfirm\", \"Delete this comment?\");\n\t\tif (!window.confirm(confirmMessage)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_EDITED_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.editedBadge\", \"(edited)\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_PENDING_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.pendingBadge\", \"Pending approval\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_REPLY_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.replyButton\", \"Reply\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_EDIT_BUTTON ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.editButton\", \"Edit\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_DELETE_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.deleteButton\", \"Delete\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n\tsort: sortProp,\n}: ResolvedCommentThreadProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst sort = sortProp ?? overrides.defaultCommentSort ?? \"desc\";\n\t// Per-instance prop wins over the plugin-level override strings; missing\n\t// keys fall through to `t()` inside each child component.\n\tconst localization = { ...overrides.localization, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tsort,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t\tsort,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{(() => {\n\t\t\t\t\t\tconst title =\n\t\t\t\t\t\t\tlocalization?.COMMENTS_TITLE ??\n\t\t\t\t\t\t\tt(\"comments.thread.title\", \"Comments\");\n\t\t\t\t\t\treturn total === 0 ? title : `${total} ${title}`;\n\t\t\t\t\t})()}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{isLoading && (\n\t\t\t\t
\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_EMPTY ??\n\t\t\t\t\t\tt(\"comments.thread.empty\", \"Be the first to comment.\")}\n\t\t\t\t

\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t
\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_PROMPT ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.thread.loginPrompt\",\n\t\t\t\t\t\t\t\t\t\t\"Please sign in to leave a comment.\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_LINK ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loginLink\", \"Sign in\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tlocalization,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t
\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? (localization?.COMMENTS_HIDE_REPLIES ??\n\t\t\t\t\t\tt(\"comments.thread.hideReplies\", \"Hide replies\"))\n\t\t\t\t\t: `${displayCount} ${\n\t\t\t\t\t\t\tdisplayCount === 1\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_REPLIES_SINGULAR ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesSingular\", \"reply\"))\n\t\t\t\t\t\t\t\t: (localization?.COMMENTS_REPLIES_PLURAL ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesPlural\", \"replies\"))\n\t\t\t\t\t\t}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Uses the top-level StackProvider API and auth configuration by default.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t
\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst currentUserId = useResolvedCurrentUserId(props.currentUserId);\n\tconst resolvedProps: ResolvedCommentThreadProps = {\n\t\t...props,\n\t\tapiBaseURL: props.apiBaseURL ?? overrides.apiBaseURL ?? \"\",\n\t\tapiBasePath: props.apiBasePath ?? overrides.apiBasePath ?? \"\",\n\t\tcurrentUserId,\n\t\tloginHref: props.loginHref ?? overrides.loginHref,\n\t\theaders: props.headers ?? overrides.headers,\n\t};\n\n\treturn (\n\t\t
\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useState, type ComponentType } from \"react\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tHeart,\n\tMessageSquare,\n\tPencil,\n\tX,\n\tLogIn,\n\tChevronDown,\n\tChevronUp,\n} from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport type { SerializedComment } from \"../../types\";\nimport { getInitials, useResolvedCurrentUserId } from \"../utils\";\nimport { CommentForm } from \"./comment-form\";\nimport {\n\tuseComments,\n\tuseInfiniteComments,\n\tusePostComment,\n\tuseUpdateComment,\n\tuseDeleteComment,\n\tuseToggleLike,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../localization\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../overrides\";\n\n/** Custom input component props */\nexport interface CommentInputProps {\n\tvalue: string;\n\tonChange: (value: string) => void;\n\tdisabled?: boolean;\n\tplaceholder?: string;\n}\n\n/** Custom renderer component props */\nexport interface CommentRendererProps {\n\tbody: string;\n}\n\n/** Override slot for custom input + renderer */\nexport interface CommentComponents {\n\tInput?: ComponentType;\n\tRenderer?: ComponentType;\n}\n\nexport interface CommentThreadProps {\n\t/** The resource this thread is attached to (e.g. post slug, task ID) */\n\tresourceId: string;\n\t/** Discriminates resources across plugins (e.g. \"blog-post\", \"kanban-task\") */\n\tresourceType: string;\n\t/** Base URL for API calls. Defaults to the top-level StackProvider API. */\n\tapiBaseURL?: string;\n\t/** Path where the API is mounted. Defaults to the top-level StackProvider API. */\n\tapiBasePath?: string;\n\t/**\n\t * Currently authenticated user ID. Defaults to the top-level StackProvider\n\t * identity. Omit for read-only / unauthenticated when no auth provider exists.\n\t */\n\tcurrentUserId?: string;\n\t/**\n\t * URL to redirect unauthenticated users to.\n\t * When provided and currentUserId is absent, shows a \"Please login to comment\" prompt.\n\t */\n\tloginHref?: string;\n\t/** Optional HTTP headers for API calls (e.g. forwarding cookies) */\n\theaders?: HeadersInit;\n\t/** Swap in custom Input / Renderer components */\n\tcomponents?: CommentComponents;\n\t/** Optional className applied to the root wrapper */\n\tclassName?: string;\n\t/** Localization strings — defaults to English */\n\tlocalization?: Partial;\n\t/**\n\t * Number of top-level comments to load per page.\n\t * Clicking \"Load more\" fetches the next page. Default: 10.\n\t */\n\tpageSize?: number;\n\t/**\n\t * When false, the comment form and reply buttons are hidden.\n\t * Overrides the global `allowPosting` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowPosting?: boolean;\n\t/**\n\t * When false, the edit button is hidden on comment cards.\n\t * Overrides the global `allowEditing` from `CommentsPluginOverrides`.\n\t * Defaults to true.\n\t */\n\tallowEditing?: boolean;\n\t/**\n\t * Sort direction for top-level comments by `createdAt`.\n\t * - `\"desc\"` (default): newest first.\n\t * - `\"asc\"`: oldest first.\n\t *\n\t * Replies inside each thread always render chronologically (oldest → newest)\n\t * and are unaffected by this prop.\n\t *\n\t * Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.\n\t */\n\tsort?: \"asc\" | \"desc\";\n}\n\ntype ResolvedCommentThreadProps = Omit<\n\tCommentThreadProps,\n\t\"apiBaseURL\" | \"apiBasePath\"\n> & {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n};\n\nconst DEFAULT_RENDERER: ComponentType = ({ body }) => (\n\t

{body}

\n);\n\n// ─── Comment Card ─────────────────────────────────────────────────────────────\n\nfunction CommentCard({\n\tcomment,\n\tcurrentUserId,\n\tapiBaseURL,\n\tapiBasePath,\n\tresourceId,\n\tresourceType,\n\theaders,\n\tcomponents,\n\tlocalization,\n\tinfiniteKey,\n\tonReplyClick,\n\tallowPosting,\n\tallowEditing,\n}: {\n\tcomment: SerializedComment;\n\tcurrentUserId?: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tresourceId: string;\n\tresourceType: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\t/** Infinite thread query key — pass for top-level comments so like optimistic\n\t * updates target the correct InfiniteData cache entry. */\n\tinfiniteKey?: readonly unknown[];\n\tonReplyClick: (parentId: string) => void;\n\tallowPosting: boolean;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst [isEditing, setIsEditing] = useState(false);\n\tconst Renderer = components?.Renderer ?? DEFAULT_RENDERER;\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst updateMutation = useUpdateComment(config);\n\tconst deleteMutation = useDeleteComment(config);\n\tconst toggleLikeMutation = useToggleLike(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tparentId: comment.parentId,\n\t\tcurrentUserId,\n\t\tinfiniteKey,\n\t});\n\n\tconst isOwn = currentUserId && comment.authorId === currentUserId;\n\tconst isPending = comment.status === \"pending\";\n\tconst isApproved = comment.status === \"approved\";\n\n\tconst handleEdit = async (body: string) => {\n\t\tawait updateMutation.mutateAsync({ id: comment.id, body });\n\t\tsetIsEditing(false);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tconst confirmMessage =\n\t\t\tlocalization?.COMMENTS_DELETE_CONFIRM ??\n\t\t\tt(\"comments.thread.deleteConfirm\", \"Delete this comment?\");\n\t\tif (!window.confirm(confirmMessage)) return;\n\t\tawait deleteMutation.mutateAsync(comment.id);\n\t};\n\n\tconst handleLike = () => {\n\t\tif (!currentUserId) return;\n\t\ttoggleLikeMutation.mutate({\n\t\t\tcommentId: comment.id,\n\t\t\tauthorId: currentUserId,\n\t\t});\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\n\t\t\t\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resolvedAuthorName}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), {\n\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t})}\n\t\t\t\t\t\n\t\t\t\t\t{comment.editedAt && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_EDITED_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.editedBadge\", \"(edited)\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{isPending && isOwn && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_PENDING_BADGE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.pendingBadge\", \"Pending approval\")}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{isEditing ? (\n\t\t\t\t\t setIsEditing(false)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\n\t\t\t\t{!isEditing && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{currentUserId && isApproved && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{comment.likes > 0 && (\n\t\t\t\t\t\t\t\t\t{comment.likes}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{allowPosting &&\n\t\t\t\t\t\t\tcurrentUserId &&\n\t\t\t\t\t\t\t!comment.parentId &&\n\t\t\t\t\t\t\tisApproved && (\n\t\t\t\t\t\t\t\t onReplyClick(comment.id)}\n\t\t\t\t\t\t\t\t\tdata-testid=\"reply-button\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_REPLY_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.replyButton\", \"Reply\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOwn && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{allowEditing && isApproved && (\n\t\t\t\t\t\t\t\t\t setIsEditing(true)}\n\t\t\t\t\t\t\t\t\t\tdata-testid=\"edit-button\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_EDIT_BUTTON ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.editButton\", \"Edit\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_DELETE_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.deleteButton\", \"Delete\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n// ─── Thread Inner (handles data) ──────────────────────────────────────────────\n\nconst DEFAULT_PAGE_SIZE = 100;\nconst REPLIES_PAGE_SIZE = 20;\nconst OPTIMISTIC_ID_PREFIX = \"optimistic-\";\n\nfunction CommentThreadInner({\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\tloginHref,\n\theaders,\n\tcomponents,\n\tlocalization: localizationProp,\n\tpageSize: pageSizeProp,\n\tallowPosting: allowPostingProp,\n\tallowEditing: allowEditingProp,\n\tsort: sortProp,\n}: ResolvedCommentThreadProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst pageSize =\n\t\tpageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;\n\tconst allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;\n\tconst allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;\n\tconst sort = sortProp ?? overrides.defaultCommentSort ?? \"desc\";\n\t// Per-instance prop wins over the plugin-level override strings; missing\n\t// keys fall through to `t()` inside each child component.\n\tconst localization = { ...overrides.localization, ...localizationProp };\n\tconst [replyingTo, setReplyingTo] = useState(null);\n\tconst [expandedReplies, setExpandedReplies] = useState>(\n\t\tnew Set(),\n\t);\n\tconst [replyOffsets, setReplyOffsets] = useState>({});\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\n\tconst {\n\t\tcomments,\n\t\ttotal,\n\t\tisLoading,\n\t\tloadMore,\n\t\thasMore,\n\t\tisLoadingMore,\n\t\tqueryKey: threadQueryKey,\n\t} = useInfiniteComments(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tstatus: \"approved\",\n\t\tparentId: null,\n\t\tcurrentUserId,\n\t\tsort,\n\t\tpageSize,\n\t});\n\n\tconst postMutation = usePostComment(config, {\n\t\tresourceId,\n\t\tresourceType,\n\t\tcurrentUserId,\n\t\tinfiniteKey: threadQueryKey,\n\t\tpageSize,\n\t\tsort,\n\t});\n\n\tconst handlePost = async (body: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId: null,\n\t\t});\n\t};\n\n\tconst handleReply = async (body: string, parentId: string) => {\n\t\tif (!currentUserId) return;\n\t\tawait postMutation.mutateAsync({\n\t\t\tbody,\n\t\t\tparentId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffsets[parentId] ?? 0,\n\t\t});\n\t\tsetReplyingTo(null);\n\t\tsetExpandedReplies((prev) => new Set(prev).add(parentId));\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{(() => {\n\t\t\t\t\t\tconst title =\n\t\t\t\t\t\t\tlocalization?.COMMENTS_TITLE ??\n\t\t\t\t\t\t\tt(\"comments.thread.title\", \"Comments\");\n\t\t\t\t\t\treturn total === 0 ? title : `${total} ${title}`;\n\t\t\t\t\t})()}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{isLoading && (\n\t\t\t\t
\n\t\t\t\t\t{[1, 2].map((i) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length > 0 && (\n\t\t\t\t
\n\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tsetReplyingTo(replyingTo === parentId ? null : parentId);\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowPosting={allowPosting}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{/* Replies */}\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\tconst isExpanded = expandedReplies.has(comment.id);\n\t\t\t\t\t\t\t\t\tif (!isExpanded) {\n\t\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\t\tif ((prev[comment.id] ?? 0) === 0) return prev;\n\t\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: 0 };\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsetExpandedReplies((prev) => {\n\t\t\t\t\t\t\t\t\t\tconst next = new Set(prev);\n\t\t\t\t\t\t\t\t\t\tnext.has(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t? next.delete(comment.id)\n\t\t\t\t\t\t\t\t\t\t\t: next.add(comment.id);\n\t\t\t\t\t\t\t\t\t\treturn next;\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonOffsetChange={(offset) => {\n\t\t\t\t\t\t\t\t\tsetReplyOffsets((prev) => {\n\t\t\t\t\t\t\t\t\t\tif (prev[comment.id] === offset) return prev;\n\t\t\t\t\t\t\t\t\t\treturn { ...prev, [comment.id]: offset };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t{allowPosting && replyingTo === comment.id && currentUserId && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t handleReply(body, comment.id)}\n\t\t\t\t\t\t\t\t\t\tonCancel={() => setReplyingTo(null)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{!isLoading && comments.length === 0 && (\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_EMPTY ??\n\t\t\t\t\t\tt(\"comments.thread.empty\", \"Be the first to comment.\")}\n\t\t\t\t

\n\t\t\t)}\n\n\t\t\t{hasMore && (\n\t\t\t\t
\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t\tdata-testid=\"load-more-comments\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{allowPosting && (\n\t\t\t\t<>\n\t\t\t\t\t\n\n\t\t\t\t\t{currentUserId ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_PROMPT ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"comments.thread.loginPrompt\",\n\t\t\t\t\t\t\t\t\t\t\"Please sign in to leave a comment.\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{loginHref && (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.COMMENTS_LOGIN_LINK ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loginLink\", \"Sign in\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Replies Section ───────────────────────────────────────────────────────────\n\nfunction RepliesSection({\n\tparentId,\n\tresourceId,\n\tresourceType,\n\tapiBaseURL,\n\tapiBasePath,\n\tcurrentUserId,\n\theaders,\n\tcomponents,\n\tlocalization,\n\texpanded,\n\treplyCount,\n\tonToggle,\n\tonOffsetChange,\n\tallowEditing,\n}: {\n\tparentId: string;\n\tresourceId: string;\n\tresourceType: string;\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\tcurrentUserId?: string;\n\theaders?: HeadersInit;\n\tcomponents?: CommentComponents;\n\tlocalization?: Partial;\n\texpanded: boolean;\n\t/** Pre-computed from the parent comment — avoids an extra fetch on mount. */\n\treplyCount: number;\n\tonToggle: () => void;\n\tonOffsetChange: (offset: number) => void;\n\tallowEditing: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst [replyOffset, setReplyOffset] = useState(0);\n\tconst [loadedReplies, setLoadedReplies] = useState([]);\n\t// Only fetch reply bodies once the section is expanded.\n\tconst {\n\t\tcomments: repliesPage,\n\t\ttotal: repliesTotal,\n\t\tisFetching: isFetchingReplies,\n\t} = useComments(\n\t\tconfig,\n\t\t{\n\t\t\tresourceId,\n\t\t\tresourceType,\n\t\t\tparentId,\n\t\t\tstatus: \"approved\",\n\t\t\tcurrentUserId,\n\t\t\tlimit: REPLIES_PAGE_SIZE,\n\t\t\toffset: replyOffset,\n\t\t},\n\t\t{ enabled: expanded },\n\t);\n\n\tuseEffect(() => {\n\t\tif (expanded) {\n\t\t\tsetReplyOffset(0);\n\t\t\tsetLoadedReplies([]);\n\t\t}\n\t}, [expanded, parentId]);\n\n\tuseEffect(() => {\n\t\tonOffsetChange(replyOffset);\n\t}, [onOffsetChange, replyOffset]);\n\n\tuseEffect(() => {\n\t\tif (!expanded) return;\n\t\tsetLoadedReplies((prev) => {\n\t\t\tconst byId = new Map(prev.map((item) => [item.id, item]));\n\t\t\tfor (const reply of repliesPage) {\n\t\t\t\tbyId.set(reply.id, reply);\n\t\t\t}\n\n\t\t\t// Reconcile optimistic replies once the real server reply arrives with\n\t\t\t// a different id. Without this, both entries can persist in local state\n\t\t\t// until the section is collapsed and re-opened.\n\t\t\tconst currentPageIds = new Set(repliesPage.map((reply) => reply.id));\n\t\t\tconst currentPageRealReplies = repliesPage.filter(\n\t\t\t\t(reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),\n\t\t\t);\n\n\t\t\treturn Array.from(byId.values()).filter((reply) => {\n\t\t\t\tif (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;\n\t\t\t\t// Keep optimistic items still present in the current cache page.\n\t\t\t\tif (currentPageIds.has(reply.id)) return true;\n\t\t\t\t// Drop stale optimistic rows that have been replaced by a real reply.\n\t\t\t\treturn !currentPageRealReplies.some(\n\t\t\t\t\t(realReply) =>\n\t\t\t\t\t\trealReply.parentId === reply.parentId &&\n\t\t\t\t\t\trealReply.authorId === reply.authorId &&\n\t\t\t\t\t\trealReply.body === reply.body,\n\t\t\t\t);\n\t\t\t});\n\t\t});\n\t}, [expanded, repliesPage]);\n\n\t// Hide when there are no known replies — but keep rendered when already\n\t// expanded so a freshly-posted first reply (which increments replyCount\n\t// only after the server responds) stays visible in the same session.\n\tif (replyCount === 0 && !expanded) return null;\n\n\t// Prefer the fetched count (accurate after optimistic inserts); fall back to\n\t// the server-provided replyCount before the fetch completes.\n\tconst displayCount = expanded\n\t\t? loadedReplies.length || replyCount\n\t\t: replyCount;\n\tconst effectiveReplyTotal = repliesTotal || replyCount;\n\tconst hasMoreReplies = loadedReplies.length < effectiveReplyTotal;\n\n\treturn (\n\t\t
\n\t\t\t{/* Toggle button — always at the top so collapse is reachable without scrolling */}\n\t\t\t\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded\n\t\t\t\t\t? (localization?.COMMENTS_HIDE_REPLIES ??\n\t\t\t\t\t\tt(\"comments.thread.hideReplies\", \"Hide replies\"))\n\t\t\t\t\t: `${displayCount} ${\n\t\t\t\t\t\t\tdisplayCount === 1\n\t\t\t\t\t\t\t\t? (localization?.COMMENTS_REPLIES_SINGULAR ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesSingular\", \"reply\"))\n\t\t\t\t\t\t\t\t: (localization?.COMMENTS_REPLIES_PLURAL ??\n\t\t\t\t\t\t\t\t\tt(\"comments.thread.repliesPlural\", \"replies\"))\n\t\t\t\t\t\t}`}\n\t\t\t\n\t\t\t{expanded && (\n\t\t\t\t\n\t\t\t\t\t{loadedReplies.map((reply) => (\n\t\t\t\t\t\t {}} // No nested replies in v1\n\t\t\t\t\t\t\tallowPosting={false}\n\t\t\t\t\t\t\tallowEditing={allowEditing}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{hasMoreReplies && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tsetReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdisabled={isFetchingReplies}\n\t\t\t\t\t\t\t\tdata-testid=\"load-more-replies\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingReplies\n\t\t\t\t\t\t\t\t\t? (localization?.COMMENTS_LOADING_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadingMore\", \"Loading…\"))\n\t\t\t\t\t\t\t\t\t: (localization?.COMMENTS_LOAD_MORE ??\n\t\t\t\t\t\t\t\t\t\tt(\"comments.thread.loadMore\", \"Load more comments\"))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\n// ─── Public export: lazy-mounts on scroll into view ───────────────────────────\n\n/**\n * Embeddable threaded comment section.\n *\n * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).\n * Uses the top-level StackProvider API and auth configuration by default.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nfunction CommentThreadSkeleton() {\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Comment rows */}\n\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t))}\n\n\t\t\t{/* Separator */}\n\t\t\t
\n\n\t\t\t{/* Textarea placeholder */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function CommentThread(props: CommentThreadProps) {\n\tconst overrides = usePluginOverrides<\n\t\tCommentsPluginOverrides,\n\t\tPartial\n\t>(\"comments\", {});\n\tconst { currentUserId, isPending: isIdentityPending } =\n\t\tuseResolvedCurrentUserId(props.currentUserId ?? overrides.currentUserId);\n\tconst resolvedProps: ResolvedCommentThreadProps = {\n\t\t...props,\n\t\tapiBaseURL: props.apiBaseURL ?? overrides.apiBaseURL ?? \"\",\n\t\tapiBasePath: props.apiBasePath ?? overrides.apiBasePath ?? \"\",\n\t\tcurrentUserId,\n\t\tloginHref: props.loginHref ?? overrides.loginHref,\n\t\theaders: props.headers ?? overrides.headers,\n\t};\n\n\treturn (\n\t\t
\n\t\t\t} rootMargin=\"300px\">\n\t\t\t\t{isIdentityPending ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/comments/client/components/comment-thread.tsx" }, { @@ -66,7 +66,7 @@ { "path": "btst/comments/client/components/pages/my-comments-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Trash2, ExternalLink, LogIn, MessageSquareOff } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { useNotify, useTranslate } from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../../localization\";\nimport { getInitials, useResolvedCurrentUserId } from \"../../utils\";\n\nconst PAGE_LIMIT = 20;\n\n// URL-synced pagination: the page number survives reloads and is undoable\n// with the back button (discrete changes default to push history).\nconst LIST_STATE_SCHEMA = {\n\tpage: { type: \"number\", default: 1 },\n} as const satisfies ListStateSchema;\n\ninterface UserCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n}\n\nfunction StatusBadge({\n\tstatus,\n\tlocalization,\n}: {\n\tstatus: CommentStatus;\n\tlocalization?: Partial;\n}) {\n\tconst t = useTranslate();\n\tif (status === \"approved\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.COMMENTS_MY_STATUS_APPROVED ??\n\t\t\t\t\tt(\"comments.my.statusApproved\", \"Approved\")}\n\t\t\t\n\t\t);\n\t}\n\tif (status === \"pending\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.COMMENTS_MY_STATUS_PENDING ??\n\t\t\t\t\tt(\"comments.my.statusPending\", \"Pending\")}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{localization?.COMMENTS_MY_STATUS_SPAM ??\n\t\t\t\tt(\"comments.my.statusSpam\", \"Spam\")}\n\t\t\n\t);\n}\n\n// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function UserCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization,\n}: UserCommentsPageProps) {\n\tconst t = useTranslate();\n\tconst resolvedUserId = useResolvedCurrentUserId(currentUserIdProp);\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_LOGIN_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.loginTitle\", \"Please log in to view your comments\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_LOGIN_DESCRIPTION ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"comments.my.loginDescription\",\n\t\t\t\t\t\t\t\"You need to be logged in to see your comment history.\",\n\t\t\t\t\t\t)}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction UserCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tlocalization,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n}) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\n\tconst [listState, setListState] = useListState(\n\t\t\"comments-my\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\t// Clamp the URL-sourced page so a mangled URL cannot produce an invalid query.\n\tconst page = Math.max(1, Math.floor(listState.page) || 1);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_MY_TOAST_DELETED ??\n\t\t\t\t\tt(\"comments.my.toastDeleted\", \"Comment deleted\"),\n\t\t\t);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_MY_TOAST_DELETE_ERROR ??\n\t\t\t\t\tt(\"comments.my.toastDeleteError\", \"Failed to delete comment\"),\n\t\t\t);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_EMPTY_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.emptyTitle\", \"No comments yet\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_EMPTY_DESCRIPTION ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"comments.my.emptyDescription\",\n\t\t\t\t\t\t\t\"Comments you post will appear here.\",\n\t\t\t\t\t\t)}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_PAGE_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.pageTitle\", \"My Comments\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{total}{\" \"}\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.COMMENTS_MY_COL_COMMENT ??\n\t\t\t\t\t\tt(\"comments.my.colComment\", \"Comment\")\n\t\t\t\t\t).toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_COMMENT ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colComment\", \"Comment\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_RESOURCE ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colResource\", \"Resource\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_STATUS ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colStatus\", \"Status\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_DATE ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colDate\", \"Date\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t {\n\t\t\t\t\t\tsetListState({ page: p });\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteTitle\", \"Delete comment?\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_DESCRIPTION ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"comments.my.deleteDescription\",\n\t\t\t\t\t\t\t\t\t\"This action cannot be undone. The comment will be permanently removed.\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteConfirm\", \"Delete\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tlocalization,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst resourceUrlBase = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\n\tconst resourceUrl = resourceUrlBase\n\t\t? `${resourceUrlBase}#comments`\n\t\t: undefined;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t

{comment.body}

\n\t\t\t\t{comment.parentId && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MY_REPLY_INDICATOR ??\n\t\t\t\t\t\t\tt(\"comments.my.replyIndicator\", \"↩ Reply\")}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resourceType.replace(/-/g, \" \")}\n\t\t\t\t\t\n\t\t\t\t\t{resourceUrl ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_VIEW_LINK ??\n\t\t\t\t\t\t\t\tt(\"comments.my.viewLink\", \"View\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{comment.resourceId}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_BUTTON_SR ??\n\t\t\t\t\t\t\tt(\"comments.my.deleteButtonSr\", \"Delete comment\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAvatar,\n\tAvatarFallback,\n\tAvatarImage,\n} from \"@/components/ui/avatar\";\nimport { Trash2, ExternalLink, LogIn, MessageSquareOff } from \"lucide-react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { useNotify, useTranslate } from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { PaginationControls } from \"@/components/ui/pagination-controls\";\nimport type { SerializedComment, CommentStatus } from \"../../../types\";\nimport {\n\tuseSuspenseComments,\n\tuseDeleteComment,\n} from \"@btst/stack/plugins/comments/client/hooks\";\nimport type { CommentsLocalization } from \"../../localization\";\nimport { getInitials, useResolvedCurrentUserId } from \"../../utils\";\n\nconst PAGE_LIMIT = 20;\n\n// URL-synced pagination: the page number survives reloads and is undoable\n// with the back button (discrete changes default to push history).\nconst LIST_STATE_SCHEMA = {\n\tpage: { type: \"number\", default: 1 },\n} as const satisfies ListStateSchema;\n\ninterface UserCommentsPageProps {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId?: CommentsPluginOverrides[\"currentUserId\"];\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n}\n\nfunction StatusBadge({\n\tstatus,\n\tlocalization,\n}: {\n\tstatus: CommentStatus;\n\tlocalization?: Partial;\n}) {\n\tconst t = useTranslate();\n\tif (status === \"approved\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.COMMENTS_MY_STATUS_APPROVED ??\n\t\t\t\t\tt(\"comments.my.statusApproved\", \"Approved\")}\n\t\t\t\n\t\t);\n\t}\n\tif (status === \"pending\") {\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.COMMENTS_MY_STATUS_PENDING ??\n\t\t\t\t\tt(\"comments.my.statusPending\", \"Pending\")}\n\t\t\t\n\t\t);\n\t}\n\treturn (\n\t\t\n\t\t\t{localization?.COMMENTS_MY_STATUS_SPAM ??\n\t\t\t\tt(\"comments.my.statusSpam\", \"Spam\")}\n\t\t\n\t);\n}\n\n// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function UserCommentsPage({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId: currentUserIdProp,\n\tresourceLinks,\n\tlocalization,\n}: UserCommentsPageProps) {\n\tconst t = useTranslate();\n\tconst { currentUserId: resolvedUserId, isPending: isIdentityPending } =\n\t\tuseResolvedCurrentUserId(currentUserIdProp);\n\n\tif (isIdentityPending) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t);\n\t}\n\n\tif (!resolvedUserId) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_LOGIN_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.loginTitle\", \"Please log in to view your comments\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_LOGIN_DESCRIPTION ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"comments.my.loginDescription\",\n\t\t\t\t\t\t\t\"You need to be logged in to see your comment history.\",\n\t\t\t\t\t\t)}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n// ─── List (suspense boundary is in ComposedRoute) ─────────────────────────────\n\nfunction UserCommentsList({\n\tapiBaseURL,\n\tapiBasePath,\n\theaders,\n\tcurrentUserId,\n\tresourceLinks,\n\tlocalization,\n}: {\n\tapiBaseURL: string;\n\tapiBasePath: string;\n\theaders?: HeadersInit;\n\tcurrentUserId: string;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n}) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\n\tconst [listState, setListState] = useListState(\n\t\t\"comments-my\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\t// Clamp the URL-sourced page so a mangled URL cannot produce an invalid query.\n\tconst page = Math.max(1, Math.floor(listState.page) || 1);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst config = { apiBaseURL, apiBasePath, headers };\n\tconst offset = (page - 1) * PAGE_LIMIT;\n\n\tconst { comments, total, refetch } = useSuspenseComments(config, {\n\t\tauthorId: currentUserId,\n\t\tsort: \"desc\",\n\t\tlimit: PAGE_LIMIT,\n\t\toffset,\n\t});\n\n\tconst deleteMutation = useDeleteComment(config);\n\n\tconst totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.COMMENTS_MY_TOAST_DELETED ??\n\t\t\t\t\tt(\"comments.my.toastDeleted\", \"Comment deleted\"),\n\t\t\t);\n\t\t\trefetch();\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.COMMENTS_MY_TOAST_DELETE_ERROR ??\n\t\t\t\t\tt(\"comments.my.toastDeleteError\", \"Failed to delete comment\"),\n\t\t\t);\n\t\t} finally {\n\t\t\tsetDeleteId(null);\n\t\t}\n\t};\n\n\tif (comments.length === 0 && page === 1) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_EMPTY_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.emptyTitle\", \"No comments yet\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_EMPTY_DESCRIPTION ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"comments.my.emptyDescription\",\n\t\t\t\t\t\t\t\"Comments you post will appear here.\",\n\t\t\t\t\t\t)}\n\t\t\t\t

\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{localization?.COMMENTS_MY_PAGE_TITLE ??\n\t\t\t\t\t\tt(\"comments.my.pageTitle\", \"My Comments\")}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{total}{\" \"}\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.COMMENTS_MY_COL_COMMENT ??\n\t\t\t\t\t\tt(\"comments.my.colComment\", \"Comment\")\n\t\t\t\t\t).toLowerCase()}\n\t\t\t\t\t{total !== 1 ? \"s\" : \"\"}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_COMMENT ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colComment\", \"Comment\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_RESOURCE ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colResource\", \"Resource\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_STATUS ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colStatus\", \"Status\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.COMMENTS_MY_COL_DATE ??\n\t\t\t\t\t\t\t\t\tt(\"comments.my.colDate\", \"Date\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{comments.map((comment) => (\n\t\t\t\t\t\t\t setDeleteId(comment.id)}\n\t\t\t\t\t\t\t\tisDeleting={deleteMutation.isPending && deleteId === comment.id}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t {\n\t\t\t\t\t\tsetListState({ page: p });\n\t\t\t\t\t\twindow.scrollTo({ top: 0, behavior: \"smooth\" });\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t !open && setDeleteId(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteTitle\", \"Delete comment?\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_DESCRIPTION ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"comments.my.deleteDescription\",\n\t\t\t\t\t\t\t\t\t\"This action cannot be undone. The comment will be permanently removed.\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteCancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\"comments.my.deleteConfirm\", \"Delete\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\n// ─── Row ──────────────────────────────────────────────────────────────────────\n\nfunction CommentRow({\n\tcomment,\n\tresourceLinks,\n\tlocalization,\n\tonDelete,\n\tisDeleting,\n}: {\n\tcomment: SerializedComment;\n\tresourceLinks?: CommentsPluginOverrides[\"resourceLinks\"];\n\tlocalization?: Partial;\n\tonDelete: () => void;\n\tisDeleting: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst resourceUrlBase = resourceLinks?.[comment.resourceType]?.(\n\t\tcomment.resourceId,\n\t);\n\tconst resourceUrl = resourceUrlBase\n\t\t? `${resourceUrlBase}#comments`\n\t\t: undefined;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{comment.resolvedAvatarUrl && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t\t{getInitials(comment.resolvedAuthorName)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t

{comment.body}

\n\t\t\t\t{comment.parentId && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MY_REPLY_INDICATOR ??\n\t\t\t\t\t\t\tt(\"comments.my.replyIndicator\", \"↩ Reply\")}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{comment.resourceType.replace(/-/g, \" \")}\n\t\t\t\t\t\n\t\t\t\t\t{resourceUrl ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.COMMENTS_MY_VIEW_LINK ??\n\t\t\t\t\t\t\t\tt(\"comments.my.viewLink\", \"View\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{comment.resourceId}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.COMMENTS_MY_DELETE_BUTTON_SR ??\n\t\t\t\t\t\t\tt(\"comments.my.deleteButtonSr\", \"Delete comment\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/my-comments-page.internal.tsx" }, { @@ -84,7 +84,7 @@ { "path": "btst/comments/client/components/pages/resource-comments-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useResolvedCurrentUserId } from \"../../utils\";\n\nconst ResourceCommentsPageInternal = lazy(() =>\n\timport(\"./resource-comments-page.internal\").then((m) => ({\n\t\tdefault: m.ResourceCommentsPage,\n\t})),\n);\n\nfunction ResourceCommentsSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function ResourceCommentsPageComponent({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\treturn (\n\t\t (\n\t\t\t\t\n\t\t\t)}\n\t\t\tLoadingComponent={ResourceCommentsSkeleton}\n\t\t\tonError={(error) =>\n\t\t\t\tconsole.error(\"[btst/comments] Resource comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ResourceCommentsPageWrapper({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId);\n\n\tuseRouteLifecycle({\n\t\trouteName: \"resourceComments\",\n\t\tcontext: {\n\t\t\tpath: `/comments/${resourceType}/${resourceId}`,\n\t\t\tparams: { resourceId, resourceType },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeResourceCommentsRendered) {\n\t\t\t\treturn o.onBeforeResourceCommentsRendered(\n\t\t\t\t\tresourceType,\n\t\t\t\t\tresourceId,\n\t\t\t\t\tcontext,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useResolvedCurrentUserId } from \"../../utils\";\n\nconst ResourceCommentsPageInternal = lazy(() =>\n\timport(\"./resource-comments-page.internal\").then((m) => ({\n\t\tdefault: m.ResourceCommentsPage,\n\t})),\n);\n\nfunction ResourceCommentsSkeleton() {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport function ResourceCommentsPageComponent({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\treturn (\n\t\t (\n\t\t\t\t\n\t\t\t)}\n\t\t\tLoadingComponent={ResourceCommentsSkeleton}\n\t\t\tonError={(error) =>\n\t\t\t\tconsole.error(\"[btst/comments] Resource comments error:\", error)\n\t\t\t}\n\t\t/>\n\t);\n}\n\nfunction ResourceCommentsPageWrapper({\n\tresourceId,\n\tresourceType,\n}: {\n\tresourceId: string;\n\tresourceType: string;\n}) {\n\tconst overrides = usePluginOverrides(\"comments\");\n\tconst { currentUserId: resolvedUserId, isPending: isIdentityPending } =\n\t\tuseResolvedCurrentUserId(overrides.currentUserId);\n\n\tuseRouteLifecycle({\n\t\trouteName: \"resourceComments\",\n\t\tcontext: {\n\t\t\tpath: `/comments/${resourceType}/${resourceId}`,\n\t\t\tparams: { resourceId, resourceType },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (o, context) => {\n\t\t\tif (o.onBeforeResourceCommentsRendered) {\n\t\t\t\treturn o.onBeforeResourceCommentsRendered(\n\t\t\t\t\tresourceType,\n\t\t\t\t\tresourceId,\n\t\t\t\t\tcontext,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\tif (isIdentityPending) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/comments/client/components/pages/resource-comments-page.tsx" }, { @@ -132,7 +132,7 @@ { "path": "btst/comments/client/utils.ts", "type": "registry:lib", - "content": "import { useState, useEffect } from \"react\";\nimport { useIdentity } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\n\n/**\n * Resolves the legacy `currentUserId` override when provided, otherwise uses\n * the identity from the top-level Stack auth provider.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): string | undefined {\n\tconst { identity } = useIdentity();\n\tconst providerUserId = identity?.id;\n\tconst [resolved, setResolved] = useState(\n\t\ttypeof raw === \"string\"\n\t\t\t? raw\n\t\t\t: raw === undefined\n\t\t\t\t? providerUserId\n\t\t\t\t: undefined,\n\t);\n\n\tuseEffect(() => {\n\t\tif (typeof raw === \"function\") {\n\t\t\tvoid Promise.resolve(raw())\n\t\t\t\t.then((id) => setResolved(id ?? undefined))\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\"[btst/comments] Failed to resolve currentUserId:\",\n\t\t\t\t\t\terr,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t} else if (typeof raw === \"string\") {\n\t\t\tsetResolved(raw);\n\t\t} else {\n\t\t\tsetResolved(providerUserId);\n\t\t}\n\t}, [providerUserId, raw]);\n\n\treturn resolved;\n}\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", + "content": "import { useState, useEffect } from \"react\";\nimport { useIdentity } from \"@btst/stack/context\";\nimport type { CommentsPluginOverrides } from \"./overrides\";\n\n/**\n * Resolves the legacy `currentUserId` override when provided, otherwise uses\n * the identity from the top-level Stack auth provider.\n */\nexport function useResolvedCurrentUserId(\n\traw: CommentsPluginOverrides[\"currentUserId\"],\n): { currentUserId: string | undefined; isPending: boolean } {\n\tconst { identity, isPending: isProviderPending } = useIdentity();\n\tconst [legacyResult, setLegacyResult] = useState<{\n\t\tcurrentUserId: string | undefined;\n\t\tisPending: boolean;\n\t}>({ currentUserId: undefined, isPending: typeof raw === \"function\" });\n\n\tuseEffect(() => {\n\t\tif (typeof raw !== \"function\") return;\n\n\t\tlet cancelled = false;\n\t\tsetLegacyResult({ currentUserId: undefined, isPending: true });\n\t\tvoid Promise.resolve(raw())\n\t\t\t.then((id) => {\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetLegacyResult({\n\t\t\t\t\t\tcurrentUserId: id ?? undefined,\n\t\t\t\t\t\tisPending: false,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch((err: unknown) => {\n\t\t\t\tconsole.error(\"[btst/comments] Failed to resolve currentUserId:\", err);\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetLegacyResult({ currentUserId: undefined, isPending: false });\n\t\t\t\t}\n\t\t\t});\n\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t};\n\t}, [raw]);\n\n\tif (typeof raw === \"string\") {\n\t\treturn { currentUserId: raw, isPending: false };\n\t}\n\tif (typeof raw === \"function\") return legacyResult;\n\treturn {\n\t\tcurrentUserId: identity?.id,\n\t\tisPending: isProviderPending,\n\t};\n}\n\nexport function getInitials(name: string | null | undefined): string {\n\tif (!name) return \"?\";\n\treturn name\n\t\t.split(\" \")\n\t\t.filter(Boolean)\n\t\t.slice(0, 2)\n\t\t.map((n) => n[0])\n\t\t.join(\"\")\n\t\t.toUpperCase();\n}\n", "target": "src/components/btst/comments/client/utils.ts" }, { diff --git a/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx index 179e6df7..2df287d9 100644 --- a/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx @@ -494,6 +494,60 @@ describe("CommentThread provider defaults", () => { ); }); + it("keeps the plugin identity override above the provider identity", async () => { + await render( + ({ id: "provider-user" }) }} + overrides={{ + comments: { + ...commentsOverrides, + currentUserId: "plugin-user", + }, + }} + > + + , + ); + await act(async () => {}); + + expect(hooks.useInfiniteComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ currentUserId: "plugin-user" }), + ); + }); + + it("waits for the provider identity before mounting the thread", async () => { + let resolveIdentity: (identity: { id: string }) => void = () => {}; + const identity = new Promise<{ id: string }>((resolve) => { + resolveIdentity = resolve; + }); + + await render( + identity }} + > + + , + ); + + expect(hooks.useInfiniteComments).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="login-link"]')).toBeNull(); + + await act(async () => { + resolveIdentity({ id: "provider-user" }); + await identity; + }); + + expect(hooks.useInfiniteComments).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ currentUserId: "provider-user" }), + ); + }); + it("uses the top-level auth login path when unauthenticated", async () => { await render( >("comments", {}); - const currentUserId = useResolvedCurrentUserId(props.currentUserId); + const { currentUserId, isPending: isIdentityPending } = + useResolvedCurrentUserId(props.currentUserId ?? overrides.currentUserId); const resolvedProps: ResolvedCommentThreadProps = { ...props, apiBaseURL: props.apiBaseURL ?? overrides.apiBaseURL ?? "", @@ -873,7 +874,11 @@ export function CommentThread(props: CommentThreadProps) { return (
} rootMargin="300px"> - + {isIdentityPending ? ( + + ) : ( + + )}
); diff --git a/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx index 4cdb6014..4b219054 100644 --- a/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx +++ b/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx @@ -100,7 +100,20 @@ export function UserCommentsPage({ localization, }: UserCommentsPageProps) { const t = useTranslate(); - const resolvedUserId = useResolvedCurrentUserId(currentUserIdProp); + const { currentUserId: resolvedUserId, isPending: isIdentityPending } = + useResolvedCurrentUserId(currentUserIdProp); + + if (isIdentityPending) { + return ( +
+
+
+
+ ); + } if (!resolvedUserId) { return ( diff --git a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx index e0f3636b..412476b2 100644 --- a/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx +++ b/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.tsx @@ -56,7 +56,8 @@ function ResourceCommentsPageWrapper({ resourceType: string; }) { const overrides = usePluginOverrides("comments"); - const resolvedUserId = useResolvedCurrentUserId(overrides.currentUserId); + const { currentUserId: resolvedUserId, isPending: isIdentityPending } = + useResolvedCurrentUserId(overrides.currentUserId); useRouteLifecycle({ routeName: "resourceComments", @@ -77,6 +78,13 @@ function ResourceCommentsPageWrapper({ return true; }, }); + if (isIdentityPending) { + return ( + + + + ); + } return ( diff --git a/packages/stack/src/plugins/comments/client/utils.ts b/packages/stack/src/plugins/comments/client/utils.ts index d1affb2c..aff268bb 100644 --- a/packages/stack/src/plugins/comments/client/utils.ts +++ b/packages/stack/src/plugins/comments/client/utils.ts @@ -8,35 +8,47 @@ import type { CommentsPluginOverrides } from "./overrides"; */ export function useResolvedCurrentUserId( raw: CommentsPluginOverrides["currentUserId"], -): string | undefined { - const { identity } = useIdentity(); - const providerUserId = identity?.id; - const [resolved, setResolved] = useState( - typeof raw === "string" - ? raw - : raw === undefined - ? providerUserId - : undefined, - ); +): { currentUserId: string | undefined; isPending: boolean } { + const { identity, isPending: isProviderPending } = useIdentity(); + const [legacyResult, setLegacyResult] = useState<{ + currentUserId: string | undefined; + isPending: boolean; + }>({ currentUserId: undefined, isPending: typeof raw === "function" }); useEffect(() => { - if (typeof raw === "function") { - void Promise.resolve(raw()) - .then((id) => setResolved(id ?? undefined)) - .catch((err: unknown) => { - console.error( - "[btst/comments] Failed to resolve currentUserId:", - err, - ); - }); - } else if (typeof raw === "string") { - setResolved(raw); - } else { - setResolved(providerUserId); - } - }, [providerUserId, raw]); + if (typeof raw !== "function") return; - return resolved; + let cancelled = false; + setLegacyResult({ currentUserId: undefined, isPending: true }); + void Promise.resolve(raw()) + .then((id) => { + if (!cancelled) { + setLegacyResult({ + currentUserId: id ?? undefined, + isPending: false, + }); + } + }) + .catch((err: unknown) => { + console.error("[btst/comments] Failed to resolve currentUserId:", err); + if (!cancelled) { + setLegacyResult({ currentUserId: undefined, isPending: false }); + } + }); + + return () => { + cancelled = true; + }; + }, [raw]); + + if (typeof raw === "string") { + return { currentUserId: raw, isPending: false }; + } + if (typeof raw === "function") return legacyResult; + return { + currentUserId: identity?.id, + isPending: isProviderPending, + }; } export function getInitials(name: string | null | undefined): string {