import type { PageCommentsView, CommentThreadView, CommentView } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../auth/auth-context'; import { FormError } from '../components/forms'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments'; /** * The page's discussion (issue #92), rendered inline in the read view between * the backlinks and the local graph (issue #133): threaded comments with a * Markdown composer, edit/delete for authors, and resolve with a collapsed * resolved section. The composer is hidden with a hint when the pond's policy * bars the viewer. Replaces the former slide-in panel. */ export function CommentsSection({ pageId, mayComment, }: { pageId: string; /** Resolved by the caller from the pond's commentPolicy + write access. */ mayComment: boolean; }): React.JSX.Element { const { t } = useTranslation('comments'); const comments = useComments(pageId); const refresh = useInvalidateComments(pageId); const [error, setError] = useState(null); // Seeing the inline section is "visiting" the discussion: the unread notion // (used by comment notifications) resets while it is on screen. 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); } }; return (

{t('title')} {comments.data && comments.data.openCount > 0 && ( {comments.data.openCount} )}

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

{t('composer.editorsOnly')}

)}
); } /** * Read-only comments for the anonymous public view (issue #133): fetched from * the public endpoint (no auth, no composer, no action controls). The section * is omitted entirely when the page has no comments, so public pages stay clean. */ export function PublicComments({ pondSlug, pageSlug, }: { pondSlug: string; pageSlug: string; }): React.JSX.Element | null { const { t } = useTranslation('comments'); const query = useQuery({ queryKey: ['public-comments', pondSlug, pageSlug], queryFn: () => apiGet(`/public/${pondSlug}/${pageSlug}/comments`), enabled: Boolean(pondSlug && pageSlug), retry: false, }); const view = query.data; if (!view || view.threads.length === 0) return null; return (

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

); } const noop = async (): Promise => {}; function CommentThreads({ view, pageId, mayComment, run, readOnly, }: { view: PageCommentsView | undefined; pageId: string; mayComment: boolean; run: (action: () => Promise) => Promise; readOnly: boolean; }): React.JSX.Element { const { t } = useTranslation('comments'); const open = (view?.threads ?? []).filter((thread) => !thread.resolved); const resolved = (view?.threads ?? []).filter((thread) => thread.resolved); return ( <> {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, readOnly, }: { thread: CommentThreadView; pageId: string; mayComment: boolean; run: (action: () => Promise) => Promise; readOnly: boolean; }): React.JSX.Element { const { t } = useTranslation('comments'); const [replying, setReplying] = useState(false); return (
    • {thread.replies.map((reply) => (
    • ))}
    {!readOnly && 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, mayComment, readOnly, }: { comment: CommentView; run: (action: () => Promise) => Promise; isRoot: boolean; resolved: boolean; mayComment: boolean; readOnly: 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.
    )} {!readOnly && (
    {own && !editing && ( <> )} {isRoot && mayComment && (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(); }} >