Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 459 lines 13 kB view raw view rendered
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 20Add after the `MarkdownText` struct: 21 22```go 23type 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 33Add `Comments []EmbeddedComment` to the `Document` struct, after `Collaborators`: 34 35```go 36Comments []EmbeddedComment `json:"comments,omitempty"` 37``` 38 39Keep the existing `Comment` struct for now — it will be removed in Task 2. 40 41**Step 2: Build** 42 43```bash 44go build ./... 45``` 46 47Expected: PASS. 48 49**Step 3: Commit** 50 51```bash 52git add internal/model/models.go 53git 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 67Remove the `collectionComment` const (line 317) and the `CreateComment` and `ListComments` functions (lines 319-357). 68 69Check whether `strings` import is still needed elsewhere before removing it: 70 71```bash 72grep -n 'strings\.' internal/atproto/xrpc/client.go 73``` 74 75Remove unused import if needed. 76 77**Step 2: Delete `collectionComment` const from `internal/handler/handler.go`** 78 79Line 27: `const collectionComment = "com.diffdown.comment"` — delete it. 80 81**Step 3: Remove the old `Comment` struct from `internal/model/models.go`** 82 83Delete the `Comment` struct block. 84 85**Step 4: Build — expect errors** 86 87```bash 88go build ./... 89``` 90 91Expected: 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 102In 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 107func 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 116Replace the entire function with: 117 118```go 119func (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 211Replace the entire function with: 212 213```go 214func (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 274go build ./... 275``` 276 277Expected: PASS. 278 279**Step 6: Run tests** 280 281```bash 282go test ./... 283``` 284 285Expected: all pass. 286 287**Step 7: Commit** 288 289```bash 290git add internal/handler/handler.go internal/model/models.go internal/atproto/xrpc/client.go 291git 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 303Find line ~85: 304``` 305{{if .IsCollaborator}} 306``` 307above the `comment-sidebar` div. Change to: 308``` 309{{if or .IsCollaborator .IsOwner}} 310``` 311 312**Step 2: Build** 313 314```bash 315go build ./... 316``` 317 318**Step 3: Commit** 319 320```bash 321git add templates/document_edit.html 322git 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 335The template already has `const ownerDID = '{{.Content.OwnerDID}}'`. 336 337In `submitComment` (around line 791), the fetch body currently sends: 338```js 339{ paragraphId: activeCommentParagraphId, text } 340``` 341 342Change it to build an object, add `ownerDID` if non-empty, then send: 343```js 344const body = { paragraphId: activeCommentParagraphId, text }; 345if (ownerDID) body.ownerDID = ownerDID; 346// then JSON.stringify(body) 347``` 348 349**Step 2: Update `loadComments` to pass `ownerDID` query param** 350 351The fetch URL is currently `/api/docs/${rkey}/comments`. Change to append `?ownerDID=...` when `ownerDID` is set: 352```js 353const 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 359After the `loadComments` function, add a new function `setupParagraphCommentTrigger` that: 3601. Finds `#editor-rich` (the Milkdown container) 3612. Listens for `click` events 3623. Calls `e.target.closest('.ProseMirror')` to confirm click is inside the editor 3634. Calls `e.target.closest('p, h1, h2, h3, h4, h5, h6, li')` to find the paragraph 3645. Computes index: `Array.from(paraEl.parentElement.children).indexOf(paraEl)` — yields 0-based index 3656. Sets `activeCommentParagraphId = 'p-' + idx` 3667. Positions and shows `commentBtn` to the right of the paragraph using `getBoundingClientRect()` + `window.scrollY` 367 368Call `setupParagraphCommentTrigger()` immediately after defining it. 369 370**Step 4: Add/verify `.comment-btn` CSS in `static/css/editor.css`** 371 372Check 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 384go build ./... 385``` 386 387**Step 6: Commit** 388 389```bash 390git add templates/document_edit.html static/css/editor.css 391git 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 403In the `renderCommentThreads` function, the thread label is currently: 404``` 405¶ ${paragraphId} 406``` 407 408Change to compute a human-readable label before building the thread HTML: 409```js 410const label = pid === 'general' 411 ? 'General' 412 : 'Paragraph ' + (parseInt(pid.replace('p-', ''), 10) + 1); 413``` 414 415Then use `label` in the label div text content (via `escHtml(label)`). 416 417**Step 2: Update author display to prefer `authorHandle`** 418 419The 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 424go build ./... && go test ./... 425``` 426 427Expected: PASS — 3 test packages, all green. 428 429**Step 4: Commit** 430 431```bash 432git add templates/document_edit.html 433git 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 443go run ./cmd/server 444``` 445 4461. **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 4482. **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 4503. **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.