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

Add collaboration implementation plan

+1116
+1116
docs/superpowers/plans/2026-03-11-collaboration-implementation.md
··· 1 + # Collaboration Feature Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Implement real-time collaboration for Markdown documents with up to 5 users, paragraph-level comments, and invite-based access control. 6 + 7 + **Architecture:** Server-side collaboration hub with WebSocket connections. Server maintains canonical document state, broadcasts edits, debounces ATProto persistence. Comments stored in separate ATProto collection. 8 + 9 + **Tech Stack:** Go (stdlib, gorilla/websocket), ATProto XRPC, SQLite 10 + 11 + --- 12 + 13 + ## Chunk 1: Database Migration and Models 14 + 15 + ### Task 1.1: Create invites migration 16 + 17 + **Files:** 18 + - Create: `migrations/005_create_invites.sql` 19 + 20 + - [ ] **Step 1: Write the migration** 21 + 22 + ```sql 23 + CREATE TABLE IF NOT EXISTS invites ( 24 + id TEXT PRIMARY KEY, 25 + document_rkey TEXT NOT NULL, 26 + token TEXT NOT NULL UNIQUE, 27 + created_by_did TEXT NOT NULL, 28 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 29 + expires_at DATETIME NOT NULL 30 + ); 31 + 32 + CREATE INDEX idx_invites_document ON invites(document_rkey); 33 + CREATE INDEX idx_invites_token ON invites(token); 34 + ``` 35 + 36 + - [ ] **Step 2: Commit** 37 + 38 + ```bash 39 + git add migrations/005_create_invites.sql 40 + git commit -m "feat: add invites table migration" 41 + ``` 42 + 43 + ### Task 1.2: Update models for collaboration 44 + 45 + **Files:** 46 + - Modify: `internal/model/models.go` 47 + 48 + - [ ] **Step 1: Write the failing test (skip - no existing tests)** 49 + 50 + - [ ] **Step 2: Add Invite and Comment types** 51 + 52 + ```go 53 + type Invite struct { 54 + ID string 55 + DocumentRKey string 56 + Token string 57 + CreatedBy string 58 + CreatedAt time.Time 59 + ExpiresAt time.Time 60 + } 61 + 62 + type Comment struct { 63 + URI string 64 + DocumentURI string 65 + ParagraphID string 66 + Text string 67 + AuthorDID string 68 + AuthorName string 69 + CreatedAt string 70 + } 71 + ``` 72 + 73 + - [ ] **Step 3: Add Collaborators field to Document** 74 + 75 + In the `Document` struct, add: 76 + ```go 77 + Collaborators []string `json:"collaborators,omitempty"` 78 + ``` 79 + 80 + - [ ] **Step 4: Commit** 81 + 82 + ```bash 83 + git add internal/model/models.go 84 + git commit -m "feat: add Invite, Comment models and Document.collaborators" 85 + ``` 86 + 87 + --- 88 + 89 + ## Chunk 2: Collaboration Package (Core Logic) 90 + 91 + ### Task 2.1: Create collaboration hub 92 + 93 + **Files:** 94 + - Create: `internal/collaboration/hub.go` 95 + 96 + - [ ] **Step 1: Write the hub with WebSocket room management** 97 + 98 + ```go 99 + package collaboration 100 + 101 + import ( 102 + "log" 103 + "sync" 104 + ) 105 + 106 + type Hub struct { 107 + rooms map[string]*Room 108 + mu sync.RWMutex 109 + } 110 + 111 + type Room struct { 112 + documentRKey string 113 + clients map[*Client]bool 114 + broadcast chan []byte 115 + register chan *Client 116 + unregister chan *Client 117 + mu sync.RWMutex 118 + } 119 + 120 + func NewHub() *Hub { 121 + return &Hub{ 122 + rooms: make(map[string]*Room), 123 + } 124 + } 125 + 126 + func (h *Hub) GetOrCreateRoom(rkey string) *Room { 127 + h.mu.Lock() 128 + defer h.mu.Unlock() 129 + if room, exists := h.rooms[rkey]; exists { 130 + return room 131 + } 132 + room := &Room{ 133 + documentRKey: rkey, 134 + clients: make(map[*Client]bool), 135 + broadcast: make(chan []byte, 256), 136 + register: make(chan *Client), 137 + unregister: make(chan *Client), 138 + } 139 + h.rooms[rkey] = room 140 + go room.run() 141 + return room 142 + } 143 + 144 + func (r *Room) run() { 145 + for { 146 + select { 147 + case client := <-r.register: 148 + r.mu.Lock() 149 + r.clients[client] = true 150 + r.mu.Unlock() 151 + r.broadcastPresence() 152 + case client := <-r.unregister: 153 + r.mu.Lock() 154 + if _, ok := r.clients[client]; ok { 155 + delete(r.clients, client) 156 + close(client.send) 157 + } 158 + r.mu.Unlock() 159 + r.broadcastPresence() 160 + case message := <-r.broadcast: 161 + r.mu.RLock() 162 + for client := range r.clients { 163 + select { 164 + case client.send <- message: 165 + default: 166 + close(client.send) 167 + delete(r.clients, client) 168 + } 169 + } 170 + r.mu.RUnlock() 171 + } 172 + } 173 + } 174 + 175 + func (r *Room) Broadcast(message []byte) { 176 + r.broadcast <- message 177 + } 178 + 179 + func (r *Room) broadcastPresence() { 180 + // Implementation in Task 2.2 181 + } 182 + ``` 183 + 184 + - [ ] **Step 2: Commit** 185 + 186 + ```bash 187 + git add internal/collaboration/hub.go 188 + git commit -m "feat: add collaboration hub with room management" 189 + ``` 190 + 191 + ### Task 2.2: Create client representation 192 + 193 + **Files:** 194 + - Create: `internal/collaboration/client.go` 195 + 196 + - [ ] **Step 1: Write the client struct** 197 + 198 + ```go 199 + package collaboration 200 + 201 + import ( 202 + "github.com/gorilla/websocket" 203 + ) 204 + 205 + type Client struct { 206 + hub *Hub 207 + conn *websocket.Conn 208 + send chan []byte 209 + DID string 210 + Name string 211 + Color string 212 + roomKey string 213 + } 214 + 215 + type ClientMessage struct { 216 + Type string `json:"type"` 217 + RKey string `json:"rkey,omitempty"` 218 + DID string `json:"did,omitempty"` 219 + Delta json.RawMessage `json:"delta,omitempty"` 220 + Cursor *CursorPos `json:"cursor,omitempty"` 221 + Comment *CommentMsg `json:"comment,omitempty"` 222 + } 223 + 224 + type CursorPos struct { 225 + Position int `json:"position"` 226 + SelectionEnd int `json:"selectionEnd"` 227 + } 228 + 229 + type CommentMsg struct { 230 + ParagraphID string `json:"paragraphId"` 231 + Text string `json:"text"` 232 + } 233 + 234 + type PresenceUser struct { 235 + DID string `json:"did"` 236 + Name string `json:"name"` 237 + Color string `json:"color"` 238 + } 239 + 240 + type PresenceMessage struct { 241 + Type string `json:"type"` 242 + Users []PresenceUser `json:"users"` 243 + } 244 + 245 + func NewClient(hub *Hub, conn *websocket.Conn, did, name, color, roomKey string) *Client { 246 + return &Client{ 247 + hub: hub, 248 + conn: conn, 249 + send: make(chan []byte, 256), 250 + DID: did, 251 + Name: name, 252 + Color: color, 253 + roomKey: roomKey, 254 + } 255 + } 256 + 257 + func (c *Client) ReadPump() { 258 + defer func() { 259 + c.hub.unregister <- c 260 + c.conn.Close() 261 + }() 262 + for { 263 + _, message, err := c.conn.ReadMessage() 264 + if err != nil { 265 + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 266 + log.Printf("WebSocket error: %v", err) 267 + } 268 + break 269 + } 270 + // Handle message - dispatch to appropriate handler 271 + } 272 + } 273 + 274 + func (c *Client) WritePump() { 275 + defer c.conn.Close() 276 + for { 277 + message, ok := <-c.send 278 + if !ok { 279 + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 280 + return 281 + } 282 + if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { 283 + return 284 + } 285 + } 286 + } 287 + ``` 288 + 289 + - [ ] **Step 2: Implement presence broadcasting in hub** 290 + 291 + Add to `hub.go`: 292 + ```go 293 + func (r *Room) GetPresence() []PresenceUser { 294 + r.mu.RLock() 295 + defer r.mu.RUnlock() 296 + users := make([]PresenceUser, 0, len(r.clients)) 297 + for client := range r.clients { 298 + users = append(users, PresenceUser{ 299 + DID: client.DID, 300 + Name: client.Name, 301 + Color: client.Color, 302 + }) 303 + } 304 + return users 305 + } 306 + 307 + func (r *Room) broadcastPresence() { 308 + presence := PresenceMessage{ 309 + Type: "presence", 310 + Users: r.GetPresence(), 311 + } 312 + data, _ := json.Marshal(presence) 313 + r.Broadcast(data) 314 + } 315 + ``` 316 + 317 + - [ ] **Step 3: Commit** 318 + 319 + ```bash 320 + git add internal/collaboration/client.go internal/collaboration/hub.go 321 + git commit -m "feat: add client representation and presence broadcasting" 322 + ``` 323 + 324 + ### Task 2.3: Create invite system 325 + 326 + **Files:** 327 + - Create: `internal/collaboration/invite.go` 328 + 329 + - [ ] **Step 1: Write the invite logic** 330 + 331 + ```go 332 + package collaboration 333 + 334 + import ( 335 + "crypto/rand" 336 + "crypto/sha256" 337 + "encoding/hex" 338 + "time" 339 + 340 + "github.com/limeleaf/diffdown/internal/db" 341 + ) 342 + 343 + func GenerateInviteToken() (string, error) { 344 + bytes := make([]byte, 32) 345 + if _, err := rand.Read(bytes); err != nil { 346 + return "", err 347 + } 348 + hash := sha256.Sum256(bytes) 349 + return hex.EncodeToString(hash[:]), nil 350 + } 351 + 352 + func CreateInvite(db *db.DB, documentRKey, createdByDID string) (*model.Invite, error) { 353 + token, err := GenerateInviteToken() 354 + if err != nil { 355 + return nil, err 356 + } 357 + 358 + invite := &model.Invite{ 359 + ID: db.NewID(), 360 + DocumentRKey: documentRKey, 361 + Token: token, 362 + CreatedBy: createdByDID, 363 + CreatedAt: time.Now(), 364 + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), 365 + } 366 + 367 + err = db.CreateInvite(invite) 368 + return invite, err 369 + } 370 + 371 + func ValidateInvite(db *db.DB, token, documentRKey string) (*model.Invite, error) { 372 + invite, err := db.GetInviteByToken(token) 373 + if err != nil { 374 + return nil, err 375 + } 376 + if invite.DocumentRKey != documentRKey { 377 + return nil, fmt.Errorf("invite does not match document") 378 + } 379 + if time.Now().After(invite.ExpiresAt) { 380 + return nil, fmt.Errorf("invite expired") 381 + } 382 + return invite, nil 383 + } 384 + ``` 385 + 386 + - [ ] **Step 2: Add DB methods** 387 + 388 + In `internal/db/db.go`, add: 389 + ```go 390 + func (db *DB) CreateInvite(invite *model.Invite) error { 391 + _, err := db.Exec(` 392 + INSERT INTO invites (id, document_rkey, token, created_by_did, created_at, expires_at) 393 + VALUES (?, ?, ?, ?, ?, ?)`, 394 + invite.ID, invite.DocumentRKey, invite.Token, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt) 395 + return err 396 + } 397 + 398 + func (db *DB) GetInviteByToken(token string) (*model.Invite, error) { 399 + row := db.QueryRow(`SELECT id, document_rkey, token, created_by_did, created_at, expires_at FROM invites WHERE token = ?`, token) 400 + var invite model.Invite 401 + err := row.Scan(&invite.ID, &invite.DocumentRKey, &invite.Token, &invite.CreatedBy, &invite.CreatedAt, &invite.ExpiresAt) 402 + if err != nil { 403 + return nil, err 404 + } 405 + return &invite, nil 406 + } 407 + ``` 408 + 409 + - [ ] **Step 3: Commit** 410 + 411 + ```bash 412 + git add internal/collaboration/invite.go internal/db/db.go 413 + git commit -m "feat: add invite generation and validation" 414 + ``` 415 + 416 + ### Task 2.4: Create OT helpers 417 + 418 + **Files:** 419 + - Create: `internal/collaboration/ot.go` 420 + 421 + - [ ] **Step 1: Write simplified OT logic** 422 + 423 + ```go 424 + package collaboration 425 + 426 + import "sync" 427 + 428 + type OTEngine struct { 429 + mu sync.Mutex 430 + documentText string 431 + version int 432 + } 433 + 434 + func NewOTEngine(initialText string) *OTEngine { 435 + return &OTEngine{ 436 + documentText: initialText, 437 + version: 0, 438 + } 439 + } 440 + 441 + type Operation struct { 442 + From int `json:"from"` 443 + To int `json:"to"` 444 + Insert string `json:"insert"` 445 + Author string `json:"author"` 446 + } 447 + 448 + func (ot *OTEngine) Apply(op Operation) string { 449 + ot.mu.Lock() 450 + defer ot.mu.Unlock() 451 + 452 + // Simple last-write-wins 453 + if op.To > len(ot.documentText) { 454 + op.To = len(ot.documentText) 455 + } 456 + if op.From > len(ot.documentText) { 457 + op.From = len(ot.documentText) 458 + } 459 + 460 + newText := ot.documentText[:op.From] + op.Insert + ot.documentText[op.To:] 461 + ot.documentText = newText 462 + ot.version++ 463 + 464 + return ot.documentText 465 + } 466 + 467 + func (ot *OTEngine) GetText() string { 468 + ot.mu.Lock() 469 + defer ot.mu.Unlock() 470 + return ot.documentText 471 + } 472 + 473 + func (ot *OTEngine) GetVersion() int { 474 + ot.mu.Lock() 475 + defer ot.mu.Unlock() 476 + return ot.version 477 + } 478 + ``` 479 + 480 + - [ ] **Step 2: Commit** 481 + 482 + ```bash 483 + git add internal/collaboration/ot.go 484 + git commit -m "feat: add simplified OT engine" 485 + ``` 486 + 487 + --- 488 + 489 + ## Chunk 3: HTTP Handlers 490 + 491 + ### Task 3.1: Document invite handler 492 + 493 + **Files:** 494 + - Modify: `internal/handler/handler.go` 495 + 496 + - [ ] **Step 1: Add DocumentInvite handler** 497 + 498 + ```go 499 + func (h *Handler) DocumentInvite(w http.ResponseWriter, r *http.Request) { 500 + user := h.currentUser(r) 501 + if user == nil { 502 + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 503 + return 504 + } 505 + 506 + rKey := model.RKeyFromURI(r.URL.Path) 507 + if rKey == "" { 508 + http.Error(w, "Invalid document", http.StatusBadRequest) 509 + return 510 + } 511 + 512 + // Get document to verify ownership 513 + client := h.xrpcClient(r) 514 + if client == nil { 515 + h.render(w, "document_edit.html", PageData{Error: "Not authenticated with ATProto"}) 516 + return 517 + } 518 + 519 + doc, err := client.GetDocument(rKey) 520 + if err != nil { 521 + http.Error(w, "Document not found", http.StatusNotFound) 522 + return 523 + } 524 + 525 + // Verify user is creator (DID matches) 526 + session, _ := h.db.GetATProtoSession(user.ID) 527 + if session == nil || session.DID != doc.URI { 528 + http.Error(w, "Unauthorized", http.StatusForbidden) 529 + return 530 + } 531 + 532 + // Check collaborator limit (5 max) 533 + if len(doc.Collaborators) >= 5 { 534 + http.Error(w, "Maximum collaborators reached", http.StatusBadRequest) 535 + return 536 + } 537 + 538 + // Create invite 539 + invite, err := collaboration.CreateInvite(h.db, rKey, session.DID) 540 + if err != nil { 541 + log.Printf("DocumentInvite: create invite: %v", err) 542 + http.Error(w, "Failed to create invite", http.StatusInternalServerError) 543 + return 544 + } 545 + 546 + inviteLink := fmt.Sprintf("%s/doc/%s?invite=%s", os.Getenv("BASE_URL"), rKey, invite.Token) 547 + h.render(w, "document_edit.html", PageData{ 548 + Content: map[string]interface{}{ 549 + "document": doc, 550 + "inviteLink": inviteLink, 551 + }, 552 + }) 553 + } 554 + ``` 555 + 556 + - [ ] **Step 2: Register route in main.go** 557 + 558 + ```go 559 + mux.HandleFunc("POST /api/docs/{rkey}/invite", handler.DocumentInvite) 560 + ``` 561 + 562 + - [ ] **Step 3: Commit** 563 + 564 + ```bash 565 + git add internal/handler/handler.go cmd/server/main.go 566 + git commit -m "feat: add document invite handler" 567 + ``` 568 + 569 + ### Task 3.2: Accept invite handler 570 + 571 + **Files:** 572 + - Modify: `internal/handler/handler.go` 573 + 574 + - [ ] **Step 1: Add AcceptInvite handler** 575 + 576 + ```go 577 + func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { 578 + user := h.currentUser(r) 579 + if user == nil { 580 + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 581 + return 582 + } 583 + 584 + rKey := model.RKeyFromURI(r.URL.Path) 585 + inviteToken := r.URL.Query().Get("invite") 586 + if inviteToken == "" { 587 + http.Error(w, "Invalid invite", http.StatusBadRequest) 588 + return 589 + } 590 + 591 + // Validate invite 592 + invite, err := collaboration.ValidateInvite(h.db, inviteToken, rKey) 593 + if err != nil { 594 + http.Error(w, err.Error(), http.StatusBadRequest) 595 + return 596 + } 597 + 598 + // Get ATProto session 599 + session, err := h.db.GetATProtoSession(user.ID) 600 + if err != nil || session == nil { 601 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 602 + return 603 + } 604 + 605 + // Add user to collaborators via ATProto 606 + client, err := h.newXRPCClient(session) 607 + if err != nil { 608 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 609 + return 610 + } 611 + 612 + // Get current document 613 + doc, err := client.GetDocument(rKey) 614 + if err != nil { 615 + http.Error(w, "Document not found", http.StatusNotFound) 616 + return 617 + } 618 + 619 + // Check if already collaborator 620 + for _, c := range doc.Collaborators { 621 + if c == session.DID { 622 + http.Redirect(w, r, "/doc/"+rKey, http.StatusSeeOther) 623 + return 624 + } 625 + } 626 + 627 + // Add to collaborators 628 + doc.Collaborators = append(doc.Collaborators, session.DID) 629 + err = client.PutDocument(rKey, doc) 630 + if err != nil { 631 + log.Printf("AcceptInvite: add collaborator: %v", err) 632 + http.Error(w, "Failed to add collaborator", http.StatusInternalServerError) 633 + return 634 + } 635 + 636 + // Delete invite token after use 637 + h.db.DeleteInvite(invite.Token) 638 + 639 + http.Redirect(w, r, "/doc/"+rKey, http.StatusSeeOther) 640 + } 641 + ``` 642 + 643 + - [ ] **Step 2: Register route** 644 + 645 + ```go 646 + mux.HandleFunc("GET /doc/{rkey}/accept", handler.AcceptInvite) 647 + ``` 648 + 649 + - [ ] **Step 3: Commit** 650 + 651 + ```bash 652 + git add internal/handler/handler.go cmd/server/main.go 653 + git commit -m "feat: add accept invite handler" 654 + ``` 655 + 656 + ### Task 3.3: Comment handlers 657 + 658 + **Files:** 659 + - Modify: `internal/handler/handler.go` 660 + 661 + - [ ] **Step 1: Add CommentCreate handler** 662 + 663 + ```go 664 + func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { 665 + user := h.currentUser(r) 666 + if user == nil { 667 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 668 + return 669 + } 670 + 671 + rKey := model.RKeyFromURI(r.URL.Path) 672 + if rKey == "" { 673 + http.Error(w, "Invalid document", http.StatusBadRequest) 674 + return 675 + } 676 + 677 + // Parse request body 678 + var req struct { 679 + ParagraphID string `json:"paragraphId"` 680 + Text string `json:"text"` 681 + } 682 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 683 + http.Error(w, "Invalid request", http.StatusBadRequest) 684 + return 685 + } 686 + 687 + if req.Text == "" { 688 + http.Error(w, "Comment text required", http.StatusBadRequest) 689 + return 690 + } 691 + 692 + // Get ATProto session 693 + session, err := h.db.GetATProtoSession(user.ID) 694 + if err != nil || session == nil { 695 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 696 + return 697 + } 698 + 699 + client, err := h.newXRPCClient(session) 700 + if err != nil { 701 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 702 + return 703 + } 704 + 705 + // Create comment record 706 + comment := &model.Comment{ 707 + DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey), 708 + ParagraphID: req.ParagraphID, 709 + Text: req.Text, 710 + AuthorDID: session.DID, 711 + } 712 + 713 + uri, err := client.CreateComment(comment) 714 + if err != nil { 715 + log.Printf("CommentCreate: %v", err) 716 + http.Error(w, "Failed to create comment", http.StatusInternalServerError) 717 + return 718 + } 719 + 720 + h.jsonResponse(w, map[string]string{"uri": uri}, http.StatusCreated) 721 + } 722 + ``` 723 + 724 + - [ ] **Step 2: Add CommentList handler** 725 + 726 + ```go 727 + func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { 728 + rKey := model.RKeyFromURI(r.URL.Path) 729 + if rKey == "" { 730 + http.Error(w, "Invalid document", http.StatusBadRequest) 731 + return 732 + } 733 + 734 + user := h.currentUser(r) 735 + if user == nil { 736 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 737 + return 738 + } 739 + 740 + session, err := h.db.GetATProtoSession(user.ID) 741 + if err != nil || session == nil { 742 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 743 + return 744 + } 745 + 746 + client, err := h.newXRPCClient(session) 747 + if err != nil { 748 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 749 + return 750 + } 751 + 752 + comments, err := client.ListComments(rKey) 753 + if err != nil { 754 + log.Printf("CommentList: %v", err) 755 + http.Error(w, "Failed to list comments", http.StatusInternalServerError) 756 + return 757 + } 758 + 759 + h.jsonResponse(w, comments, http.StatusOK) 760 + } 761 + ``` 762 + 763 + - [ ] **Step 3: Register routes** 764 + 765 + ```go 766 + mux.HandleFunc("POST /api/docs/{rkey}/comments", handler.CommentCreate) 767 + mux.HandleFunc("GET /api/docs/{rkey}/comments", handler.CommentList) 768 + ``` 769 + 770 + - [ ] **Step 4: Commit** 771 + 772 + ```bash 773 + git add internal/handler/handler.go cmd/server/main.go 774 + git commit -m "feat: add comment handlers" 775 + ``` 776 + 777 + --- 778 + 779 + ## Chunk 4: WebSocket Handler 780 + 781 + ### Task 4.1: WebSocket upgrade handler 782 + 783 + **Files:** 784 + - Modify: `internal/handler/handler.go`, `cmd/server/main.go` 785 + 786 + - [ ] **Step 1: Add CollaboratorWebSocket handler** 787 + 788 + ```go 789 + var upgrader = websocket.Upgrader{ 790 + CheckOrigin: func(r *http.Request) bool { return true }, 791 + } 792 + 793 + func (h *Handler) CollaboratorWebSocket(w http.ResponseWriter, r *http.Request) { 794 + rKey := model.RKeyFromURI(r.URL.Path) 795 + if rKey == "" { 796 + http.Error(w, "Invalid document", http.StatusBadRequest) 797 + return 798 + } 799 + 800 + // Get access token and DPoP proof from query params 801 + accessToken := r.URL.Query().Get("access_token") 802 + dpopProof := r.URL.Query().Get("dpop_proof") 803 + if accessToken == "" || dpopProof == "" { 804 + http.Error(w, "Missing auth tokens", http.StatusUnauthorized) 805 + return 806 + } 807 + 808 + // Validate tokens and get DID 809 + did, name, err := h.validateWSToken(accessToken, dpopProof) 810 + if err != nil { 811 + http.Error(w, "Invalid tokens", http.StatusUnauthorized) 812 + return 813 + } 814 + 815 + // Get document and verify collaborator access 816 + session, _ := h.db.GetATProtoSessionByDID(did) 817 + if session == nil { 818 + http.Error(w, "No ATProto session", http.StatusUnauthorized) 819 + return 820 + } 821 + 822 + client, err := h.newXRPCClient(session) 823 + if err != nil { 824 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 825 + return 826 + } 827 + 828 + doc, err := client.GetDocument(rKey) 829 + if err != nil { 830 + http.Error(w, "Document not found", http.StatusNotFound) 831 + return 832 + } 833 + 834 + // Check if user is collaborator 835 + isCollaborator := false 836 + for _, c := range doc.Collaborators { 837 + if c == did { 838 + isCollaborator = true 839 + break 840 + } 841 + } 842 + if !isCollaborator { 843 + http.Error(w, "Not a collaborator", http.StatusForbidden) 844 + return 845 + } 846 + 847 + // Generate color based on DID 848 + color := colorFromDID(did) 849 + 850 + // Upgrade connection 851 + conn, err := upgrader.Upgrade(w, r, nil) 852 + if err != nil { 853 + log.Printf("WebSocket upgrade failed: %v", err) 854 + return 855 + } 856 + 857 + // Get room and register client 858 + room := h.CollaborationHub.GetOrCreateRoom(rKey) 859 + wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 860 + room.Register <- wsClient 861 + 862 + // Start pumps 863 + go wsClient.WritePump() 864 + wsClient.ReadPump() 865 + } 866 + 867 + func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) { 868 + // Validate JWT and DPoP proof, extract DID and name 869 + // Use existing ATProto token validation 870 + return "", "", nil 871 + } 872 + 873 + func colorFromDID(did string) string { 874 + colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"} 875 + hash := 0 876 + for _, c := range did { 877 + hash += int(c) 878 + } 879 + return colors[hash%len(colors)] 880 + } 881 + ``` 882 + 883 + - [ ] **Step 2: Wire up Hub in main.go** 884 + 885 + ```go 886 + // In main.go, add to Handler struct or global 887 + var collaborationHub = collaboration.NewHub() 888 + 889 + // Pass to handler 890 + handler := &handler.Handler{ 891 + DB: db, 892 + Store: store, 893 + Render: r, 894 + CollaborationHub: collaborationHub, 895 + } 896 + ``` 897 + 898 + - [ ] **Step 3: Register WebSocket route** 899 + 900 + ```go 901 + mux.HandleFunc("GET /ws/doc/{rkey}", handler.CollaboratorWebSocket) 902 + ``` 903 + 904 + - [ ] **Step 4: Commit** 905 + 906 + ```bash 907 + git add internal/handler/handler.go cmd/server/main.go 908 + git commit -m "feat: add WebSocket collaboration handler" 909 + ``` 910 + 911 + --- 912 + 913 + ## Chunk 5: Frontend Updates 914 + 915 + ### Task 5.1: WebSocket client and presence 916 + 917 + **Files:** 918 + - Modify: `templates/document_edit.html` 919 + 920 + - [ ] **Step 1: Add WebSocket connection** 921 + 922 + ```javascript 923 + // Add to document_edit.html 924 + let ws = null; 925 + let collaborators = []; 926 + 927 + function connectWebSocket(rkey) { 928 + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 929 + const wsUrl = `${protocol}//${window.location.host}/ws/doc/${rkey}?access_token=${encodeURIComponent(getAccessToken())}&dpop_proof=${encodeURIComponent(getDPoPProof())}`; 930 + 931 + ws = new WebSocket(wsUrl); 932 + 933 + ws.onopen = () => { 934 + console.log('WebSocket connected'); 935 + ws.send(JSON.stringify({ type: 'join', rkey: rkey, did: getCurrentDID() })); 936 + }; 937 + 938 + ws.onmessage = (event) => { 939 + const msg = JSON.parse(event.data); 940 + handleWSMessage(msg); 941 + }; 942 + 943 + ws.onclose = () => { 944 + console.log('WebSocket disconnected'); 945 + setTimeout(() => connectWebSocket(rkey), 3000); 946 + }; 947 + } 948 + 949 + function handleWSMessage(msg) { 950 + switch (msg.type) { 951 + case 'presence': 952 + updatePresenceSidebar(msg.users); 953 + break; 954 + case 'edit': 955 + applyRemoteEdit(msg.delta); 956 + break; 957 + case 'sync': 958 + setEditorContent(msg.content); 959 + break; 960 + } 961 + } 962 + 963 + function updatePresenceSidebar(users) { 964 + collaborators = users; 965 + const sidebar = document.getElementById('presence-sidebar'); 966 + if (!sidebar) return; 967 + 968 + sidebar.innerHTML = users.map(u => ` 969 + <div class="presence-user" style="display: flex; align-items: center; gap: 8px; padding: 8px;"> 970 + <span class="presence-avatar" style="width: 12px; height: 12px; border-radius: 50%; background: ${u.color};"></span> 971 + <span>${u.name}</span> 972 + </div> 973 + `).join(''); 974 + } 975 + 976 + function getAccessToken() { 977 + // Get from session storage or cookie 978 + return sessionStorage.getItem('atproto_access_token'); 979 + } 980 + 981 + function getDPoPProof() { 982 + return sessionStorage.getItem('atproto_dpop_proof'); 983 + } 984 + 985 + function getCurrentDID() { 986 + return sessionStorage.getItem('atproto_did'); 987 + } 988 + 989 + // Connect on page load if user is collaborator 990 + if (isCollaborator) { 991 + connectWebSocket(documentRKey); 992 + } 993 + ``` 994 + 995 + - [ ] **Step 2: Add presence sidebar to HTML** 996 + 997 + Add to document_edit.html: 998 + ```html 999 + <div id="presence-sidebar" style="position: fixed; right: 0; top: 50%; transform: translateY(-50%); background: #f5f5f5; padding: 16px; border-radius: 8px; z-index: 100;"> 1000 + <h3 style="margin: 0 0 12px; font-size: 14px;">Collaborators</h3> 1001 + </div> 1002 + ``` 1003 + 1004 + - [ ] **Step 3: Commit** 1005 + 1006 + ```bash 1007 + git add templates/document_edit.html 1008 + git commit -m "feat: add WebSocket client and presence sidebar" 1009 + ``` 1010 + 1011 + ### Task 5.2: Comment UI 1012 + 1013 + **Files:** 1014 + - Modify: `templates/document_edit.html` 1015 + 1016 + - [ ] **Step 1: Add comment functionality** 1017 + 1018 + ```javascript 1019 + // Add to document_edit.html 1020 + function addComment(paragraphId) { 1021 + const text = prompt('Enter your comment:'); 1022 + if (!text) return; 1023 + 1024 + fetch(`/api/docs/${documentRKey}/comments`, { 1025 + method: 'POST', 1026 + headers: { 'Content-Type': 'application/json' }, 1027 + body: JSON.stringify({ paragraphId, text }) 1028 + }) 1029 + .then(res => res.json()) 1030 + .then(data => { 1031 + renderCommentThread(paragraphId, [{ text, author: getCurrentDID(), createdAt: new Date().toISOString() }]); 1032 + }); 1033 + } 1034 + 1035 + function renderCommentThread(paragraphId, comments) { 1036 + const container = document.getElementById(`comments-${paragraphId}`); 1037 + if (!container) return; 1038 + 1039 + container.innerHTML = comments.map(c => ` 1040 + <div class="comment" style="padding: 8px; margin: 4px 0; background: #fff; border-radius: 4px;"> 1041 + <div class="comment-text">${c.text}</div> 1042 + <div class="comment-meta" style="font-size: 12px; color: #666;">${c.author} - ${new Date(c.createdAt).toLocaleString()}</div> 1043 + </div> 1044 + `).join(''); 1045 + } 1046 + 1047 + // Load comments on page load 1048 + fetch(`/api/docs/${documentRKey}/comments`) 1049 + .then(res => res.json()) 1050 + .then(comments => { 1051 + // Group by paragraphId and render 1052 + const byParagraph = {}; 1053 + comments.forEach(c => { 1054 + if (!byParagraph[c.paragraphId]) byParagraph[c.paragraphId] = []; 1055 + byParagraph[c.paragraphId].push(c); 1056 + }); 1057 + Object.keys(byParagraph).forEach(pid => { 1058 + renderCommentThread(pid, byParagraph[pid]); 1059 + }); 1060 + }); 1061 + ``` 1062 + 1063 + - [ ] **Step 2: Add comment button to each paragraph** 1064 + 1065 + Add click handler to editor that shows comment button on paragraph selection: 1066 + ```javascript 1067 + editor.on('selectionChange', (data) => { 1068 + const selectedNode = data.state.selection.$from.parent; 1069 + if (selectedNode) { 1070 + showCommentButton(selectedNode.attrs.id); 1071 + } 1072 + }); 1073 + 1074 + function showCommentButton(nodeId) { 1075 + // Show floating comment button near selected paragraph 1076 + } 1077 + ``` 1078 + 1079 + - [ ] **Step 3: Add comment CSS** 1080 + 1081 + ```css 1082 + .comment-thread { 1083 + margin-top: 8px; 1084 + padding: 8px; 1085 + background: #f9f9f9; 1086 + border-left: 3px solid #3498db; 1087 + } 1088 + 1089 + .comment-button { 1090 + position: absolute; 1091 + right: 8px; 1092 + padding: 4px 8px; 1093 + background: #3498db; 1094 + color: white; 1095 + border: none; 1096 + border-radius: 4px; 1097 + cursor: pointer; 1098 + } 1099 + ``` 1100 + 1101 + - [ ] **Step 4: Commit** 1102 + 1103 + ```bash 1104 + git add templates/document_edit.html static/css/editor.css 1105 + git commit -m "feat: add comment UI" 1106 + ``` 1107 + 1108 + --- 1109 + 1110 + ## Final Review 1111 + 1112 + After completing all chunks: 1113 + - Run `go build ./...` to verify compilation 1114 + - Verify all handlers are registered in main.go 1115 + - Ensure no conflicts with existing code 1116 + - Test basic flow: create document, generate invite, accept invite, see presence