Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

Add implementation plan for embedded comments in document record

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+459
+459
docs/plans/2026-03-18-embedded-comments.md
··· 1 + # Embedded Comments Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Replace the broken per-commenter ATProto record approach with comments embedded directly in the `com.diffdown.document` record on the owner's PDS. Comments become a portable part of the document. 6 + 7 + **Architecture:** `Document.Comments []EmbeddedComment` is stored inside the ATProto record. `CommentCreate` does read-modify-write on the owner's document via the owner's XRPC client (same pattern as `AutoSave`). `CommentList` reads the document and returns the embedded array. The collaborator passes `ownerDID` in the request so the handler knows whose PDS to talk to. 8 + 9 + **Tech Stack:** Go 1.22, ATProto XRPC, html/template, ProseMirror (Milkdown) click events 10 + 11 + --- 12 + 13 + ### Task 1: Update the Document model 14 + 15 + **Files:** 16 + - Modify: `internal/model/models.go` 17 + 18 + **Step 1: Add `EmbeddedComment` struct and `Comments` field to `Document`** 19 + 20 + Add after the `MarkdownText` struct: 21 + 22 + ```go 23 + type EmbeddedComment struct { 24 + ID string `json:"id"` // random 8-char hex for dedup 25 + ParagraphID string `json:"paragraphId"` // "p-0", "p-1", ... or "general" 26 + Text string `json:"text"` 27 + Author string `json:"author"` // DID 28 + AuthorHandle string `json:"authorHandle"` // resolved handle, may be empty 29 + CreatedAt string `json:"createdAt"` // RFC3339 30 + } 31 + ``` 32 + 33 + Add `Comments []EmbeddedComment` to the `Document` struct, after `Collaborators`: 34 + 35 + ```go 36 + Comments []EmbeddedComment `json:"comments,omitempty"` 37 + ``` 38 + 39 + Keep the existing `Comment` struct for now — it will be removed in Task 2. 40 + 41 + **Step 2: Build** 42 + 43 + ```bash 44 + go build ./... 45 + ``` 46 + 47 + Expected: PASS. 48 + 49 + **Step 3: Commit** 50 + 51 + ```bash 52 + git add internal/model/models.go 53 + git commit -m "Add EmbeddedComment type and Comments field to Document" 54 + ``` 55 + 56 + --- 57 + 58 + ### Task 2: Remove old XRPC comment methods and stale types 59 + 60 + **Files:** 61 + - Modify: `internal/atproto/xrpc/client.go` 62 + - Modify: `internal/model/models.go` 63 + - Modify: `internal/handler/handler.go` 64 + 65 + **Step 1: Delete from `internal/atproto/xrpc/client.go`** 66 + 67 + Remove the `collectionComment` const (line 317) and the `CreateComment` and `ListComments` functions (lines 319-357). 68 + 69 + Check whether `strings` import is still needed elsewhere before removing it: 70 + 71 + ```bash 72 + grep -n 'strings\.' internal/atproto/xrpc/client.go 73 + ``` 74 + 75 + Remove unused import if needed. 76 + 77 + **Step 2: Delete `collectionComment` const from `internal/handler/handler.go`** 78 + 79 + Line 27: `const collectionComment = "com.diffdown.comment"` — delete it. 80 + 81 + **Step 3: Remove the old `Comment` struct from `internal/model/models.go`** 82 + 83 + Delete the `Comment` struct block. 84 + 85 + **Step 4: Build — expect errors** 86 + 87 + ```bash 88 + go build ./... 89 + ``` 90 + 91 + Expected: compile errors about `CommentCreate` and `ListComments` called in handler. These are fixed in Task 3 — do not commit yet. 92 + 93 + --- 94 + 95 + ### Task 3: Rewrite `CommentCreate` and `CommentList` handlers 96 + 97 + **Files:** 98 + - Modify: `internal/handler/handler.go` 99 + 100 + **Step 1: Add `crypto/rand` and `encoding/hex` imports** 101 + 102 + In the `import` block, add both if not already present. 103 + 104 + **Step 2: Add `randomID` helper near the top of handler.go (after imports)** 105 + 106 + ```go 107 + func randomID() string { 108 + b := make([]byte, 4) 109 + rand.Read(b) 110 + return hex.EncodeToString(b) 111 + } 112 + ``` 113 + 114 + **Step 3: Rewrite `CommentCreate`** 115 + 116 + Replace the entire function with: 117 + 118 + ```go 119 + func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { 120 + user, _ := h.currentUser(r) 121 + if user == nil { 122 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 123 + return 124 + } 125 + 126 + rKey := r.PathValue("rkey") 127 + if rKey == "" { 128 + http.Error(w, "Invalid document", http.StatusBadRequest) 129 + return 130 + } 131 + 132 + var req struct { 133 + OwnerDID string `json:"ownerDID"` 134 + ParagraphID string `json:"paragraphId"` 135 + Text string `json:"text"` 136 + } 137 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 138 + http.Error(w, "Invalid request", http.StatusBadRequest) 139 + return 140 + } 141 + if req.Text == "" { 142 + http.Error(w, "Comment text required", http.StatusBadRequest) 143 + return 144 + } 145 + 146 + session, err := h.DB.GetATProtoSession(user.ID) 147 + if err != nil || session == nil { 148 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 149 + return 150 + } 151 + 152 + ownerUserID := user.ID 153 + ownerDID := session.DID 154 + if req.OwnerDID != "" && req.OwnerDID != session.DID { 155 + ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 156 + if err != nil { 157 + http.Error(w, "Owner not found", http.StatusBadRequest) 158 + return 159 + } 160 + ownerUserID = ownerUser.ID 161 + ownerDID = req.OwnerDID 162 + } 163 + 164 + ownerClient, err := h.xrpcClient(ownerUserID) 165 + if err != nil { 166 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 167 + return 168 + } 169 + 170 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 171 + if err != nil { 172 + log.Printf("CommentCreate: GetRecord: %v", err) 173 + http.Error(w, "Document not found", http.StatusNotFound) 174 + return 175 + } 176 + var doc model.Document 177 + if err := json.Unmarshal(value, &doc); err != nil { 178 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 179 + return 180 + } 181 + 182 + authorHandle, _ := atproto.ResolveHandleFromDID(session.DID) 183 + 184 + paragraphID := req.ParagraphID 185 + if paragraphID == "" { 186 + paragraphID = "general" 187 + } 188 + 189 + comment := model.EmbeddedComment{ 190 + ID: randomID(), 191 + ParagraphID: paragraphID, 192 + Text: req.Text, 193 + Author: session.DID, 194 + AuthorHandle: authorHandle, 195 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 196 + } 197 + doc.Comments = append(doc.Comments, comment) 198 + 199 + if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil { 200 + log.Printf("CommentCreate: PutDocument: %v", err) 201 + http.Error(w, "Failed to save comment", http.StatusInternalServerError) 202 + return 203 + } 204 + 205 + h.jsonResponse(w, comment, http.StatusCreated) 206 + } 207 + ``` 208 + 209 + **Step 4: Rewrite `CommentList`** 210 + 211 + Replace the entire function with: 212 + 213 + ```go 214 + func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { 215 + rKey := r.PathValue("rkey") 216 + if rKey == "" { 217 + http.Error(w, "Invalid document", http.StatusBadRequest) 218 + return 219 + } 220 + 221 + user, _ := h.currentUser(r) 222 + if user == nil { 223 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 224 + return 225 + } 226 + 227 + session, err := h.DB.GetATProtoSession(user.ID) 228 + if err != nil || session == nil { 229 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 230 + return 231 + } 232 + 233 + ownerUserID := user.ID 234 + ownerDID := session.DID 235 + if qOwner := r.URL.Query().Get("ownerDID"); qOwner != "" && qOwner != session.DID { 236 + ownerUser, err := h.DB.GetUserByDID(qOwner) 237 + if err != nil { 238 + http.Error(w, "Owner not found", http.StatusBadRequest) 239 + return 240 + } 241 + ownerUserID = ownerUser.ID 242 + ownerDID = qOwner 243 + } 244 + 245 + ownerClient, err := h.xrpcClient(ownerUserID) 246 + if err != nil { 247 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 248 + return 249 + } 250 + 251 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 252 + if err != nil { 253 + log.Printf("CommentList: GetRecord: %v", err) 254 + http.Error(w, "Document not found", http.StatusNotFound) 255 + return 256 + } 257 + var doc model.Document 258 + if err := json.Unmarshal(value, &doc); err != nil { 259 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 260 + return 261 + } 262 + 263 + comments := doc.Comments 264 + if comments == nil { 265 + comments = []model.EmbeddedComment{} 266 + } 267 + h.jsonResponse(w, comments, http.StatusOK) 268 + } 269 + ``` 270 + 271 + **Step 5: Build** 272 + 273 + ```bash 274 + go build ./... 275 + ``` 276 + 277 + Expected: PASS. 278 + 279 + **Step 6: Run tests** 280 + 281 + ```bash 282 + go test ./... 283 + ``` 284 + 285 + Expected: all pass. 286 + 287 + **Step 7: Commit** 288 + 289 + ```bash 290 + git add internal/handler/handler.go internal/model/models.go internal/atproto/xrpc/client.go 291 + git commit -m "Embed comments in document record; rewrite CommentCreate/CommentList" 292 + ``` 293 + 294 + --- 295 + 296 + ### Task 4: Fix comment sidebar visibility (show for owners too) 297 + 298 + **Files:** 299 + - Modify: `templates/document_edit.html` 300 + 301 + **Step 1: Change the sidebar guard from `IsCollaborator` to `or .IsCollaborator .IsOwner`** 302 + 303 + Find line ~85: 304 + ``` 305 + {{if .IsCollaborator}} 306 + ``` 307 + above the `comment-sidebar` div. Change to: 308 + ``` 309 + {{if or .IsCollaborator .IsOwner}} 310 + ``` 311 + 312 + **Step 2: Build** 313 + 314 + ```bash 315 + go build ./... 316 + ``` 317 + 318 + **Step 3: Commit** 319 + 320 + ```bash 321 + git add templates/document_edit.html 322 + git commit -m "Show comment sidebar for document owners, not just collaborators" 323 + ``` 324 + 325 + --- 326 + 327 + ### Task 5: Wire up paragraph click + update API calls 328 + 329 + **Files:** 330 + - Modify: `templates/document_edit.html` 331 + - Modify: `static/css/editor.css` 332 + 333 + **Step 1: Update `submitComment` to include `ownerDID` in POST body** 334 + 335 + The template already has `const ownerDID = '{{.Content.OwnerDID}}'`. 336 + 337 + In `submitComment` (around line 791), the fetch body currently sends: 338 + ```js 339 + { paragraphId: activeCommentParagraphId, text } 340 + ``` 341 + 342 + Change it to build an object, add `ownerDID` if non-empty, then send: 343 + ```js 344 + const body = { paragraphId: activeCommentParagraphId, text }; 345 + if (ownerDID) body.ownerDID = ownerDID; 346 + // then JSON.stringify(body) 347 + ``` 348 + 349 + **Step 2: Update `loadComments` to pass `ownerDID` query param** 350 + 351 + The fetch URL is currently `/api/docs/${rkey}/comments`. Change to append `?ownerDID=...` when `ownerDID` is set: 352 + ```js 353 + const qs = ownerDID ? '?ownerDID=' + encodeURIComponent(ownerDID) : ''; 354 + // then fetch(`/api/docs/${rkey}/comments${qs}`) 355 + ``` 356 + 357 + **Step 3: Add paragraph click detection after `loadComments` function** 358 + 359 + After the `loadComments` function, add a new function `setupParagraphCommentTrigger` that: 360 + 1. Finds `#editor-rich` (the Milkdown container) 361 + 2. Listens for `click` events 362 + 3. Calls `e.target.closest('.ProseMirror')` to confirm click is inside the editor 363 + 4. Calls `e.target.closest('p, h1, h2, h3, h4, h5, h6, li')` to find the paragraph 364 + 5. Computes index: `Array.from(paraEl.parentElement.children).indexOf(paraEl)` — yields 0-based index 365 + 6. Sets `activeCommentParagraphId = 'p-' + idx` 366 + 7. Positions and shows `commentBtn` to the right of the paragraph using `getBoundingClientRect()` + `window.scrollY` 367 + 368 + Call `setupParagraphCommentTrigger()` immediately after defining it. 369 + 370 + **Step 4: Add/verify `.comment-btn` CSS in `static/css/editor.css`** 371 + 372 + Check whether `.comment-btn` has `position: fixed` or `position: absolute` and `z-index`. If not present, add: 373 + 374 + ```css 375 + .comment-btn { 376 + position: fixed; 377 + z-index: 100; 378 + } 379 + ``` 380 + 381 + **Step 5: Build** 382 + 383 + ```bash 384 + go build ./... 385 + ``` 386 + 387 + **Step 6: Commit** 388 + 389 + ```bash 390 + git add templates/document_edit.html static/css/editor.css 391 + git commit -m "Wire paragraph click to comment button; pass ownerDID in comment API calls" 392 + ``` 393 + 394 + --- 395 + 396 + ### Task 6: Improve comment sidebar rendering 397 + 398 + **Files:** 399 + - Modify: `templates/document_edit.html` 400 + 401 + **Step 1: Update `renderCommentThreads` paragraph label** 402 + 403 + In the `renderCommentThreads` function, the thread label is currently: 404 + ``` 405 + ¶ ${paragraphId} 406 + ``` 407 + 408 + Change to compute a human-readable label before building the thread HTML: 409 + ```js 410 + const label = pid === 'general' 411 + ? 'General' 412 + : 'Paragraph ' + (parseInt(pid.replace('p-', ''), 10) + 1); 413 + ``` 414 + 415 + Then use `label` in the label div text content (via `escHtml(label)`). 416 + 417 + **Step 2: Update author display to prefer `authorHandle`** 418 + 419 + The comment item shows `c.authorName || c.author`. Since the new struct uses `authorHandle`, update to `c.authorHandle || c.author`. 420 + 421 + **Step 3: Build and run all tests** 422 + 423 + ```bash 424 + go build ./... && go test ./... 425 + ``` 426 + 427 + Expected: PASS — 3 test packages, all green. 428 + 429 + **Step 4: Commit** 430 + 431 + ```bash 432 + git add templates/document_edit.html 433 + git commit -m "Improve comment rendering: human-readable paragraph labels, authorHandle" 434 + ``` 435 + 436 + --- 437 + 438 + ### Task 7: Manual smoke test 439 + 440 + **Start the server from the worktree:** 441 + 442 + ```bash 443 + go run ./cmd/server 444 + ``` 445 + 446 + 1. **Owner:** Sign in, open a document. Click a paragraph body — the blue "Comment" button should appear to the right. Click it, write a comment, post. The sidebar appears with the comment under "Paragraph N". 447 + 448 + 2. **Collaborator:** Sign in as the collaborator, navigate to the shared doc via `/docs/{ownerDID}/{rkey}`. Click a paragraph, post a comment. The comment appears in the sidebar with your handle. 449 + 450 + 3. **Owner sees collaborator's comment:** Sign back in as owner on the same document — comment is visible in the sidebar. 451 + 452 + --- 453 + 454 + ## Notes 455 + 456 + - **No DB migration needed** — comments live entirely in the ATProto record. 457 + - **Concurrent comment race:** `CommentCreate` reads then writes; two concurrent comments could overwrite each other (last write wins). Acceptable trade-off; CID-based optimistic locking is a future improvement. 458 + - **Positional paragraph IDs:** `p-0`, `p-1` are index-based. Document edits may shift which content a comment refers to. Stable UUIDs embedded in ProseMirror schema would require a bigger refactor. 459 + - **Old `com.diffdown.comment` records:** Orphaned on user PDSes. Cleanup is out of scope.