Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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.