Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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