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

Embedded Comments Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

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.

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.

Tech Stack: Go 1.22, ATProto XRPC, html/template, ProseMirror (Milkdown) click events


Task 1: Update the Document model#

Files:

  • Modify: internal/model/models.go

Step 1: Add EmbeddedComment struct and Comments field to Document

Add after the MarkdownText struct:

type EmbeddedComment struct {
    ID           string `json:"id"`           // random 8-char hex for dedup
    ParagraphID  string `json:"paragraphId"`  // "p-0", "p-1", ... or "general"
    Text         string `json:"text"`
    Author       string `json:"author"`       // DID
    AuthorHandle string `json:"authorHandle"` // resolved handle, may be empty
    CreatedAt    string `json:"createdAt"`    // RFC3339
}

Add Comments []EmbeddedComment to the Document struct, after Collaborators:

Comments []EmbeddedComment `json:"comments,omitempty"`

Keep the existing Comment struct for now — it will be removed in Task 2.

Step 2: Build

go build ./...

Expected: PASS.

Step 3: Commit

git add internal/model/models.go
git commit -m "Add EmbeddedComment type and Comments field to Document"

Task 2: Remove old XRPC comment methods and stale types#

Files:

  • Modify: internal/atproto/xrpc/client.go
  • Modify: internal/model/models.go
  • Modify: internal/handler/handler.go

Step 1: Delete from internal/atproto/xrpc/client.go

Remove the collectionComment const (line 317) and the CreateComment and ListComments functions (lines 319-357).

Check whether strings import is still needed elsewhere before removing it:

grep -n 'strings\.' internal/atproto/xrpc/client.go

Remove unused import if needed.

Step 2: Delete collectionComment const from internal/handler/handler.go

Line 27: const collectionComment = "com.diffdown.comment" — delete it.

Step 3: Remove the old Comment struct from internal/model/models.go

Delete the Comment struct block.

Step 4: Build — expect errors

go build ./...

Expected: compile errors about CommentCreate and ListComments called in handler. These are fixed in Task 3 — do not commit yet.


Task 3: Rewrite CommentCreate and CommentList handlers#

Files:

  • Modify: internal/handler/handler.go

Step 1: Add crypto/rand and encoding/hex imports

In the import block, add both if not already present.

Step 2: Add randomID helper near the top of handler.go (after imports)

func randomID() string {
    b := make([]byte, 4)
    rand.Read(b)
    return hex.EncodeToString(b)
}

Step 3: Rewrite CommentCreate

Replace the entire function with:

func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) {
    user, _ := h.currentUser(r)
    if user == nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    rKey := r.PathValue("rkey")
    if rKey == "" {
        http.Error(w, "Invalid document", http.StatusBadRequest)
        return
    }

    var req struct {
        OwnerDID    string `json:"ownerDID"`
        ParagraphID string `json:"paragraphId"`
        Text        string `json:"text"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    if req.Text == "" {
        http.Error(w, "Comment text required", http.StatusBadRequest)
        return
    }

    session, err := h.DB.GetATProtoSession(user.ID)
    if err != nil || session == nil {
        http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized)
        return
    }

    ownerUserID := user.ID
    ownerDID := session.DID
    if req.OwnerDID != "" && req.OwnerDID != session.DID {
        ownerUser, err := h.DB.GetUserByDID(req.OwnerDID)
        if err != nil {
            http.Error(w, "Owner not found", http.StatusBadRequest)
            return
        }
        ownerUserID = ownerUser.ID
        ownerDID = req.OwnerDID
    }

    ownerClient, err := h.xrpcClient(ownerUserID)
    if err != nil {
        http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError)
        return
    }

    value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey)
    if err != nil {
        log.Printf("CommentCreate: GetRecord: %v", err)
        http.Error(w, "Document not found", http.StatusNotFound)
        return
    }
    var doc model.Document
    if err := json.Unmarshal(value, &doc); err != nil {
        http.Error(w, "Failed to parse document", http.StatusInternalServerError)
        return
    }

    authorHandle, _ := atproto.ResolveHandleFromDID(session.DID)

    paragraphID := req.ParagraphID
    if paragraphID == "" {
        paragraphID = "general"
    }

    comment := model.EmbeddedComment{
        ID:           randomID(),
        ParagraphID:  paragraphID,
        Text:         req.Text,
        Author:       session.DID,
        AuthorHandle: authorHandle,
        CreatedAt:    time.Now().UTC().Format(time.RFC3339),
    }
    doc.Comments = append(doc.Comments, comment)

    if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil {
        log.Printf("CommentCreate: PutDocument: %v", err)
        http.Error(w, "Failed to save comment", http.StatusInternalServerError)
        return
    }

    h.jsonResponse(w, comment, http.StatusCreated)
}

Step 4: Rewrite CommentList

Replace the entire function with:

func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) {
    rKey := r.PathValue("rkey")
    if rKey == "" {
        http.Error(w, "Invalid document", http.StatusBadRequest)
        return
    }

    user, _ := h.currentUser(r)
    if user == nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    session, err := h.DB.GetATProtoSession(user.ID)
    if err != nil || session == nil {
        http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized)
        return
    }

    ownerUserID := user.ID
    ownerDID := session.DID
    if qOwner := r.URL.Query().Get("ownerDID"); qOwner != "" && qOwner != session.DID {
        ownerUser, err := h.DB.GetUserByDID(qOwner)
        if err != nil {
            http.Error(w, "Owner not found", http.StatusBadRequest)
            return
        }
        ownerUserID = ownerUser.ID
        ownerDID = qOwner
    }

    ownerClient, err := h.xrpcClient(ownerUserID)
    if err != nil {
        http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError)
        return
    }

    value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey)
    if err != nil {
        log.Printf("CommentList: GetRecord: %v", err)
        http.Error(w, "Document not found", http.StatusNotFound)
        return
    }
    var doc model.Document
    if err := json.Unmarshal(value, &doc); err != nil {
        http.Error(w, "Failed to parse document", http.StatusInternalServerError)
        return
    }

    comments := doc.Comments
    if comments == nil {
        comments = []model.EmbeddedComment{}
    }
    h.jsonResponse(w, comments, http.StatusOK)
}

Step 5: Build

go build ./...

Expected: PASS.

Step 6: Run tests

go test ./...

Expected: all pass.

Step 7: Commit

git add internal/handler/handler.go internal/model/models.go internal/atproto/xrpc/client.go
git commit -m "Embed comments in document record; rewrite CommentCreate/CommentList"

Task 4: Fix comment sidebar visibility (show for owners too)#

Files:

  • Modify: templates/document_edit.html

Step 1: Change the sidebar guard from IsCollaborator to or .IsCollaborator .IsOwner

Find line ~85:

{{if .IsCollaborator}}

above the comment-sidebar div. Change to:

{{if or .IsCollaborator .IsOwner}}

Step 2: Build

go build ./...

Step 3: Commit

git add templates/document_edit.html
git commit -m "Show comment sidebar for document owners, not just collaborators"

Task 5: Wire up paragraph click + update API calls#

Files:

  • Modify: templates/document_edit.html
  • Modify: static/css/editor.css

Step 1: Update submitComment to include ownerDID in POST body

The template already has const ownerDID = '{{.Content.OwnerDID}}'.

In submitComment (around line 791), the fetch body currently sends:

{ paragraphId: activeCommentParagraphId, text }

Change it to build an object, add ownerDID if non-empty, then send:

const body = { paragraphId: activeCommentParagraphId, text };
if (ownerDID) body.ownerDID = ownerDID;
// then JSON.stringify(body)

Step 2: Update loadComments to pass ownerDID query param

The fetch URL is currently /api/docs/${rkey}/comments. Change to append ?ownerDID=... when ownerDID is set:

const qs = ownerDID ? '?ownerDID=' + encodeURIComponent(ownerDID) : '';
// then fetch(`/api/docs/${rkey}/comments${qs}`)

Step 3: Add paragraph click detection after loadComments function

After the loadComments function, add a new function setupParagraphCommentTrigger that:

  1. Finds #editor-rich (the Milkdown container)
  2. Listens for click events
  3. Calls e.target.closest('.ProseMirror') to confirm click is inside the editor
  4. Calls e.target.closest('p, h1, h2, h3, h4, h5, h6, li') to find the paragraph
  5. Computes index: Array.from(paraEl.parentElement.children).indexOf(paraEl) — yields 0-based index
  6. Sets activeCommentParagraphId = 'p-' + idx
  7. Positions and shows commentBtn to the right of the paragraph using getBoundingClientRect() + window.scrollY

Call setupParagraphCommentTrigger() immediately after defining it.

Step 4: Add/verify .comment-btn CSS in static/css/editor.css

Check whether .comment-btn has position: fixed or position: absolute and z-index. If not present, add:

.comment-btn {
    position: fixed;
    z-index: 100;
}

Step 5: Build

go build ./...

Step 6: Commit

git add templates/document_edit.html static/css/editor.css
git commit -m "Wire paragraph click to comment button; pass ownerDID in comment API calls"

Task 6: Improve comment sidebar rendering#

Files:

  • Modify: templates/document_edit.html

Step 1: Update renderCommentThreads paragraph label

In the renderCommentThreads function, the thread label is currently:

¶ ${paragraphId}

Change to compute a human-readable label before building the thread HTML:

const label = pid === 'general'
    ? 'General'
    : 'Paragraph ' + (parseInt(pid.replace('p-', ''), 10) + 1);

Then use label in the label div text content (via escHtml(label)).

Step 2: Update author display to prefer authorHandle

The comment item shows c.authorName || c.author. Since the new struct uses authorHandle, update to c.authorHandle || c.author.

Step 3: Build and run all tests

go build ./... && go test ./...

Expected: PASS — 3 test packages, all green.

Step 4: Commit

git add templates/document_edit.html
git commit -m "Improve comment rendering: human-readable paragraph labels, authorHandle"

Task 7: Manual smoke test#

Start the server from the worktree:

go run ./cmd/server
  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".

  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.

  3. Owner sees collaborator's comment: Sign back in as owner on the same document — comment is visible in the sidebar.


Notes#

  • No DB migration needed — comments live entirely in the ATProto record.
  • 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.
  • 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.
  • Old com.diffdown.comment records: Orphaned on user PDSes. Cleanup is out of scope.