Skip to content

feat(comments): opt-in live comment display (<Comments live> / <CommentForm live>)#1903

Open
marcusbellamyshaw-cell wants to merge 3 commits into
emdash-cms:mainfrom
marcusbellamyshaw-cell:feat/comment-optimistic-display
Open

feat(comments): opt-in live comment display (<Comments live> / <CommentForm live>)#1903
marcusbellamyshaw-cell wants to merge 3 commits into
emdash-cms:mainfrom
marcusbellamyshaw-cell:feat/comment-optimistic-display

Conversation

@marcusbellamyshaw-cell

@marcusbellamyshaw-cell marcusbellamyshaw-cell commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Draft — opening for review-readiness, not yet requesting merge. This implements the "optimistic comment display" increment proposed in #1497 (comment) (the Tier 2/3 follow-up to the reactions RFC, posted 2026-06-23, bumped 2026-07-02). Per CONTRIBUTING.md, features need an Ideas Discussion + Approved for PR label before merge — that label isn't on the discussion yet, so this stays draft until it is. Opening it now so the proposal has working code behind it rather than staying abstract, same as how the reactions Tier 1 branch was built ahead of its approval.

Today, on a successful comment submission the form only clears the textarea — the SSR comment list never updates, so a visitor can't see the comment they just posted without a manual reload. Worse when commentsModeration: "none" (auto-approve): the comment is live server-side immediately, but invisible to its own author until reload.

  • POST /_emdash/api/comments/:collection/:contentId now additionally returns the created comment payload on success (additive field only — existing id/status/message consumers unaffected).
  • New opt-in live prop on <CommentForm> and <Comments>. When both are set, a small inline script (mirrors the existing reactions prop's script pattern) listens for the new comment and inserts it client-side, respecting the active sort — a fresh zero-reaction comment always sorts last under both oldest and Wilson best ranking, so no client-side ranking math is needed.
  • Comments not yet approved are inserted in a visually distinct muted "Awaiting moderation — visible only to you" state, never mixed in as if publicly visible.
  • Zero behavior/JS change when live isn't set.

Noticed but did not fix (out of scope, flagging for a separate small PR): CommentForm's inline script reads result.message on success, but the success envelope is { data: { message, ... } }, so it always falls back to the generic "Comment submitted!" text instead of the server's actual message. Only the success path is affected.

Type of change

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (targeted: comments-submit-response.test.ts, comments-rate-limit.test.ts, reactions.test.ts, openapi tests — all green; the full-dev-server comments.test.ts E2E suite is pre-existing CI-only per its own vitest config, verified unrelated to this diff)
  • pnpm format has been run
  • I have added/updated tests for my changes
  • User-visible strings in the admin UI are wrapped for translation — n/a, no admin UI strings
  • I have added a changeset
  • New features link to an approved Discussion: RFC: Completing EmDash comments — from "it works" to best-in-class engagement #1497 (approval pending — hence draft)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Sonnet 5, via Claude Code's multi-agent (ultracode) workflow — implementation and independent adversarial review were done by separate agent instances; I (the requesting developer) independently re-read the diff and re-ran the tests myself before opening this PR.

Visitors currently can't see their own comment after posting without a
page reload. Add an opt-in `live` prop that inserts the new comment
client-side on success, and render comments awaiting moderation in a
distinct muted state so they never look publicly visible.

- POST /comments response gains an additive `comment` payload (id,
  author info, body, createdAt, status) alongside the existing
  id/status/message fields.
- <CommentForm live> dispatches an `ec:comment:created` DOM event with
  that payload on success.
- <Comments live> listens for the event and inserts the comment,
  respecting the active sort (a new, zero-reaction comment always
  belongs at the end under both "oldest" and "best" — proven inline).
- Zero behavior change when `live` is unset on either component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 05e163c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 520 lines across 9 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1903

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1903

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1903

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1903

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1903

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1903

emdash

npm i https://pkg.pr.new/emdash@1903

create-emdash

npm i https://pkg.pr.new/create-emdash@1903

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1903

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1903

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1903

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1903

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1903

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@1903

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1903

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1903

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1903

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1903

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1903

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1903

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1903

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1903

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1903

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1903

commit: 05e163c

@github-actions

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added the stale label Jul 24, 2026
@marcusbellamyshaw-cell
marcusbellamyshaw-cell marked this pull request as ready for review July 24, 2026 04:15
@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

Discussion #1497 got the "Approved for PR" label on 2026-07-11 — missed that. Marking ready for review now; code/tests/checks were already green since the original commit.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 24, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for the problem it describes: visitors currently have to reload to see a comment they just posted, and auto-approved comments are invisible to their own author until then. The opt-in live prop design mirrors the existing reactions pattern, keeps JS zero-cost unless enabled, and the API change is purely additive. I checked the diff, the changed route/schema/components/tests, and the surrounding comment repository, query helper, object-cache, and fixture code.

Headline: the implementation is clean, backwards-compatible, and introduces no new logged-out database queries. There are no security issues in the client-side rendering path (output is escaped before innerHTML, and the inline script is a static constant). The main code-level concerns are test quality and a couple of comment/logic nits.

Before merge, the linked Discussion #1497 still needs the maintainer Approved for PR label per CONTRIBUTING.md/AGENTS.md — the author already flagged this, but it is a real blocker.

Note: the author also flagged a pre-existing bug where CommentForm reads result.message instead of result.data.message; that is out of scope and not treated as a new finding here.

Comment on lines +120 to +137
it("does not emit live-display markup or script by default (backward compatible)", async () => {
const html = await fetchHtml(ctx, "/posts/first-post");
expect(html).not.toContain("data-ec-live");
expect(html).not.toContain("ec:comment:created");
});

it("emits live-display markup and script only when `live` is set", async () => {
const html = await fetchHtml(ctx, "/posts-live/first-post");

// Opt-in markers on both <Comments> and <CommentForm>
expect(html).toContain("data-ec-live");
expect(html).toContain("data-ec-threaded");

// Inline script wires up the created-comment event and renders the
// moderation-pending case in a visually distinct, muted state.
expect(html).toContain("ec:comment:created");
expect(html).toContain("ec-comment-pending");
expect(html).toContain("Awaiting moderation");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] These two tests only assert that implementation-detail strings appear in the HTML (data-ec-live, ec:comment:created, ec-comment-pending, Awaiting moderation). That cannot catch a real regression in the actual behavior: the event listener could be wired to the wrong selector, the detail payload could be malformed, the inserted DOM could be missing classes, etc. Per AGENTS.md, tests that assert implementation details straight back "inflate coverage and pins intentional changes" and should be rewritten against observable behavior or deleted.

Replace these HTML substring checks with a real browser evaluation (e.g., Playwright under tests/e2e/) that submits a comment on /posts-live/first-post and asserts the new row appears in the DOM without a reload.

Comment on lines +137 to +151
// Progressive-enhancement script for live/optimistic display. Emitted
// verbatim (is:inline) only when `live` is enabled, so pages that don't opt
// in ship zero extra JS. Listens for the `ec:comment:created` event that a
// sibling <CommentForm live> dispatches on successful submission, and
// inserts the new comment client-side — respecting the active sort. Both
// "oldest" and "best" (Wilson-ranked) place a brand-new, zero-reaction
// comment at the very end of its list: chronologically it's the newest, and
// under Wilson ranking a zero-reaction comment scores exactly 0 (the
// minimum), tying with any other zero-reaction comments and losing every
// oldest-first tiebreak against them since it's the newest of the group. So
// "append at the end" is correct for both sorts — no client-side ranking
// math needed. Replies always stay chronological (per the SSR list), so
// appending to the end of the parent's reply list is correct there too.
// Mirrors the escaping/autolink logic in formatBody() above (duplicated,
// not imported — this runs in the browser).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This comment block is mostly justifying the duplication and the append-at-end strategy. AGENTS.md asks that comments be evergreen for future readers and avoid narrating rejected alternatives / justifying decisions. Trim it to a single line that states the bare fact.

Suggested change
// Progressive-enhancement script for live/optimistic display. Emitted
// verbatim (is:inline) only when `live` is enabled, so pages that don't opt
// in ship zero extra JS. Listens for the `ec:comment:created` event that a
// sibling <CommentForm live> dispatches on successful submission, and
// inserts the new comment client-side — respecting the active sort. Both
// "oldest" and "best" (Wilson-ranked) place a brand-new, zero-reaction
// comment at the very end of its list: chronologically it's the newest, and
// under Wilson ranking a zero-reaction comment scores exactly 0 (the
// minimum), tying with any other zero-reaction comments and losing every
// oldest-first tiebreak against them since it's the newest of the group. So
// "append at the end" is correct for both sorts — no client-side ranking
// math needed. Replies always stay chronological (per the SSR list), so
// appending to the end of the parent's reply list is correct there too.
// Mirrors the escaping/autolink logic in formatBody() above (duplicated,
// not imported — this runs in the browser).
// Client-side replica of the SSR rendering; emitted verbatim as an is:inline script.

}

function renderComment(section, comment, isReply) {
const pending = comment.status !== 'approved';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] comment.status !== 'approved' treats any non-approved status as pending, including spam. The POST endpoint can return spam when a moderation hook classifies the comment, so a spam submission would be rendered as "Awaiting moderation — visible only to you", which is misleading.

Narrow the condition to status === 'pending' and decide what to do with spam/trash (most likely: don't render it to the author at all, since the success message already appears in the form).

@@ -1112,7 +1113,7 @@ const commentsPaths = {
description: "Comment created (pending moderation)",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The 201 response can now be approved when comments are auto-approved (commentsModeration: "none"), so the description "Comment created (pending moderation)" is no longer accurate.

Suggested change
description: "Comment created (pending moderation)",
description: "Comment created",

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed stale review/needs-review No maintainer or bot review yet labels Jul 24, 2026
Addresses review on emdash-cms#1903:

- comment.status !== 'approved' treated a spam-classified submission as
  merely pending, rendering it with "Awaiting moderation" instead of
  hiding it. renderComment now bails out entirely for spam/trash status.
- Trimmed reviewer-facing narrative from the test file header and the
  LIVE_SCRIPT comment block, per AGENTS.md comment discipline.
- Fixed the stale "Comment created (pending moderation)" OpenAPI
  description — the 201 response can be `approved` under
  commentsModeration: "none".
- Replaced the two implementation-detail-string integration tests with a
  Playwright E2E spec (e2e/tests/comments-live.spec.ts) that submits a
  real comment on /posts/first-post and asserts it appears in the DOM
  without a reload.

Note: could not execute the new Playwright spec locally — this Windows
dev environment hits a pre-existing, unrelated `spawn astro ENOENT` in
both e2e/global-setup.ts and the vitest client-integration harness
(node's spawn() doesn't resolve .cmd shims without shell:true on
Windows). Confirmed it's pre-existing by running an untouched spec
(redirects.spec.ts), which fails identically. CI runs on Linux and
should execute it fine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 26, 2026
@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

Addressed the code-level review items:

  • Real bug: comment.status !== 'approved' rendered a spam-classified comment as merely "pending" instead of hiding it. renderComment now bails out entirely for spam/trash status.
  • Fixed the stale OpenAPI 201 description ("pending moderation" — no longer true under commentsModeration: "none").
  • Trimmed the reviewer-facing narrative from the test file header and the LIVE_SCRIPT comment block.
  • Replaced the two implementation-detail-string assertions with e2e/tests/comments-live.spec.ts — a real Playwright test that submits a comment on /posts/first-post and asserts it lands in the DOM without a reload (and that a same-session marker survives, proving no navigation happened). Added live to both <Comments>/<CommentForm> on the e2e fixture's post page (and its Cloudflare counterpart) since neither had it wired up before.

One caveat: I couldn't execute the new Playwright spec locally — this dev environment is Windows, and both e2e/global-setup.ts and the vitest client-integration harness hit a pre-existing spawn astro/pnpm ENOENT (Node's spawn/execFile doesn't resolve .cmd shims without shell: true on Windows). Confirmed it's pre-existing and unrelated to this change by running an untouched spec (redirects.spec.ts), which fails identically. Format/lint/typecheck are clean; CI (Linux) should run the new spec fine.

The Discussion #1497 "Approved for PR" label is still outstanding and outside what I can do here.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant