Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 1116 lines 28 kB view raw view rendered
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 23CREATE 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 32CREATE INDEX idx_invites_document ON invites(document_rkey); 33CREATE INDEX idx_invites_token ON invites(token); 34``` 35 36- [ ] **Step 2: Commit** 37 38```bash 39git add migrations/005_create_invites.sql 40git 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 53type Invite struct { 54 ID string 55 DocumentRKey string 56 Token string 57 CreatedBy string 58 CreatedAt time.Time 59 ExpiresAt time.Time 60} 61 62type 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 75In the `Document` struct, add: 76```go 77Collaborators []string `json:"collaborators,omitempty"` 78``` 79 80- [ ] **Step 4: Commit** 81 82```bash 83git add internal/model/models.go 84git 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 99package collaboration 100 101import ( 102 "log" 103 "sync" 104) 105 106type Hub struct { 107 rooms map[string]*Room 108 mu sync.RWMutex 109} 110 111type 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 120func NewHub() *Hub { 121 return &Hub{ 122 rooms: make(map[string]*Room), 123 } 124} 125 126func (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 144func (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 175func (r *Room) Broadcast(message []byte) { 176 r.broadcast <- message 177} 178 179func (r *Room) broadcastPresence() { 180 // Implementation in Task 2.2 181} 182``` 183 184- [ ] **Step 2: Commit** 185 186```bash 187git add internal/collaboration/hub.go 188git 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 199package collaboration 200 201import ( 202 "github.com/gorilla/websocket" 203) 204 205type 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 215type 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 224type CursorPos struct { 225 Position int `json:"position"` 226 SelectionEnd int `json:"selectionEnd"` 227} 228 229type CommentMsg struct { 230 ParagraphID string `json:"paragraphId"` 231 Text string `json:"text"` 232} 233 234type PresenceUser struct { 235 DID string `json:"did"` 236 Name string `json:"name"` 237 Color string `json:"color"` 238} 239 240type PresenceMessage struct { 241 Type string `json:"type"` 242 Users []PresenceUser `json:"users"` 243} 244 245func 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 257func (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 274func (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 291Add to `hub.go`: 292```go 293func (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 307func (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 320git add internal/collaboration/client.go internal/collaboration/hub.go 321git 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 332package collaboration 333 334import ( 335 "crypto/rand" 336 "crypto/sha256" 337 "encoding/hex" 338 "time" 339 340 "github.com/limeleaf/diffdown/internal/db" 341) 342 343func 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 352func 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 371func 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 388In `internal/db/db.go`, add: 389```go 390func (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 398func (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 412git add internal/collaboration/invite.go internal/db/db.go 413git 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 424package collaboration 425 426import "sync" 427 428type OTEngine struct { 429 mu sync.Mutex 430 documentText string 431 version int 432} 433 434func NewOTEngine(initialText string) *OTEngine { 435 return &OTEngine{ 436 documentText: initialText, 437 version: 0, 438 } 439} 440 441type Operation struct { 442 From int `json:"from"` 443 To int `json:"to"` 444 Insert string `json:"insert"` 445 Author string `json:"author"` 446} 447 448func (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 467func (ot *OTEngine) GetText() string { 468 ot.mu.Lock() 469 defer ot.mu.Unlock() 470 return ot.documentText 471} 472 473func (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 483git add internal/collaboration/ot.go 484git 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 499func (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 559mux.HandleFunc("POST /api/docs/{rkey}/invite", handler.DocumentInvite) 560``` 561 562- [ ] **Step 3: Commit** 563 564```bash 565git add internal/handler/handler.go cmd/server/main.go 566git 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 577func (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 646mux.HandleFunc("GET /doc/{rkey}/accept", handler.AcceptInvite) 647``` 648 649- [ ] **Step 3: Commit** 650 651```bash 652git add internal/handler/handler.go cmd/server/main.go 653git 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 664func (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 727func (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 766mux.HandleFunc("POST /api/docs/{rkey}/comments", handler.CommentCreate) 767mux.HandleFunc("GET /api/docs/{rkey}/comments", handler.CommentList) 768``` 769 770- [ ] **Step 4: Commit** 771 772```bash 773git add internal/handler/handler.go cmd/server/main.go 774git 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 789var upgrader = websocket.Upgrader{ 790 CheckOrigin: func(r *http.Request) bool { return true }, 791} 792 793func (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 867func (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 873func 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 887var collaborationHub = collaboration.NewHub() 888 889// Pass to handler 890handler := &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 901mux.HandleFunc("GET /ws/doc/{rkey}", handler.CollaboratorWebSocket) 902``` 903 904- [ ] **Step 4: Commit** 905 906```bash 907git add internal/handler/handler.go cmd/server/main.go 908git 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 924let ws = null; 925let collaborators = []; 926 927function 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 949function 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 963function 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 976function getAccessToken() { 977 // Get from session storage or cookie 978 return sessionStorage.getItem('atproto_access_token'); 979} 980 981function getDPoPProof() { 982 return sessionStorage.getItem('atproto_dpop_proof'); 983} 984 985function getCurrentDID() { 986 return sessionStorage.getItem('atproto_did'); 987} 988 989// Connect on page load if user is collaborator 990if (isCollaborator) { 991 connectWebSocket(documentRKey); 992} 993``` 994 995- [ ] **Step 2: Add presence sidebar to HTML** 996 997Add 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 1007git add templates/document_edit.html 1008git 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 1020function 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 1035function 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 1048fetch(`/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 1065Add click handler to editor that shows comment button on paragraph selection: 1066```javascript 1067editor.on('selectionChange', (data) => { 1068 const selectedNode = data.state.selection.$from.parent; 1069 if (selectedNode) { 1070 showCommentButton(selectedNode.attrs.id); 1071 } 1072}); 1073 1074function 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 1104git add templates/document_edit.html static/css/editor.css 1105git commit -m "feat: add comment UI" 1106``` 1107 1108--- 1109 1110## Final Review 1111 1112After 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