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

feat: add comment handlers

+143 -1
+2
cmd/server/main.go
··· 100 100 mux.HandleFunc("POST /api/docs/{rkey}/save", h.APIDocumentSave) 101 101 mux.HandleFunc("PUT /api/docs/{rkey}/autosave", h.APIDocumentAutoSave) 102 102 mux.HandleFunc("DELETE /api/docs/{rkey}", h.APIDocumentDelete) 103 + mux.HandleFunc("POST /api/docs/{rkey}/comments", h.CommentCreate) 104 + mux.HandleFunc("GET /api/docs/{rkey}/comments", h.CommentList) 103 105 104 106 // Middleware stack 105 107 stack := middleware.Logger(
+40
internal/atproto/xrpc/client.go
··· 16 16 "github.com/limeleaf/diffdown/internal/model" 17 17 ) 18 18 19 + const collectionComment = "com.diffdown.comment" 20 + 19 21 type Client struct { 20 22 db *db.DB 21 23 userID string ··· 291 293 } 292 294 return nil 293 295 } 296 + 297 + // CreateComment creates a new comment record. 298 + func (c *Client) CreateComment(comment *model.Comment) (string, error) { 299 + now := time.Now().UTC().Format(time.RFC3339) 300 + record := map[string]interface{}{ 301 + "$type": "com.diffdown.comment", 302 + "documentUri": comment.DocumentURI, 303 + "paragraphId": comment.ParagraphID, 304 + "text": comment.Text, 305 + "authorDid": comment.AuthorDID, 306 + "createdAt": now, 307 + } 308 + 309 + uri, _, err := c.CreateRecord(collectionComment, record) 310 + if err != nil { 311 + return "", err 312 + } 313 + return uri, nil 314 + } 315 + 316 + // ListComments lists all comments for a document. 317 + func (c *Client) ListComments(rkey string) ([]model.Comment, error) { 318 + records, _, err := c.ListRecords(c.session.DID, collectionComment, 100, "") 319 + if err != nil { 320 + return nil, err 321 + } 322 + 323 + var comments []model.Comment 324 + for _, rec := range records { 325 + var comment model.Comment 326 + if err := json.Unmarshal(rec.Value, &comment); err != nil { 327 + continue 328 + } 329 + comment.URI = rec.URI 330 + comments = append(comments, comment) 331 + } 332 + return comments, nil 333 + }
+92
internal/handler/handler.go
··· 18 18 ) 19 19 20 20 const collectionDocument = "com.diffdown.document" 21 + const collectionComment = "com.diffdown.comment" 21 22 22 23 type Handler struct { 23 24 DB *db.DB ··· 419 420 } 420 421 421 422 h.jsonResponse(w, map[string]string{"status": "ok"}) 423 + } 424 + 425 + // --- API: Comments --- 426 + 427 + func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { 428 + user := h.currentUser(r) 429 + if user == nil { 430 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 431 + return 432 + } 433 + 434 + rKey := r.PathValue("rkey") 435 + if rKey == "" { 436 + http.Error(w, "Invalid document", http.StatusBadRequest) 437 + return 438 + } 439 + 440 + var req struct { 441 + ParagraphID string `json:"paragraphId"` 442 + Text string `json:"text"` 443 + } 444 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 445 + http.Error(w, "Invalid request", http.StatusBadRequest) 446 + return 447 + } 448 + 449 + if req.Text == "" { 450 + http.Error(w, "Comment text required", http.StatusBadRequest) 451 + return 452 + } 453 + 454 + client, err := h.xrpcClient(user.ID) 455 + if err != nil { 456 + log.Printf("CommentCreate: xrpc client: %v", err) 457 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 458 + return 459 + } 460 + 461 + session, err := h.DB.GetATProtoSession(user.ID) 462 + if err != nil || session == nil { 463 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 464 + return 465 + } 466 + 467 + comment := &model.Comment{ 468 + DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey), 469 + ParagraphID: req.ParagraphID, 470 + Text: req.Text, 471 + AuthorDID: session.DID, 472 + } 473 + 474 + uri, err := client.CreateComment(comment) 475 + if err != nil { 476 + log.Printf("CommentCreate: %v", err) 477 + http.Error(w, "Failed to create comment", http.StatusInternalServerError) 478 + return 479 + } 480 + 481 + w.WriteHeader(http.StatusCreated) 482 + h.jsonResponse(w, map[string]string{"uri": uri}) 483 + } 484 + 485 + func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { 486 + rKey := r.PathValue("rkey") 487 + if rKey == "" { 488 + http.Error(w, "Invalid document", http.StatusBadRequest) 489 + return 490 + } 491 + 492 + user := h.currentUser(r) 493 + if user == nil { 494 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 495 + return 496 + } 497 + 498 + client, err := h.xrpcClient(user.ID) 499 + if err != nil { 500 + log.Printf("CommentList: xrpc client: %v", err) 501 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 502 + return 503 + } 504 + 505 + comments, err := client.ListComments(rKey) 506 + if err != nil { 507 + log.Printf("CommentList: %v", err) 508 + http.Error(w, "Failed to list comments", http.StatusInternalServerError) 509 + return 510 + } 511 + 512 + w.WriteHeader(http.StatusOK) 513 + h.jsonResponse(w, comments) 422 514 } 423 515 424 516 // --- API: Render markdown ---
+9 -1
internal/model/models.go
··· 56 56 RawMarkdown string `json:"rawMarkdown"` 57 57 } 58 58 59 + type Comment struct { 60 + URI string `json:"uri,omitempty"` 61 + DocumentURI string `json:"documentUri"` 62 + ParagraphID string `json:"paragraphId"` 63 + Text string `json:"text"` 64 + AuthorDID string `json:"authorDid"` 65 + CreatedAt string `json:"createdAt,omitempty"` 66 + } 67 + 59 68 // RKeyFromURI extracts the rkey (last path segment) from an at:// URI. 60 69 func RKeyFromURI(uri string) string { 61 70 parts := strings.Split(uri, "/") ··· 64 73 } 65 74 return "" 66 75 } 67 -