From e54aaf76f986606b9caacddc83e0fcff80797f0f Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 22:07:16 +0200 Subject: [PATCH] Add the comments panel, unread badge, and comment-policy setting (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New comments panel on the page (toggle next to attachments, unread badge counting comments newer than the last localStorage-recorded visit): threaded display with relative times and author names, a Markdown composer with hints, edit/delete for authors, resolve moving threads into a collapsed resolved
section with reopen, and a permission-aware composer — hidden with a hint when the pond's policy bars the viewer (readers always see the discussion). The pond settings page gains the "who may comment" select. Fixes PondsService.update silently dropping commentPolicy from the settings merge (found by the new two-user Playwright pack; the DB test now exercises the real pond PATCH). New comments i18n namespace (de+en); the pack runs as its own CI step. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 10 + apps/api/src/comments/comments.e2e.db.test.ts | 9 +- apps/api/src/ponds/ponds.service.ts | 24 +- apps/web/e2e/comments.spec.ts | 150 ++++++++ .../web/src/comments/CommentPolicySetting.tsx | 53 +++ apps/web/src/comments/CommentsPanel.tsx | 330 ++++++++++++++++++ apps/web/src/comments/use-comments.ts | 60 ++++ apps/web/src/i18n/index.ts | 4 + apps/web/src/pages/PageEditorPage.tsx | 39 ++- apps/web/src/pages/PondSettingsPage.tsx | 12 + apps/web/src/styles/base.css | 145 ++++++++ packages/shared/i18n/de/comments.json | 39 +++ packages/shared/i18n/en/comments.json | 39 +++ 13 files changed, 902 insertions(+), 12 deletions(-) create mode 100644 apps/web/e2e/comments.spec.ts create mode 100644 apps/web/src/comments/CommentPolicySetting.tsx create mode 100644 apps/web/src/comments/CommentsPanel.tsx create mode 100644 apps/web/src/comments/use-comments.ts create mode 100644 packages/shared/i18n/de/comments.json create mode 100644 packages/shared/i18n/en/comments.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c88a544..5b67244 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -222,6 +222,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/system.spec.ts + - name: Reset login rate limit before comments pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run comments pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/comments.spec.ts + - name: Reset login rate limit before admin-quotas pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/comments/comments.e2e.db.test.ts b/apps/api/src/comments/comments.e2e.db.test.ts index 2b9030d..32b5551 100644 --- a/apps/api/src/comments/comments.e2e.db.test.ts +++ b/apps/api/src/comments/comments.e2e.db.test.ts @@ -194,7 +194,14 @@ describe.skipIf(!hasTestDb)('comments (e2e, issue #91)', () => { }); it('enforces the editors-only policy', async () => { - await setPolicy('editors'); + // Through the real pond PATCH — guards the settings merge in + // PondsService.update (a silently dropped commentPolicy broke the UI + // pack during #92). + await api() + .patch(`/api/v1/ponds/${pondId}`) + .set('Cookie', cookies.owner!) + .send({ commentPolicy: 'editors' }) + .expect(200); await api() .post(`/api/v1/pages/${pageId}/comments`) .set('Cookie', cookies.reader!) diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 06207ff..072a5d6 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -146,16 +146,20 @@ export class PondsService { async update(_user: User, id: string, input: UpdatePondInput): Promise { const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } }); if (!pond) throw new NotFoundException(); - // Stored settings hold only deviations from the defaults; merge in whichever - // of `sidebarSort` / `fonts` this request changes (issue #26 / #66). - const settings = - input.sidebarSort === undefined && input.fonts === undefined - ? undefined - : { - ...(pond.settings as object), - ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), - ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), - }; + // Stored settings hold only deviations from the defaults; merge in + // whichever of the settings keys this request changes (#26/#66/#91). + const settingsChanged = + input.sidebarSort !== undefined || + input.fonts !== undefined || + input.commentPolicy !== undefined; + const settings = !settingsChanged + ? undefined + : { + ...(pond.settings as object), + ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), + ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), + ...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}), + }; const updated = await this.prisma.pond.update({ where: { id }, data: { name: input.name, description: input.description, settings }, diff --git a/apps/web/e2e/comments.spec.ts b/apps/web/e2e/comments.spec.ts new file mode 100644 index 0000000..0e0d855 --- /dev/null +++ b/apps/web/e2e/comments.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, type BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Comments UI (issue #92): the full two-user lifecycle in the panel, + * resolve/unresolve with the collapsed section, the permission variant + * (composer hidden with a hint), and the localStorage unread badge. + * The pack provisions its own page and grant, so it is repeatable. + */ + +let pondId: string; +let pageUrl: string; +let pageId: string; +let readerUserId: string; + +async function pondOf(owner: BrowserContext): Promise<{ id: string; slug: string }> { + const ponds = (await (await owner.request.get('/api/v1/ponds')).json()) as { + id: string; + slug: string; + type: string; + }[]; + const shared = ponds.find((p) => p.type === 'shared'); + expect(shared).toBeTruthy(); + return shared!; +} + +test.beforeAll(async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await pondOf(owner); + pondId = pond.id; + + // Fresh page per run — never depends on fixture pages' trash state. + const created = await owner.request.post(`/api/v1/ponds/${pondId}/pages`, { + data: { title: `Comment stage ${Date.now()}` }, + }); + expect(created.ok()).toBe(true); + const page = (await created.json()) as { id: string; slug: string }; + pageId = page.id; + pageUrl = `/p/${pond.slug}/${page.slug}`; + + // fixture-editor becomes a reader-member of the pond (the second user); + // a leftover membership from an aborted run just yields a conflict we ignore. + await owner.request.post(`/api/v1/ponds/${pondId}/members`, { + data: { usernameOrEmail: 'fixture-editor', role: 'reader' }, + }); + const members = (await (await owner.request.get(`/api/v1/ponds/${pondId}/members`)).json()) as { + members: { id: string; username: string }[]; + }; + readerUserId = members.members.find((m) => m.username === 'fixture-editor')!.id; + // Deterministic starting policy. + await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } }); + await owner.close(); +}); + +test.afterAll(async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } }); + if (readerUserId) await owner.request.delete(`/api/v1/ponds/${pondId}/members/${readerUserId}`); + if (pageId) await owner.request.delete(`/api/v1/pages/${pageId}`); + await owner.close(); +}); + +test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => { + // The reader starts the thread. + const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const readerPage = await reader.newPage(); + await readerPage.goto(pageUrl); + await readerPage.locator('.editor-shell__comments-toggle').click(); + const readerPanel = readerPage.locator('.comments-panel'); + await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?'); + await readerPanel.locator('.comments-composer button[type="submit"]').click(); + await expect(readerPanel.locator('.comment__body strong')).toHaveText('final'); + + // The owner sees the unread badge, replies, and resolves the thread. + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ownerPage = await owner.newPage(); + await ownerPage.goto(pageUrl); + await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible(); + await ownerPage.locator('.editor-shell__comments-toggle').click(); + const ownerPanel = ownerPage.locator('.comments-panel'); + await ownerPanel + .locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i }) + .click(); + await ownerPanel.locator('.comments-thread textarea').fill('Yes, shipping it.'); + await ownerPanel.locator('.comments-thread button[type="submit"]').click(); + await expect(ownerPanel.locator('.comments-thread__replies .comment__body')).toContainText( + 'shipping', + ); + + // Resolve collapses the thread into the resolved section. + await ownerPanel + .locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i }) + .first() + .click(); + const resolvedSection = ownerPanel.locator('.comments-panel__resolved'); + await expect(resolvedSection).toBeVisible(); + await expect(resolvedSection.locator('details, summary').first()).toBeVisible(); + // Collapsed: the thread body is hidden until the section is opened. + await expect(resolvedSection.locator('.comment__body strong')).toBeHidden(); + await resolvedSection.locator('summary').click(); + await expect(resolvedSection.locator('.comment__body strong')).toBeVisible(); + + // Unresolve restores it to the open list. + await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click(); + await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0); + await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible(); + + // Author edits and deletes own reply. + const replyItem = ownerPanel.locator('.comments-thread__replies .comment'); + await replyItem.locator('.comments-link-button', { hasText: /edit|bearbeit/i }).click(); + await replyItem.locator('textarea').fill('Yes — shipped.'); + await replyItem.locator('button[type="submit"]').click(); + await expect(replyItem.locator('.comment__body')).toContainText('shipped.'); + await expect(replyItem.locator('.comment__edited')).toBeVisible(); + await replyItem.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); + await expect(ownerPanel.locator('.comments-thread__replies .comment')).toHaveCount(0); + + // Cleanup: the reader deletes their own (now reply-free) root. + await readerPage.reload(); + await readerPage.locator('.editor-shell__comments-toggle').click(); + await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); + await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible(); + + await reader.close(); + await owner.close(); +}); + +test('the composer hides with a hint when the policy bars readers', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'editors' } }); + await owner.close(); + + const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const page = await reader.newPage(); + await page.goto(pageUrl); + await page.locator('.editor-shell__comments-toggle').click(); + const panel = page.locator('.comments-panel'); + await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible(); + await expect(panel.locator('.comments-composer')).toHaveCount(0); + await reader.close(); + + const ownerAgain = await contextForUser(browser, BASE_URL, 'fixture-user'); + await ownerAgain.request.patch(`/api/v1/ponds/${pondId}`, { + data: { commentPolicy: 'readers' }, + }); + await ownerAgain.close(); +}); diff --git a/apps/web/src/comments/CommentPolicySetting.tsx b/apps/web/src/comments/CommentPolicySetting.tsx new file mode 100644 index 0000000..399ead2 --- /dev/null +++ b/apps/web/src/comments/CommentPolicySetting.tsx @@ -0,0 +1,53 @@ +import type { CommentPolicy } from '@dorfteich/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormError, FormSuccess } from '../components/forms'; +import { apiPatch } from '../lib/api'; + +/** + * The pond's "who may comment" setting (issue #91/#92): every reader, or + * pond-wide editors only. Rides the generic pond PATCH. + */ +export function CommentPolicySetting({ + pondId, + pondSlug, + value, +}: { + pondId: string; + pondSlug: string; + value: CommentPolicy; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + const save = async (policy: CommentPolicy): Promise => { + setError(null); + setSaved(false); + try { + await apiPatch(`/ponds/${pondId}`, { commentPolicy: policy }); + await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); + setSaved(true); + } catch (err) { + setError(err); + } + }; + + return ( +
+ + + +

{t('policy.hint')}

+
+ ); +} diff --git a/apps/web/src/comments/CommentsPanel.tsx b/apps/web/src/comments/CommentsPanel.tsx new file mode 100644 index 0000000..12c1f9f --- /dev/null +++ b/apps/web/src/comments/CommentsPanel.tsx @@ -0,0 +1,330 @@ +import type { CommentThreadView, CommentView } from '@dorfteich/shared'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAuth } from '../auth/auth-context'; +import { FormError } from '../components/forms'; +import { apiDelete, apiPatch, apiPost } from '../lib/api'; + +import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments'; + +/** + * The page's discussion panel (issue #92): threaded comments with a + * Markdown composer, edit/delete for authors, resolve with a collapsed + * resolved section, and the permission-aware composer (hidden with a hint + * when the pond's policy bars the viewer). + */ +export function CommentsPanel({ + pageId, + mayComment, + onClose, +}: { + pageId: string; + /** Resolved by the caller from the pond's commentPolicy + collab mode. */ + mayComment: boolean; + onClose: () => void; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const comments = useComments(pageId); + const refresh = useInvalidateComments(pageId); + const [error, setError] = useState(null); + + // Opening the panel is "visiting" the discussion: the unread badge resets. + useEffect(() => { + markCommentsSeen(pageId); + return () => markCommentsSeen(pageId); + }, [pageId, comments.data]); + + const run = async (action: () => Promise): Promise => { + setError(null); + try { + await action(); + await refresh(); + } catch (err) { + setError(err); + } + }; + + const view = comments.data; + const open = (view?.threads ?? []).filter((thread) => !thread.resolved); + const resolved = (view?.threads ?? []).filter((thread) => thread.resolved); + + return ( +
+
+

+ {t('title')} + {view && view.openCount > 0 && ( + {view.openCount} + )} +

+ +
+ + + {mayComment ? ( + run(() => apiPost(`/pages/${pageId}/comments`, { body }))} + /> + ) : ( +

{t('composer.editorsOnly')}

+ )} + + {view && open.length === 0 && resolved.length === 0 && ( +

{t('empty')}

+ )} + +
    + {open.map((thread) => ( + + ))} +
+ + {resolved.length > 0 && ( +
+ {t('resolvedSection', { count: resolved.length })} +
    + {resolved.map((thread) => ( + + ))} +
+
+ )} +
+ ); +} + +function Thread({ + thread, + pageId, + mayComment, + run, +}: { + thread: CommentThreadView; + pageId: string; + mayComment: boolean; + run: (action: () => Promise) => Promise; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const [replying, setReplying] = useState(false); + + return ( +
  • + +
      + {thread.replies.map((reply) => ( +
    • + +
    • + ))} +
    + {mayComment && !thread.resolved && ( +
    + {replying ? ( + setReplying(false)} + onSubmit={async (body) => { + await run(() => + apiPost(`/pages/${pageId}/comments`, { body, parentId: thread.root.id }), + ); + setReplying(false); + }} + /> + ) : ( + + )} +
    + )} +
  • + ); +} + +function CommentItem({ + comment, + run, + isRoot, + resolved, +}: { + comment: CommentView; + run: (action: () => Promise) => Promise; + isRoot: boolean; + resolved: boolean; +}): React.JSX.Element { + const { t, i18n } = useTranslation('comments'); + const { user } = useAuth(); + const [editing, setEditing] = useState(false); + const own = user?.id === comment.author?.id; + + return ( +
    +
    + {authorName(comment, t)} + + {comment.editedAt && {t('edited')}} +
    + {editing ? ( + setEditing(false)} + onSubmit={async (body) => { + await run(() => apiPatch(`/comments/${comment.id}`, { body })); + setEditing(false); + }} + /> + ) : ( + // Server-sanitized render (shared pipeline, issue #91) — safe by contract. +
    + )} +
    + {own && !editing && ( + <> + + + + )} + {isRoot && + (resolved ? ( + + ) : ( + + ))} +
    +
    + ); +} + +function Composer({ + label, + submitLabel, + onSubmit, + onCancel, + initialValue = '', + autoFocus = false, +}: { + label: string; + submitLabel: string; + onSubmit: (body: string) => Promise | void; + onCancel?: () => void; + initialValue?: string; + autoFocus?: boolean; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const [body, setBody] = useState(initialValue); + const [busy, setBusy] = useState(false); + + const submit = async (): Promise => { + const trimmed = body.trim(); + if (!trimmed || busy) return; + setBusy(true); + try { + await onSubmit(trimmed); + setBody(''); + } finally { + setBusy(false); + } + }; + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > +