Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions app/api/v1/docs/[slug]/comments/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { findBySlug } from "@/lib/docs/store";
import { canView } from "@/lib/docs/access";
import { isOwner } from "@/lib/docs/grants";
import { checkLimits } from "@/lib/auth/ratelimit";
import { parseAnchor, type TextAnchor } from "@/lib/docs/anchor";
import {
findComment,
commentView,
editCommentBody,
reanchorComment,
setResolved,
softDeleteComment,
resolveCommentPrincipal,
Expand All @@ -23,8 +25,10 @@ export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ slug: string; id: string }> };

// /api/v1/docs/:slug/comments/:id
// PATCH — edit body (author only) and/or resolve|unresolve (anyone who can
// comment). birthday.md "Permission matrix".
// PATCH — edit body (author only), re-anchor/detach (author own, doc owner
// any; the manual fix for an orphaned thread), and/or
// resolve|unresolve (anyone who can comment). birthday.md
// "Permission matrix".
// DELETE — soft-delete (author own, owner any).
//
// Auth: API key OR session, same as POST /comments.
Expand Down Expand Up @@ -73,9 +77,10 @@ export async function PATCH(req: Request, ctx: Ctx): Promise<Response> {
const b = parsed.obj;

const hasBody = b.body !== undefined;
const hasAnchor = b.anchor !== undefined;
const hasResolved = b.resolved !== undefined;
if (!hasBody && !hasResolved) {
return apiError(400, "invalid_request", "Provide 'body' (edit) and/or 'resolved' (resolve/unresolve).");
if (!hasBody && !hasAnchor && !hasResolved) {
return apiError(400, "invalid_request", "Provide 'body' (edit), 'anchor' (re-anchor/detach), and/or 'resolved' (resolve/unresolve).");
}

// Edit body: AUTHOR ONLY. The author 403 precedes field validation (ordering
Expand All @@ -98,6 +103,27 @@ export async function PATCH(req: Request, ctx: Ctx): Promise<Response> {
await editCommentBody(doc.id, commentId, body);
}

// Re-anchor / detach: AUTHOR (own) OR DOC OWNER (any) — same shape as
// delete. The owner can repair orphaned threads on their document regardless
// of who authored them. A new quote re-resolves against the current doc text
// (un-orphaning on success); null detaches to a doc-level comment. Replies
// carry no anchor, same rule as POST /comments.
if (hasAnchor) {
if (!isAuthor && !cap.isOwner) {
return apiError(403, "forbidden", "Only the comment's author or the document owner can re-anchor it.");
}
if (comment.parent_id !== null) {
return apiError(400, "invalid_request", "A reply cannot carry its own anchor; omit 'anchor' on replies.");
}
let anchor: TextAnchor | null = null;
if (b.anchor !== null) {
const parsed = parseAnchor(b.anchor);
if ("error" in parsed) return apiError(400, "invalid_request", parsed.error);
anchor = parsed.anchor;
}
await reanchorComment(doc, commentId, anchor);
}

// Resolve / unresolve: ANYONE WHO CAN COMMENT.
if (hasResolved) {
const resolvedParse = PatchResolvedField.safeParse(b.resolved);
Expand Down
113 changes: 111 additions & 2 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ type Props = {
// server-side by canEdit). Everyone else — including view-token holders who may
// comment — never sees the affordance, and the API would refuse them anyway.
canEdit: boolean;
// The viewer owns this doc (resolved server-side). Owners can re-anchor ANY
// orphaned thread on their document; everyone else only their own.
isOwner: boolean;
signedIn: boolean;
docId: number;
bookmarked: boolean;
Expand Down Expand Up @@ -164,6 +167,7 @@ export default function CommentsShell(props: Props) {
canComment,
canReact,
canEdit,
isOwner,
signedIn,
docId,
me,
Expand Down Expand Up @@ -278,6 +282,10 @@ export default function CommentsShell(props: Props) {
// Selection state (from the overlay) → the floating toolbar + a pending draft.
const [selection, setSelection] = useState<{ anchor: NonNullable<Anchor>; top: number; viewTop: number } | null>(null);
const [draft, setDraft] = useState<{ anchor: NonNullable<Anchor>; top: number } | null>(null);
// Re-anchor mode: the author of an orphaned thread is picking a new passage
// in the doc. While set, a selection drives a confirm bar (PATCH anchor)
// instead of the comment/react toolbar.
const [reanchorId, setReanchorId] = useState<number | null>(null);

const apiBase = `/api/v1/docs/${encodeURIComponent(slug)}`;
const tokenQuery = viewtoken ? `?viewtoken=${encodeURIComponent(viewtoken)}` : "";
Expand Down Expand Up @@ -576,6 +584,22 @@ export default function CommentsShell(props: Props) {
[apiBase, tokenQuery, reload]
);

// Re-anchor a thread to a fresh quote (the orphaned-thread fix), or detach
// when the caller passes anchor null. Server enforces author-own / owner-any.
const reanchor = useCallback(
async (id: number, anchor: NonNullable<Anchor> | null) => {
const r = await fetch(`${apiBase}/comments/${id}${tokenQuery}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ anchor }),
});
if (r.ok) await reload();
return r.ok;
},
[apiBase, tokenQuery, reload]
);

const deleteComment = useCallback(
async (id: number) => {
const r = await fetch(`${apiBase}/comments/${id}${tokenQuery}`, {
Expand Down Expand Up @@ -1036,7 +1060,27 @@ export default function CommentsShell(props: Props) {
selection's viewport-top within the iframe. Shows on selection when
the viewer can comment OR react (react-only viewers still get the
react affordance). */}
{selection && !editing && (canComment || canReact) ? (
{selection && !editing && reanchorId != null ? (
<ReanchorBar
viewTop={selection.viewTop}
exact={selection.anchor.exact}
onConfirm={async () => {
const ok = await reanchor(reanchorId, selection.anchor);
setSelection(null);
postToOverlay({ type: "jh:clearSelection" });
if (ok) {
setReanchorId(null);
showToast("comment re-anchored");
} else {
showToast("couldn't re-anchor — only the author or doc owner can");
}
}}
onCancel={() => {
setSelection(null);
postToOverlay({ type: "jh:clearSelection" });
}}
/>
) : selection && !editing && (canComment || canReact) ? (
<SelectionToolbar
viewTop={selection.viewTop}
canComment={canComment}
Expand Down Expand Up @@ -1094,6 +1138,22 @@ export default function CommentsShell(props: Props) {
</span>
</div>

{reanchorId != null ? (
<div style={{ padding: "7px 10px", fontSize: 11.5, color: "var(--jh-rail-muted, #666)", borderBottom: "1px solid var(--jh-rail-line, #eee)", background: "var(--jh-composer-bg, #fafafa)" }}>
re-anchoring — select the new passage in the document{" "}
<span
style={{ cursor: "pointer", textDecoration: "underline" }}
onClick={() => {
setReanchorId(null);
setSelection(null);
postToOverlay({ type: "jh:clearSelection" });
}}
>
cancel
</span>
</div>
) : null}

{/* Doc-level reactions, compact in the rail header (birthday.md B11:
"doc-level reactions render compactly in the rail header"). The
react chip set + a mini picker for anyone who can react. */}
Expand Down Expand Up @@ -1132,9 +1192,12 @@ export default function CommentsShell(props: Props) {
else postToOverlay({ type: "jh:focus", key: null });
}}
onHover={(id) => setActiveId(id)}
me={me}
isOwner={isOwner}
onReply={postComment}
onResolve={toggleResolve}
onDelete={deleteComment}
onReanchor={(id) => setReanchorId(id)}
onReact={react}
onSubmitDraft={async (body) => {
if (!draft) return;
Expand Down Expand Up @@ -1254,6 +1317,37 @@ function ThemeToggle({ mode, onChange }: { mode: ThemeMode; onChange: (m: ThemeM
);
}

// Re-anchor confirm bar — shown in place of the selection toolbar while the
// author of an orphaned thread is picking the replacement passage. Same floating
// style/positioning as the selection toolbar.
function ReanchorBar({
viewTop,
exact,
onConfirm,
onCancel,
}: {
viewTop: number;
exact: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const top = `max(8px, min(${Math.max(8, Math.round(viewTop))}px, calc(100% - 84px)))`;
const excerpt = exact.length > 60 ? `${exact.slice(0, 60)}…` : exact;
return (
<div style={{ ...seltoolStyle, top, flexDirection: "row", alignItems: "center", gap: 6, padding: "5px 7px", maxWidth: 260 }}>
<span style={{ color: "var(--jh-sel-fg, #fff)", fontSize: 11, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
re-anchor to “{excerpt}”?
</span>
<button title="re-anchor here" style={{ ...seltoolBtn, width: "auto", height: "auto", padding: "2px 7px", fontSize: 11 }} onClick={onConfirm}>
re-anchor
</button>
<button title="keep selecting" style={{ ...seltoolBtn, width: "auto", height: "auto", padding: "2px 5px", fontSize: 11, opacity: 0.75 }} onClick={onCancel}>
</button>
</div>
);
}

function SelectionToolbar({
viewTop,
canComment,
Expand Down Expand Up @@ -1313,11 +1407,14 @@ function RailCards(props: {
activeId: number | null;
canComment: boolean;
draft: { anchor: NonNullable<Anchor>; top: number } | null;
me: string | null;
isOwner: boolean;
onPin: (id: number | null) => void;
onHover: (id: number | null) => void;
onReply: (body: string, anchor: null, parentId: number) => Promise<boolean>;
onResolve: (id: number, resolved: boolean) => void;
onDelete: (id: number) => void;
onReanchor: (id: number) => void;
onReact: (emoji: string, commentId: number | null) => void;
onSubmitDraft: (body: string) => void;
onCancelDraft: () => void;
Expand All @@ -1333,11 +1430,14 @@ function RailCards(props: {
activeId,
canComment,
draft,
me,
isOwner,
onPin,
onHover,
onReply,
onResolve,
onDelete,
onReanchor,
onReact,
onSubmitDraft,
onCancelDraft,
Expand Down Expand Up @@ -1413,6 +1513,8 @@ function RailCards(props: {
onReply={(body) => onReply(body, null, t.id)}
onResolve={(resolved) => onResolve(t.id, resolved)}
onDelete={() => onDelete(t.id)}
canReanchor={isOwner || (me != null && t.author === me)}
onReanchor={() => onReanchor(t.id)}
onReact={(emoji) => onReact(emoji, t.id)}
onCopyLink={() => onCopyLink(t.id)}
/>
Expand Down Expand Up @@ -1453,11 +1555,13 @@ const Card = forwardRef<
onReply: (body: string) => Promise<boolean>;
onResolve: (resolved: boolean) => void;
onDelete: () => void;
canReanchor: boolean;
onReanchor: () => void;
onReact: (emoji: string) => void;
onCopyLink: () => void;
}
>(function Card(
{ thread: t, pinned, active, canComment, onPin, onHoverIn, onHoverOut, onReply, onResolve, onDelete, onReact, onCopyLink },
{ thread: t, pinned, active, canComment, onPin, onHoverIn, onHoverOut, onReply, onResolve, onDelete, canReanchor, onReanchor, onReact, onCopyLink },
ref
) {
const [replyText, setReplyText] = useState("");
Expand Down Expand Up @@ -1580,6 +1684,11 @@ const Card = forwardRef<
<span style={{ cursor: "pointer" }} onClick={() => onDelete()}>
delete
</span>
{t.orphaned && canReanchor ? (
<span style={{ cursor: "pointer" }} title="Point this comment at a new passage" onClick={() => onReanchor()}>
re-anchor
</span>
) : null}
{showEmoji ? (
<span style={{ display: "flex", gap: 4 }}>
{EMOJIS.map((e) => (
Expand Down
3 changes: 3 additions & 0 deletions app/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ export default async function ViewerPage({ params, searchParams }: Props) {
let canComment = false;
let canReact = false;
let canEditDoc = false;
let isOwner = false;
if (principal) {
const cap = await resolveCapability(doc, principal, canView(doc, viewtoken));
canComment = cap.canComment;
canReact = cap.canReact;
canEditDoc = canEdit(cap.access);
isOwner = cap.isOwner;
}

const threadData = await allThreads(doc);
Expand Down Expand Up @@ -130,6 +132,7 @@ export default async function ViewerPage({ params, searchParams }: Props) {
canComment={canComment}
canReact={canReact}
canEdit={canEditDoc}
isOwner={isOwner}
signedIn={session !== null}
docId={doc.id}
bookmarked={bookmarked}
Expand Down
35 changes: 35 additions & 0 deletions lib/docs/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,41 @@ export async function createComment(opts: {
}
}

/**
* Re-anchor a comment to a new quote — the manual fix for an orphaned thread
* whose text was rewritten rather than deleted (automatic re-anchoring only
* un-orphans when the ORIGINAL quote comes back). Author (own) or doc owner
* (any) — enforced by caller. `anchor === null` detaches: the comment becomes
* doc-level. Otherwise
* the new selector is resolved against the current doc text exactly like at
* creation (resolveInitialAnchor), so a re-anchored comment and a freshly
* created one land identically: offsets re-stamped, anchored_version set to the
* current version, orphaned recomputed — a new quote that doesn't resolve yet
* keeps the comment orphaned with the NEW selector stored, so a later restoring
* edit can still un-orphan it.
*/
export async function reanchorComment(
doc: DocRow,
commentId: number,
anchor: TextAnchor | null
): Promise<CommentRow | null> {
if (anchor === null) {
await query(
`UPDATE comments SET anchor = NULL, anchored_version = NULL, orphaned = false
WHERE id = $1 AND doc_id = $2 AND deleted_at IS NULL`,
[commentId, doc.id]
);
} else {
const { anchorJson, orphaned, anchoredVersion } = resolveInitialAnchor(doc.html, anchor, doc.version);
await query(
`UPDATE comments SET anchor = $3, anchored_version = $4, orphaned = $5
WHERE id = $1 AND doc_id = $2 AND deleted_at IS NULL`,
[commentId, doc.id, anchorJson, anchoredVersion, orphaned]
);
}
return findComment(doc.id, commentId);
}

/** Edit a comment's body (author only — enforced by caller). Sets edited_at. */
export async function editCommentBody(docId: number, commentId: number, body: string): Promise<CommentRow | null> {
await query(
Expand Down
8 changes: 5 additions & 3 deletions lib/docs/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,12 +490,14 @@ registry.registerPath({
},
});

// PATCH /api/v1/docs/{slug}/comments/{id} — edit body / resolve toggle
// PATCH /api/v1/docs/{slug}/comments/{id} — edit body / re-anchor / resolve toggle
registry.registerPath({
method: "patch",
path: "/api/v1/docs/{slug}/comments/{id}",
tags: ["collaboration"],
summary: "Edit body (author) and/or resolve/unresolve (anyone who can comment)",
summary: "Edit body (author), re-anchor/detach (author or doc owner), and/or resolve/unresolve (anyone who can comment)",
description:
"anchor re-anchors the comment to a new quote (re-resolved against the current text; un-orphans on success) or, when null, detaches it to a doc-level comment — the manual fix for an orphaned thread whose quoted text was rewritten. The comment's author or the document owner; root comments only.",
operationId: "updateComment",
security: keyOrSessionSecurity,
request: {
Expand All @@ -510,7 +512,7 @@ registry.registerPath({
400: { description: "Invalid request body or parameters", content: jsonError },
401: { description: "Missing/invalid credential", content: jsonError },
403: {
description: "Editing another author's body, or resolving without comment rights",
description: "Editing another author's body, re-anchoring without being the author or doc owner, or resolving without comment rights",
content: jsonError,
},
404: {
Expand Down
Loading