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