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

Embed comments in document record; rewrite CommentCreate/CommentList

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

+80 -70
-41
internal/atproto/xrpc/client.go
··· 314 314 return c.PutRecord(collectionDocument, rkey, doc) 315 315 } 316 316 317 - const collectionComment = "com.diffdown.comment" 318 - 319 - // CreateComment creates a new comment record. 320 - func (c *Client) CreateComment(comment *model.Comment) (string, error) { 321 - record := map[string]interface{}{ 322 - "$type": "com.diffdown.comment", 323 - "documentURI": comment.DocumentURI, 324 - "paragraphId": comment.ParagraphID, 325 - "text": comment.Text, 326 - "author": comment.Author, 327 - } 328 - uri, _, err := c.CreateRecord(collectionComment, record) 329 - if err != nil { 330 - return "", err 331 - } 332 - return uri, nil 333 - } 334 - 335 - // ListComments fetches all comments for a document. 336 - func (c *Client) ListComments(documentRKey string) ([]model.Comment, error) { 337 - records, _, err := c.ListRecords(c.session.DID, collectionComment, 100, "") 338 - if err != nil { 339 - return nil, err 340 - } 341 - 342 - var comments []model.Comment 343 - for _, r := range records { 344 - var comment model.Comment 345 - if err := json.Unmarshal(r.Value, &comment); err != nil { 346 - continue 347 - } 348 - // Filter by documentRKey: at://{did}/com.diffdown.document/{rkey} 349 - expectedSuffix := "/com.diffdown.document/" + documentRKey 350 - if documentRKey != "" && !strings.HasSuffix(comment.DocumentURI, expectedSuffix) { 351 - continue 352 - } 353 - comment.URI = r.URI 354 - comments = append(comments, comment) 355 - } 356 - return comments, nil 357 - }
+80 -19
internal/handler/handler.go
··· 1 1 package handler 2 2 3 3 import ( 4 + "crypto/rand" 5 + "encoding/hex" 4 6 "encoding/json" 5 7 "fmt" 6 8 "html/template" ··· 24 26 ) 25 27 26 28 const collectionDocument = "com.diffdown.document" 27 - const collectionComment = "com.diffdown.comment" 29 + 30 + func randomID() string { 31 + b := make([]byte, 4) 32 + rand.Read(b) 33 + return hex.EncodeToString(b) 34 + } 28 35 29 36 type Handler struct { 30 37 DB *db.DB ··· 725 732 } 726 733 727 734 var req struct { 735 + OwnerDID string `json:"ownerDID"` 728 736 ParagraphID string `json:"paragraphId"` 729 737 Text string `json:"text"` 730 738 } ··· 732 740 http.Error(w, "Invalid request", http.StatusBadRequest) 733 741 return 734 742 } 735 - 736 743 if req.Text == "" { 737 744 http.Error(w, "Comment text required", http.StatusBadRequest) 738 745 return ··· 744 751 return 745 752 } 746 753 747 - client, err := h.xrpcClient(user.ID) 754 + ownerUserID := user.ID 755 + ownerDID := session.DID 756 + if req.OwnerDID != "" && req.OwnerDID != session.DID { 757 + ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 758 + if err != nil { 759 + http.Error(w, "Owner not found", http.StatusBadRequest) 760 + return 761 + } 762 + ownerUserID = ownerUser.ID 763 + ownerDID = req.OwnerDID 764 + } 765 + 766 + ownerClient, err := h.xrpcClient(ownerUserID) 767 + if err != nil { 768 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 769 + return 770 + } 771 + 772 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 748 773 if err != nil { 749 - http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 774 + log.Printf("CommentCreate: GetRecord: %v", err) 775 + http.Error(w, "Document not found", http.StatusNotFound) 776 + return 777 + } 778 + var doc model.Document 779 + if err := json.Unmarshal(value, &doc); err != nil { 780 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 750 781 return 751 782 } 752 783 753 - comment := &model.Comment{ 754 - DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey), 755 - ParagraphID: req.ParagraphID, 756 - Text: req.Text, 757 - Author: session.DID, 784 + authorHandle, _ := atproto.ResolveHandleFromDID(session.DID) 785 + 786 + paragraphID := req.ParagraphID 787 + if paragraphID == "" { 788 + paragraphID = "general" 758 789 } 759 790 760 - uri, err := client.CreateComment(comment) 761 - if err != nil { 762 - log.Printf("CommentCreate: %v", err) 763 - http.Error(w, "Failed to create comment", http.StatusInternalServerError) 791 + comment := model.EmbeddedComment{ 792 + ID: randomID(), 793 + ParagraphID: paragraphID, 794 + Text: req.Text, 795 + Author: session.DID, 796 + AuthorHandle: authorHandle, 797 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 798 + } 799 + doc.Comments = append(doc.Comments, comment) 800 + 801 + if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil { 802 + log.Printf("CommentCreate: PutDocument: %v", err) 803 + http.Error(w, "Failed to save comment", http.StatusInternalServerError) 764 804 return 765 805 } 766 806 767 - h.jsonResponse(w, map[string]string{"uri": uri}, http.StatusCreated) 807 + h.jsonResponse(w, comment, http.StatusCreated) 768 808 } 769 809 770 810 func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { ··· 786 826 return 787 827 } 788 828 789 - client, err := h.xrpcClient(user.ID) 829 + ownerUserID := user.ID 830 + ownerDID := session.DID 831 + if qOwner := r.URL.Query().Get("ownerDID"); qOwner != "" && qOwner != session.DID { 832 + ownerUser, err := h.DB.GetUserByDID(qOwner) 833 + if err != nil { 834 + http.Error(w, "Owner not found", http.StatusBadRequest) 835 + return 836 + } 837 + ownerUserID = ownerUser.ID 838 + ownerDID = qOwner 839 + } 840 + 841 + ownerClient, err := h.xrpcClient(ownerUserID) 790 842 if err != nil { 791 - http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 843 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 792 844 return 793 845 } 794 846 795 - comments, err := client.ListComments(rKey) 847 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 796 848 if err != nil { 797 - log.Printf("CommentList: %v", err) 798 - http.Error(w, "Failed to list comments", http.StatusInternalServerError) 849 + log.Printf("CommentList: GetRecord: %v", err) 850 + http.Error(w, "Document not found", http.StatusNotFound) 851 + return 852 + } 853 + var doc model.Document 854 + if err := json.Unmarshal(value, &doc); err != nil { 855 + http.Error(w, "Failed to parse document", http.StatusInternalServerError) 799 856 return 800 857 } 801 858 859 + comments := doc.Comments 860 + if comments == nil { 861 + comments = []model.EmbeddedComment{} 862 + } 802 863 h.jsonResponse(w, comments, http.StatusOK) 803 864 } 804 865
-10
internal/model/models.go
··· 68 68 ExpiresAt time.Time `json:"expires_at"` 69 69 } 70 70 71 - type Comment struct { 72 - URI string `json:"uri"` 73 - DocumentURI string `json:"documentURI"` 74 - ParagraphID string `json:"paragraphId"` 75 - Text string `json:"text"` 76 - Author string `json:"author"` 77 - AuthorName string `json:"authorName"` 78 - CreatedAt time.Time `json:"createdAt"` 79 - } 80 - 81 71 // RKeyFromURI extracts the rkey (last path segment) from an at:// URI. 82 72 func RKeyFromURI(uri string) string { 83 73 parts := strings.Split(uri, "/")