Documentation

Comment actions

Let VitNode Content Engine comment authors and moderators edit, delete, hide, restore and report comments in the shared comment section. Each action takes one content-type option and one typed callback, and the server decides who may do what.

This page puts a More actions menu on each comment of the section from Comments: Edit, Delete, Hide or Unhide, and Report. Each action is a content-type option plus one callback in article-comments.tsx. The menu shows an action only where you wired it and the server says this visitor may use it on this comment.

ActionRequired configurationServer permissionCallback and shared UI
EditpublicWrite.updatecanEdit: the author while their role's edit window is open, or a moderator with Editedit: { load, save } adds an inline editor and a conflict alert with revisions
DeletepublicWritecanDelete: the author when their role allows it, or a moderator with Deletedelete adds a confirmation dialog, and a placeholder when the comment has replies
Hide and restorevisibilitycanHide: a moderator with Hide, or the author when their role allows itvisibility: { hide, unhide } adds a reason prompt and the hidden state
ReportreportscanReport: signed-in visitors on other people's commentsreport opens the core report dialog, which files to ModCP

Before you begin

  • The working comment section from Comments.
  • Two member accounts, and an account that moderates. A root administrator holds every moderator permission. For anyone else, add them in AdminCP → Staff → Moderators, as Configure moderators shows.

Turn on the options

Add visibility, reports and update to the comment type in src/content/comment.ts. This is an excerpt; keep the rest of the file:

plugins/newsroom/src/content/comment.ts
  publication: true,
  visibility: true, 
  reports: { titleField: "body", snapshotFields: ["body"] }, 

  publicWrite: {
    ownership: { field: "author" },
    create: { fields: ["body", "article", "parent"], status: "published" },
    update: { fields: ["body"] }, 
  },
  • visibility adds POST /{id}/hide and /unhide, and the columns that remember who hid a comment and why.
  • reports adds reporting. titleField: "body" gives moderators the first line of the comment as the report's title, so comments need no title field of their own. snapshotFields keeps a copy of the text as it was when reported.
  • update adds GET /{id}/edit and PATCH /{id}. Only body is editable; a comment never moves to another article or parent.
  • GET /permissions, which the menu asks, already exists, because publicWrite mounts it.

Add the moderation labels next to the other newsroom:comment labels in src/locales/en.json:

plugins/newsroom/src/locales/en.json
{
  "newsroom:comment:can_view_reports": "View comment reports",
  "newsroom:comment:can_manage_reports": "Claim, assign and resolve comment reports",
  "newsroom:comment:can_view_hidden": "Open hidden comments"
}

Then build and migrate from the workspace root:

Build and migrate
bun run build:plugins
bun run db:migrate

Ask who may do what

Add permissions to the useContentComments call in src/pages/article-comments.tsx. This is an excerpt; the following steps add to the same call:

plugins/newsroom/src/pages/article-comments.tsx
  const comments = useContentComments({
    ...articleCommentsThread(articleId),
    create: async ({ body, parentId }) => /* unchanged */,
    permissions: async ids =>
      await fetcher({
        plugin: manifest.id,
        method: "get",
        module: "content/article-comments",
        path: "/permissions",
        args: { query: { ids: ids.join(",") } },
      }),
  });

The section asks once for every comment on screen, in batches of 50, and again for the comments Load more comments adds. A comment whose answer has not arrived, or whose batch failed, shows no actions. The answers belong to the signed-in visitor, so signing in or out asks again.

The server answers from settings, not from your code:

  • Authors edit their own comments while their role's Time to edit runs, if Allow editing own content is on (it is, by default). Allow deleting own content and Allow hiding own content are off by default. Turning on visibility does not turn them on. Roles explains all three.
  • Moderators act on anyone's comments, at any age, with the comment type's permissions in AdminCP → Staff → Moderators: Edit comments, Delete comments and Hide and unhide comments.
  • Reporting is open to every signed-in visitor on other people's comments. Add selfReport: true to reports to let authors report their own.

Connect edit

plugins/newsroom/src/pages/article-comments.tsx
    edit: {
      load: async id =>
        await fetcher({
          plugin: manifest.id,
          method: "get",
          module: "content/article-comments",
          path: "/{id}/edit",
          args: { params: { id } },
        }),
      // Comments have no editorial revisions, so the last save wins.
      save: async (id, { body }) =>
        await fetcher({
          plugin: manifest.id,
          method: "patch",
          module: "content/article-comments",
          path: "/{id}",
          args: { params: { id }, body: { values: { body } } },
        }),
    },

Edit loads the editable text first and opens the editor in place of the comment. After Save, the thread and the form's defaults refresh. If the author's edit waits for a moderator, the editor opens on that proposed text, and saving says it is waiting rather than live.

Newsroom comments have no editorial, so two saves of the same comment both succeed and the last one wins. To refuse a stale save instead, add editorial: true to the comment type, migrate, and send the version:

save: async (id, { body, expectedVersion }) =>
  await fetcher({
    plugin: manifest.id,
    method: "patch",
    module: "content/article-comments",
    path: "/{id}",
    args: { params: { id }, body: { values: { body }, expectedVersion } },
  }),

A stale save then answers 409, and the editor shows Someone else saved in the meantime with Keep my version and Load latest version. The Blog plugin's comments work this way.

Connect delete

plugins/newsroom/src/pages/article-comments.tsx
    delete: async id =>
      await fetcher({
        plugin: manifest.id,
        method: "post",
        module: "content/article-comments",
        path: "/{id}/delete",
        args: { params: { id } },
      }),

Delete asks for confirmation first. A comment nobody answered is removed. A comment with replies stays in the thread as an anonymous [deleted] placeholder with no text and no author, so its replies keep their place. The placeholder reads "Comment deleted by user" or "Comment removed by a moderator". Deleting the last reply under a placeholder removes the placeholder too. A deleted comment cannot be restored.

Connect hide and restore

plugins/newsroom/src/pages/article-comments.tsx
    visibility: {
      hide: async (id, { reason }) =>
        await fetcher({
          plugin: manifest.id,
          method: "post",
          module: "content/article-comments",
          path: "/{id}/hide",
          args: { params: { id }, body: reason ? { reason } : {} },
        }),
      unhide: async id =>
        await fetcher({
          plugin: manifest.id,
          method: "post",
          module: "content/article-comments",
          path: "/{id}/unhide",
          args: { params: { id } },
        }),
    },

Hide asks for an optional reason of up to 500 characters. A hidden comment disappears for everyone else, but stays visible, marked Hidden with its reason, to its author and to moderators, who get Unhide. Readers who can't see it get the same placeholder as a deleted comment when it has replies, and nothing when it has none. Hiding is not deleting. Unhide brings the comment back unchanged. It is also not unpublishing, which is a staff action in the AdminCP.

Connect reporting

plugins/newsroom/src/pages/article-comments.tsx
import { contentTarget } from "@vitnode/core/content"; 

import { commentContentType } from "@/content/comment"; 

    report: id => contentTarget(manifest.id, commentContentType, id), 

Report opens the core report dialog, where the visitor picks a reason. A guest who reaches it is asked to sign in first and comes back to the article. Reports land in ModCP with the comment's first line as their title.

ModCP links each report to its content's public URL, and a comment has none yet. Give it a permalink that redirects to the comment under its article.

Add delivery to the comment type. This is an excerpt:

plugins/newsroom/src/content/comment.ts
  delivery: { path: "/news/comments/:slug" }, 

  thread: { parent: "parent", root: "article", maxDepth: 1 },

Delivery claims the type's publicApi.path across the whole site. That's why this guide picked article-comments. The Blog plugin already delivers comments, and two plugins can't share a path.

The redirect needs the article's slug, and a comment only knows its article's id. Add a small public route that answers it. Create src/api/modules/articles/articles.module.ts:

plugins/newsroom/src/api/modules/articles/articles.module.ts
import { z } from "@hono/zod-openapi";
import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache";
import { contentPublicListTag } from "@vitnode/core/content";
import { HTTPException } from "hono/http-exception";

import { buildModule, buildRoute } from "@/api/builders";
import { articleContent } from "@/database/articles";

const articleSlugRoute = buildRoute({
  route: {
    method: "get",
    path: "/{id}/slug",
    description:
      "The slug of one public article, for turning a comment permalink into the comment under its article",
    request: {
      params: z.object({
        id: z.coerce.number().int().positive().openapi({ example: 1 }),
      }),
    },
    responses: {
      200: {
        content: {
          "application/json": {
            schema: z.object({ id: z.number(), slug: z.string() }),
          },
        },
        description: "The article's slug",
      },
      404: { description: "No public article with this id" },
    },
  },
  handler: async c => {
    const { id } = c.req.valid("param");
    const service = articleContent.publicService?.(c);
    if (!service) {
      throw new HTTPException(500, { message: "Articles have no public API." });
    }

    const article = await service.findById(id);
    if (!article) throw new HTTPException(404, { message: "Article not found." });

    setPageCacheTags(c, [contentPublicListTag(articleContent.definition.id)]);

    return c.json({ id: article.id, slug: article.slug }, 200);
  },
});

export const articlesModule = buildModule({
  name: "articles",
  routes: [articleSlugRoute],
});

Register it in src/config.api.ts. This is an excerpt:

plugins/newsroom/src/config.api.ts
import { articlesModule } from "./api/modules/articles/articles.module"; 

    modules: [
      helloModule,
      adminModule,
      articlesModule, 

The redirect page uses TanStack Query and TanStack Router. Skip this if the plugin already has both; otherwise run this from the workspace root:

Add TanStack Query and Router
bun add @tanstack/react-query @tanstack/react-router --cwd plugins/newsroom

Create src/pages/comment-redirect-page.tsx:

plugins/newsroom/src/pages/comment-redirect-page.tsx
import { queryOptions } from "@tanstack/react-query";
import { redirect } from "@tanstack/react-router";
import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client";
import { CONTENT_COMMENT_ANCHOR_PREFIX } from "@vitnode/core/tanstack/comments";
import { contentPageItem } from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { localizeHref } from "@vitnode/core/tanstack/i18n";
import { defineRoute } from "@vitnode/core/tanstack/plugin-routes";
import { z } from "zod";

import { manifest } from "@/manifest.gen";

const zodCommentLink = z.object({
  article: z.object({ id: z.number() }),
  id: z.number(),
});

/** Where a comment's permalink leads: its article's slug and the comment's id. */
const commentLinkQueryOptions = (slug: string) =>
  queryOptions({
    meta: LOADER_DATA_QUERY_META,
    queryFn: async () => {
      const comment = await fetcher({
        plugin: manifest.id,
        method: "get",
        module: "content/article-comments",
        path: "/{slug}",
        args: { params: { slug: encodeURIComponent(slug) } },
      });
      if (comment.status === 404) return null;
      if (comment.status !== 200) {
        throw new Error(`Resolving the comment "${slug}" failed.`);
      }

      const record = zodCommentLink.parse(await comment.json());
      const article = await fetcher({
        plugin: manifest.id,
        method: "get",
        module: "articles",
        path: "/{id}/slug",
        args: { params: { id: record.article.id } },
      });
      if (article.status === 404) return null;
      if (article.status !== 200) {
        throw new Error(`Resolving the article of the comment "${slug}" failed.`);
      }

      return { article: (await article.json()).slug, comment: record.id };
    },
    queryKey: [manifest.id, "comment-link", slug],
  });

export const route = defineRoute({
  load: async ({ context, params }) => {
    const { article, comment } = contentPageItem(
      await context.queryClient.query(commentLinkQueryOptions(params.slug)),
    );

    // oxlint-disable-next-line typescript/only-throw-error
    throw redirect({
      href: `${localizeHref(`/news/${article}`, context.locale)}#${CONTENT_COMMENT_ANCHOR_PREFIX}${comment}`,
      statusCode: 302,
    });
  },

  head: () => ({ robots: "noindex, nofollow" }),
});

const CommentRedirectPage = () => null;

export default CommentRedirectPage;
  • contentPageItem turns a null into the not-found page, for a comment or article that is gone or not public.
  • #comment-{id} is the element id the section gives every comment. The section scrolls to it, loading more pages until it appears, focuses it and rings it.

Declare the page in src/routes.ts:

plugins/newsroom/src/routes.ts
  page("/news/comments/:slug", {
    component: lazy(() => import("./pages/comment-redirect-page")),
  }),

Build again, so the new route's types and the delivery URL exist:

Build the plugin
bun run build:plugins

Check the result

Open an article with two comments by Ada, a member. Then check each action with the right account:

  1. As Ada, open More actions on one of the comments. It offers only Edit, because deleting and hiding your own comments are off by default. Click Edit, change the text and click Save. Comment saved appears and the comment shows the new text, marked edited when you changed it more than a minute after posting.

    A comment open in the inline editor under the label Edit your comment, with Save and Cancel buttons, above the replies to it
  2. As Grace, another member, the menu on Ada's comment offers Report. Pick Spam and click Send report.

  3. As the moderator, the menu offers Edit, Report, Hide and Delete:

    The More actions menu open on a comment, with Edit, Report, Hide and Delete, Delete in red
  4. Click Hide, type a reason and click Hide. The comment stays visible to you and to Ada, marked Hidden with the reason. Signed out, it is gone, and the count drops by one:

    A comment marked Hidden with the reason Off topic for this article, seen by a moderator, above a visible comment
  5. Open More actions again and click Unhide. Everyone sees the comment again.

  6. Click Delete on Ada's other comment. The section asks first, and the comment is gone after you confirm:

    The Delete this comment? confirmation, saying it is removed for everyone and that replies to it stay, with Cancel and Delete comment buttons
  7. In ModCP, open Reports and the report from step 2. Its title is the comment's first line. Open current content opens /news/comments/{slug}, which lands on the article with the reported comment scrolled into view, focused and outlined:

    A ModCP report for a comment by Ada Lovelace, reason Spam, with the snapshot of its text and an Open current content link

With Replies, a hidden or deleted comment that has answers stays in the thread as a placeholder, so its replies keep their place.

To let members delete or hide their own comments, turn on Allow deleting own content or Allow hiding own content in their role (AdminCP → Core → Users → Roles). Ada's menu then offers Delete or Hide too. With a Time to edit of a few minutes, Edit disappears from Ada's comments once it runs out.

What the server checks

  • Every action. The menu only hides buttons; each route checks the permission again and answers 401, 403 or 404 when it says no. The section shows one message for it and keeps the comment as it was.
  • One action at a time. While one action runs, the comment's menu waits, and Post comment, Save and the dialogs' buttons are disabled until the server answers. Nothing is shown as done before it accepts.
  • Edit window. A save after the window closed answers 403 with the edit_window_closed issue, even from a page loaded before it closed, and the editor says The time to edit this has run out without losing the text.
  • Hidden comments. Only the author and moderators get hidden: true and the reason; everyone else gets a placeholder or nothing.
  • Replies: let comments answer each other.
  • Comments API: every option and component, and building your own comment layout.
  • Reports: report reasons, snapshots and ModCP.