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

Merge feature/collaboration into main

+4859 -81
+1 -1
.gitignore
··· 16 16 .Trashes 17 17 ehthumbs.db 18 18 Thumbs.db 19 - server 19 + /server
+218
AGENTS.md
··· 1 + # AGENTS.md - Agentic Coding Guidelines for Diffdown 2 + 3 + This file provides guidelines for AI agents operating in this repository. 4 + 5 + ## Project Overview 6 + 7 + Diffdown is a collaborative Markdown editor with ATProto (Bluesky) authentication. It uses Go for the backend and vanilla JavaScript/CodeMirror for the frontend. 8 + 9 + --- 10 + 11 + ## Build, Lint, and Test Commands 12 + 13 + ### Makefile Targets 14 + 15 + | Command | Description | 16 + |---------|-------------| 17 + | `make build` | Build the Go application to `bin/server` | 18 + | `make run` | Run the server directly with `go run` | 19 + | `make test` | Run all tests with verbose output | 20 + | `make test-coverage` | Run tests with coverage report (`coverage.html`) | 21 + | `make lint` | Run golangci-lint | 22 + | `make fmt` | Format code with `go fmt` | 23 + | `make dev` | Run with hot reload using air | 24 + | `make migrate-up` | Run database migrations | 25 + | `make migrate-down` | Rollback last migration | 26 + | `make migrate-create name=<name>` | Create new migration | 27 + 28 + ### Running a Single Test 29 + 30 + ```bash 31 + # Run tests in a specific package 32 + go test -v ./internal/handler 33 + 34 + # Run a specific test function 35 + go test -v -run TestFunctionName ./internal/handler 36 + 37 + # Run tests matching a pattern 38 + go test -v -run "Test.*Handler" ./... 39 + ``` 40 + 41 + ### Environment Variables 42 + 43 + ```bash 44 + export DIFFDOWN_SESSION_SECRET="your-secret-here" 45 + export BASE_URL="http://127.0.0.1:8080" 46 + export PORT=8080 47 + export DB_PATH="./diffdown.db" 48 + ``` 49 + 50 + --- 51 + 52 + ## Code Style Guidelines 53 + 54 + ### Go Conventions 55 + 56 + - **Go Version**: 1.22+ 57 + - **Module**: `github.com/limeleaf/diffdown` 58 + - **Formatting**: Use `go fmt` or `gofumports` - run before committing 59 + 60 + ### Imports 61 + 62 + Organize imports in three groups with blank lines between: 63 + 1. Standard library 64 + 2. Third-party packages 65 + 3. Internal packages 66 + 67 + ```go 68 + import ( 69 + "context" 70 + "encoding/json" 71 + "fmt" 72 + "log" 73 + "net/http" 74 + "time" 75 + 76 + "github.com/gorilla/sessions" 77 + "golang.org/x/crypto/bcrypt" 78 + 79 + "github.com/limeleaf/diffdown/internal/auth" 80 + "github.com/limeleaf/diffdown/internal/db" 81 + "github.com/limeleaf/diffdown/internal/model" 82 + ) 83 + ``` 84 + 85 + ### Naming Conventions 86 + 87 + - **Files**: `snake_case.go` (e.g., `auth.go`, `handler.go`) 88 + - **Types/Interfaces**: `PascalCase` (e.g., `Handler`, `User`, `DB`) 89 + - **Functions/Methods**: `PascalCase` exported, `camelCase` unexported 90 + - **Constants**: `PascalCase` for exported, `camelCase` for unexported 91 + - **Variables**: `camelCase`, prefer short names (`db`, `err`, `w`, `r`) 92 + 93 + ### Error Handling 94 + 95 + - Always handle errors explicitly; avoid bare `err != nil` checks 96 + - Log meaningful context with `log.Printf` before returning errors 97 + - Return user-friendly error messages in HTTP handlers 98 + - Use wrapped errors with `%w` for context: `fmt.Errorf("open db: %w", err)` 99 + 100 + ### HTTP Handlers 101 + 102 + Follow the existing pattern in `internal/handler/handler 103 + func (h *Handler) Handler.go`: 104 + 105 + ```goName(w http.ResponseWriter, r *http.Request) { 106 + // 1. Authenticate/authorize 107 + user := h.currentUser(r) 108 + if user == nil { 109 + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 110 + return 111 + } 112 + 113 + // 2. Extract request data 114 + data := r.FormValue("field") 115 + 116 + // 3. Process (call DB, external API, etc.) 117 + result, err := h.DB.Method(data) 118 + if err != nil { 119 + log.Printf("HandlerName: operation: %v", err) 120 + h.render(w, "page.html", PageData{Error: "Friendly message"}) 121 + return 122 + } 123 + 124 + // 4. Respond 125 + h.render(w, "page.html", PageData{Content: result}) 126 + } 127 + ``` 128 + 129 + ### Database 130 + 131 + - Use SQLite with WAL mode: `_journal_mode=WAL&_foreign_keys=on` 132 + - Use ULID for IDs: `db.NewID()` (uses `oklog/ulid/v2`) 133 + - Use migrations in `migrations/` directory (SQL files) 134 + - Apply migrations on startup: `database.Migrate()` 135 + 136 + ### Authentication 137 + 138 + - Sessions: Gorilla `sessions.CookieStore` 139 + - Passwords: bcrypt (via `golang.org/x/crypto/bcrypt`) 140 + - ATProto OAuth: DPoP tokens with JWT 141 + 142 + ### Testing 143 + 144 + - Tests should live in `*_test.go` files alongside the code they test 145 + - Use table-driven tests when testing multiple cases 146 + - Mock external dependencies (database, HTTP clients) where practical 147 + 148 + --- 149 + 150 + ## Project Structure 151 + 152 + ``` 153 + diffdown/ 154 + ├── cmd/ 155 + │ ├── server/main.go # Application entry point 156 + │ └── migrate/main.go # Migration tool 157 + ├── internal/ 158 + │ ├── atproto/ # ATProto OAuth, DPoP, XRPC client 159 + │ ├── auth/ # Session management, password hashing 160 + │ ├── db/ # Database operations, migrations 161 + │ ├── handler/ # HTTP handlers 162 + │ ├── middleware/ # HTTP middleware (logging, auth injection) 163 + │ ├── model/ # Data models 164 + │ └── render/ # Markdown rendering 165 + ├── migrations/ # SQL migration files 166 + ├── static/ # Frontend assets 167 + ├── templates/ # HTML templates 168 + ├── Makefile # Build commands 169 + └── go.mod # Go dependencies 170 + ``` 171 + 172 + --- 173 + 174 + ## Common Tasks 175 + 176 + ### Adding a New Handler 177 + 178 + 1. Add method to `Handler` struct in `internal/handler/handler.go` 179 + 2. Register route in `cmd/server/main.go` 180 + 3. Add template to `templates/` if needed 181 + 182 + ### Adding a Database Query 183 + 184 + 1. Add method to `DB` struct in `internal/db/db.go` 185 + 2. Follow naming: `GetX`, `CreateX`, `UpdateX`, `DeleteX` 186 + 3. Use `db.scanUser` helper for scanning rows 187 + 188 + ### Adding a Migration 189 + 190 + ```bash 191 + make migrate-create name=create_users_table 192 + ``` 193 + 194 + Then edit the generated SQL file in `migrations/`. 195 + 196 + --- 197 + 198 + ## Dependencies 199 + 200 + Key dependencies (see `go.mod`): 201 + - `github.com/gorilla/sessions` - Session management 202 + - `github.com/mattn/go-sqlite3` - SQLite driver 203 + - `github.com/yuin/goldmark` - Markdown parsing 204 + - `github.com/golang-jwt/jwt/v5` - JWT tokens 205 + - `golang.org/x/crypto` - bcrypt, cryptography 206 + 207 + --- 208 + 209 + ## Frontend (JavaScript) 210 + 211 + The frontend uses vanilla JavaScript with CodeMirror 6 for the editor. Bundling is done with esbuild: 212 + 213 + ```bash 214 + npm install 215 + npx esbuild --bundle --format=esm --outfile=static/vendor/codemirror.js ... 216 + ``` 217 + 218 + Static files are served from `static/` at `/static/`.
bin/server

This is a binary file and will not be displayed.

+16 -1
cmd/server/main.go
··· 9 9 "time" 10 10 11 11 "github.com/limeleaf/diffdown/internal/auth" 12 + "github.com/limeleaf/diffdown/internal/collaboration" 12 13 "github.com/limeleaf/diffdown/internal/db" 13 14 "github.com/limeleaf/diffdown/internal/handler" 14 15 "github.com/limeleaf/diffdown/internal/middleware" ··· 27 28 sessionSecret := os.Getenv("DIFFDOWN_SESSION_SECRET") 28 29 if sessionSecret == "" { 29 30 sessionSecret = "dev-secret-change-in-production" 31 + } 32 + baseURL := os.Getenv("BASE_URL") 33 + if baseURL == "" { 34 + baseURL = "http://127.0.0.1:8080" 30 35 } 31 36 32 37 // Init ··· 65 70 "templates/"+name, 66 71 )) 67 72 } 68 - h := handler.New(database, tmpls) 73 + 74 + collabHub := collaboration.NewHub() 75 + h := handler.New(database, tmpls, baseURL, collabHub) 69 76 70 77 // Routes 71 78 mux := http.NewServeMux() ··· 94 101 mux.HandleFunc("POST /docs/new", h.NewDocumentSubmit) 95 102 mux.HandleFunc("GET /docs/{rkey}", h.DocumentView) 96 103 mux.HandleFunc("GET /docs/{rkey}/edit", h.DocumentEdit) 104 + // Collaborator-scoped routes (owner DID in path) 105 + mux.HandleFunc("GET /docs/{did}/{rkey}", h.CollaboratorDocumentView) 106 + mux.HandleFunc("GET /docs/{did}/{rkey}/edit", h.CollaboratorDocumentEdit) 97 107 98 108 // API 99 109 mux.HandleFunc("POST /api/render", h.APIRender) 100 110 mux.HandleFunc("POST /api/docs/{rkey}/save", h.APIDocumentSave) 101 111 mux.HandleFunc("PUT /api/docs/{rkey}/autosave", h.APIDocumentAutoSave) 102 112 mux.HandleFunc("DELETE /api/docs/{rkey}", h.APIDocumentDelete) 113 + mux.HandleFunc("POST /api/docs/{rkey}/invite", h.DocumentInvite) 114 + mux.HandleFunc("GET /docs/{rkey}/accept", h.AcceptInvite) 103 115 mux.HandleFunc("POST /api/docs/{rkey}/comments", h.CommentCreate) 104 116 mux.HandleFunc("GET /api/docs/{rkey}/comments", h.CommentList) 117 + 118 + // WebSocket 119 + mux.HandleFunc("GET /ws/docs/{rkey}", h.CollaboratorWebSocket) 105 120 106 121 // Middleware stack 107 122 stack := middleware.Logger(
+1116
docs/superpowers/plans/2026-03-11-collaboration-implementation.md
··· 1 + # Collaboration Feature Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Implement real-time collaboration for Markdown documents with up to 5 users, paragraph-level comments, and invite-based access control. 6 + 7 + **Architecture:** Server-side collaboration hub with WebSocket connections. Server maintains canonical document state, broadcasts edits, debounces ATProto persistence. Comments stored in separate ATProto collection. 8 + 9 + **Tech Stack:** Go (stdlib, gorilla/websocket), ATProto XRPC, SQLite 10 + 11 + --- 12 + 13 + ## Chunk 1: Database Migration and Models 14 + 15 + ### Task 1.1: Create invites migration 16 + 17 + **Files:** 18 + - Create: `migrations/005_create_invites.sql` 19 + 20 + - [ ] **Step 1: Write the migration** 21 + 22 + ```sql 23 + CREATE TABLE IF NOT EXISTS invites ( 24 + id TEXT PRIMARY KEY, 25 + document_rkey TEXT NOT NULL, 26 + token TEXT NOT NULL UNIQUE, 27 + created_by_did TEXT NOT NULL, 28 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 29 + expires_at DATETIME NOT NULL 30 + ); 31 + 32 + CREATE INDEX idx_invites_document ON invites(document_rkey); 33 + CREATE INDEX idx_invites_token ON invites(token); 34 + ``` 35 + 36 + - [ ] **Step 2: Commit** 37 + 38 + ```bash 39 + git add migrations/005_create_invites.sql 40 + git commit -m "feat: add invites table migration" 41 + ``` 42 + 43 + ### Task 1.2: Update models for collaboration 44 + 45 + **Files:** 46 + - Modify: `internal/model/models.go` 47 + 48 + - [ ] **Step 1: Write the failing test (skip - no existing tests)** 49 + 50 + - [ ] **Step 2: Add Invite and Comment types** 51 + 52 + ```go 53 + type Invite struct { 54 + ID string 55 + DocumentRKey string 56 + Token string 57 + CreatedBy string 58 + CreatedAt time.Time 59 + ExpiresAt time.Time 60 + } 61 + 62 + type Comment struct { 63 + URI string 64 + DocumentURI string 65 + ParagraphID string 66 + Text string 67 + AuthorDID string 68 + AuthorName string 69 + CreatedAt string 70 + } 71 + ``` 72 + 73 + - [ ] **Step 3: Add Collaborators field to Document** 74 + 75 + In the `Document` struct, add: 76 + ```go 77 + Collaborators []string `json:"collaborators,omitempty"` 78 + ``` 79 + 80 + - [ ] **Step 4: Commit** 81 + 82 + ```bash 83 + git add internal/model/models.go 84 + git commit -m "feat: add Invite, Comment models and Document.collaborators" 85 + ``` 86 + 87 + --- 88 + 89 + ## Chunk 2: Collaboration Package (Core Logic) 90 + 91 + ### Task 2.1: Create collaboration hub 92 + 93 + **Files:** 94 + - Create: `internal/collaboration/hub.go` 95 + 96 + - [ ] **Step 1: Write the hub with WebSocket room management** 97 + 98 + ```go 99 + package collaboration 100 + 101 + import ( 102 + "log" 103 + "sync" 104 + ) 105 + 106 + type Hub struct { 107 + rooms map[string]*Room 108 + mu sync.RWMutex 109 + } 110 + 111 + type Room struct { 112 + documentRKey string 113 + clients map[*Client]bool 114 + broadcast chan []byte 115 + register chan *Client 116 + unregister chan *Client 117 + mu sync.RWMutex 118 + } 119 + 120 + func NewHub() *Hub { 121 + return &Hub{ 122 + rooms: make(map[string]*Room), 123 + } 124 + } 125 + 126 + func (h *Hub) GetOrCreateRoom(rkey string) *Room { 127 + h.mu.Lock() 128 + defer h.mu.Unlock() 129 + if room, exists := h.rooms[rkey]; exists { 130 + return room 131 + } 132 + room := &Room{ 133 + documentRKey: rkey, 134 + clients: make(map[*Client]bool), 135 + broadcast: make(chan []byte, 256), 136 + register: make(chan *Client), 137 + unregister: make(chan *Client), 138 + } 139 + h.rooms[rkey] = room 140 + go room.run() 141 + return room 142 + } 143 + 144 + func (r *Room) run() { 145 + for { 146 + select { 147 + case client := <-r.register: 148 + r.mu.Lock() 149 + r.clients[client] = true 150 + r.mu.Unlock() 151 + r.broadcastPresence() 152 + case client := <-r.unregister: 153 + r.mu.Lock() 154 + if _, ok := r.clients[client]; ok { 155 + delete(r.clients, client) 156 + close(client.send) 157 + } 158 + r.mu.Unlock() 159 + r.broadcastPresence() 160 + case message := <-r.broadcast: 161 + r.mu.RLock() 162 + for client := range r.clients { 163 + select { 164 + case client.send <- message: 165 + default: 166 + close(client.send) 167 + delete(r.clients, client) 168 + } 169 + } 170 + r.mu.RUnlock() 171 + } 172 + } 173 + } 174 + 175 + func (r *Room) Broadcast(message []byte) { 176 + r.broadcast <- message 177 + } 178 + 179 + func (r *Room) broadcastPresence() { 180 + // Implementation in Task 2.2 181 + } 182 + ``` 183 + 184 + - [ ] **Step 2: Commit** 185 + 186 + ```bash 187 + git add internal/collaboration/hub.go 188 + git commit -m "feat: add collaboration hub with room management" 189 + ``` 190 + 191 + ### Task 2.2: Create client representation 192 + 193 + **Files:** 194 + - Create: `internal/collaboration/client.go` 195 + 196 + - [ ] **Step 1: Write the client struct** 197 + 198 + ```go 199 + package collaboration 200 + 201 + import ( 202 + "github.com/gorilla/websocket" 203 + ) 204 + 205 + type Client struct { 206 + hub *Hub 207 + conn *websocket.Conn 208 + send chan []byte 209 + DID string 210 + Name string 211 + Color string 212 + roomKey string 213 + } 214 + 215 + type ClientMessage struct { 216 + Type string `json:"type"` 217 + RKey string `json:"rkey,omitempty"` 218 + DID string `json:"did,omitempty"` 219 + Delta json.RawMessage `json:"delta,omitempty"` 220 + Cursor *CursorPos `json:"cursor,omitempty"` 221 + Comment *CommentMsg `json:"comment,omitempty"` 222 + } 223 + 224 + type CursorPos struct { 225 + Position int `json:"position"` 226 + SelectionEnd int `json:"selectionEnd"` 227 + } 228 + 229 + type CommentMsg struct { 230 + ParagraphID string `json:"paragraphId"` 231 + Text string `json:"text"` 232 + } 233 + 234 + type PresenceUser struct { 235 + DID string `json:"did"` 236 + Name string `json:"name"` 237 + Color string `json:"color"` 238 + } 239 + 240 + type PresenceMessage struct { 241 + Type string `json:"type"` 242 + Users []PresenceUser `json:"users"` 243 + } 244 + 245 + func NewClient(hub *Hub, conn *websocket.Conn, did, name, color, roomKey string) *Client { 246 + return &Client{ 247 + hub: hub, 248 + conn: conn, 249 + send: make(chan []byte, 256), 250 + DID: did, 251 + Name: name, 252 + Color: color, 253 + roomKey: roomKey, 254 + } 255 + } 256 + 257 + func (c *Client) ReadPump() { 258 + defer func() { 259 + c.hub.unregister <- c 260 + c.conn.Close() 261 + }() 262 + for { 263 + _, message, err := c.conn.ReadMessage() 264 + if err != nil { 265 + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 266 + log.Printf("WebSocket error: %v", err) 267 + } 268 + break 269 + } 270 + // Handle message - dispatch to appropriate handler 271 + } 272 + } 273 + 274 + func (c *Client) WritePump() { 275 + defer c.conn.Close() 276 + for { 277 + message, ok := <-c.send 278 + if !ok { 279 + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 280 + return 281 + } 282 + if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { 283 + return 284 + } 285 + } 286 + } 287 + ``` 288 + 289 + - [ ] **Step 2: Implement presence broadcasting in hub** 290 + 291 + Add to `hub.go`: 292 + ```go 293 + func (r *Room) GetPresence() []PresenceUser { 294 + r.mu.RLock() 295 + defer r.mu.RUnlock() 296 + users := make([]PresenceUser, 0, len(r.clients)) 297 + for client := range r.clients { 298 + users = append(users, PresenceUser{ 299 + DID: client.DID, 300 + Name: client.Name, 301 + Color: client.Color, 302 + }) 303 + } 304 + return users 305 + } 306 + 307 + func (r *Room) broadcastPresence() { 308 + presence := PresenceMessage{ 309 + Type: "presence", 310 + Users: r.GetPresence(), 311 + } 312 + data, _ := json.Marshal(presence) 313 + r.Broadcast(data) 314 + } 315 + ``` 316 + 317 + - [ ] **Step 3: Commit** 318 + 319 + ```bash 320 + git add internal/collaboration/client.go internal/collaboration/hub.go 321 + git commit -m "feat: add client representation and presence broadcasting" 322 + ``` 323 + 324 + ### Task 2.3: Create invite system 325 + 326 + **Files:** 327 + - Create: `internal/collaboration/invite.go` 328 + 329 + - [ ] **Step 1: Write the invite logic** 330 + 331 + ```go 332 + package collaboration 333 + 334 + import ( 335 + "crypto/rand" 336 + "crypto/sha256" 337 + "encoding/hex" 338 + "time" 339 + 340 + "github.com/limeleaf/diffdown/internal/db" 341 + ) 342 + 343 + func GenerateInviteToken() (string, error) { 344 + bytes := make([]byte, 32) 345 + if _, err := rand.Read(bytes); err != nil { 346 + return "", err 347 + } 348 + hash := sha256.Sum256(bytes) 349 + return hex.EncodeToString(hash[:]), nil 350 + } 351 + 352 + func CreateInvite(db *db.DB, documentRKey, createdByDID string) (*model.Invite, error) { 353 + token, err := GenerateInviteToken() 354 + if err != nil { 355 + return nil, err 356 + } 357 + 358 + invite := &model.Invite{ 359 + ID: db.NewID(), 360 + DocumentRKey: documentRKey, 361 + Token: token, 362 + CreatedBy: createdByDID, 363 + CreatedAt: time.Now(), 364 + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), 365 + } 366 + 367 + err = db.CreateInvite(invite) 368 + return invite, err 369 + } 370 + 371 + func ValidateInvite(db *db.DB, token, documentRKey string) (*model.Invite, error) { 372 + invite, err := db.GetInviteByToken(token) 373 + if err != nil { 374 + return nil, err 375 + } 376 + if invite.DocumentRKey != documentRKey { 377 + return nil, fmt.Errorf("invite does not match document") 378 + } 379 + if time.Now().After(invite.ExpiresAt) { 380 + return nil, fmt.Errorf("invite expired") 381 + } 382 + return invite, nil 383 + } 384 + ``` 385 + 386 + - [ ] **Step 2: Add DB methods** 387 + 388 + In `internal/db/db.go`, add: 389 + ```go 390 + func (db *DB) CreateInvite(invite *model.Invite) error { 391 + _, err := db.Exec(` 392 + INSERT INTO invites (id, document_rkey, token, created_by_did, created_at, expires_at) 393 + VALUES (?, ?, ?, ?, ?, ?)`, 394 + invite.ID, invite.DocumentRKey, invite.Token, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt) 395 + return err 396 + } 397 + 398 + func (db *DB) GetInviteByToken(token string) (*model.Invite, error) { 399 + row := db.QueryRow(`SELECT id, document_rkey, token, created_by_did, created_at, expires_at FROM invites WHERE token = ?`, token) 400 + var invite model.Invite 401 + err := row.Scan(&invite.ID, &invite.DocumentRKey, &invite.Token, &invite.CreatedBy, &invite.CreatedAt, &invite.ExpiresAt) 402 + if err != nil { 403 + return nil, err 404 + } 405 + return &invite, nil 406 + } 407 + ``` 408 + 409 + - [ ] **Step 3: Commit** 410 + 411 + ```bash 412 + git add internal/collaboration/invite.go internal/db/db.go 413 + git commit -m "feat: add invite generation and validation" 414 + ``` 415 + 416 + ### Task 2.4: Create OT helpers 417 + 418 + **Files:** 419 + - Create: `internal/collaboration/ot.go` 420 + 421 + - [ ] **Step 1: Write simplified OT logic** 422 + 423 + ```go 424 + package collaboration 425 + 426 + import "sync" 427 + 428 + type OTEngine struct { 429 + mu sync.Mutex 430 + documentText string 431 + version int 432 + } 433 + 434 + func NewOTEngine(initialText string) *OTEngine { 435 + return &OTEngine{ 436 + documentText: initialText, 437 + version: 0, 438 + } 439 + } 440 + 441 + type Operation struct { 442 + From int `json:"from"` 443 + To int `json:"to"` 444 + Insert string `json:"insert"` 445 + Author string `json:"author"` 446 + } 447 + 448 + func (ot *OTEngine) Apply(op Operation) string { 449 + ot.mu.Lock() 450 + defer ot.mu.Unlock() 451 + 452 + // Simple last-write-wins 453 + if op.To > len(ot.documentText) { 454 + op.To = len(ot.documentText) 455 + } 456 + if op.From > len(ot.documentText) { 457 + op.From = len(ot.documentText) 458 + } 459 + 460 + newText := ot.documentText[:op.From] + op.Insert + ot.documentText[op.To:] 461 + ot.documentText = newText 462 + ot.version++ 463 + 464 + return ot.documentText 465 + } 466 + 467 + func (ot *OTEngine) GetText() string { 468 + ot.mu.Lock() 469 + defer ot.mu.Unlock() 470 + return ot.documentText 471 + } 472 + 473 + func (ot *OTEngine) GetVersion() int { 474 + ot.mu.Lock() 475 + defer ot.mu.Unlock() 476 + return ot.version 477 + } 478 + ``` 479 + 480 + - [ ] **Step 2: Commit** 481 + 482 + ```bash 483 + git add internal/collaboration/ot.go 484 + git commit -m "feat: add simplified OT engine" 485 + ``` 486 + 487 + --- 488 + 489 + ## Chunk 3: HTTP Handlers 490 + 491 + ### Task 3.1: Document invite handler 492 + 493 + **Files:** 494 + - Modify: `internal/handler/handler.go` 495 + 496 + - [ ] **Step 1: Add DocumentInvite handler** 497 + 498 + ```go 499 + func (h *Handler) DocumentInvite(w http.ResponseWriter, r *http.Request) { 500 + user := h.currentUser(r) 501 + if user == nil { 502 + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 503 + return 504 + } 505 + 506 + rKey := model.RKeyFromURI(r.URL.Path) 507 + if rKey == "" { 508 + http.Error(w, "Invalid document", http.StatusBadRequest) 509 + return 510 + } 511 + 512 + // Get document to verify ownership 513 + client := h.xrpcClient(r) 514 + if client == nil { 515 + h.render(w, "document_edit.html", PageData{Error: "Not authenticated with ATProto"}) 516 + return 517 + } 518 + 519 + doc, err := client.GetDocument(rKey) 520 + if err != nil { 521 + http.Error(w, "Document not found", http.StatusNotFound) 522 + return 523 + } 524 + 525 + // Verify user is creator (DID matches) 526 + session, _ := h.db.GetATProtoSession(user.ID) 527 + if session == nil || session.DID != doc.URI { 528 + http.Error(w, "Unauthorized", http.StatusForbidden) 529 + return 530 + } 531 + 532 + // Check collaborator limit (5 max) 533 + if len(doc.Collaborators) >= 5 { 534 + http.Error(w, "Maximum collaborators reached", http.StatusBadRequest) 535 + return 536 + } 537 + 538 + // Create invite 539 + invite, err := collaboration.CreateInvite(h.db, rKey, session.DID) 540 + if err != nil { 541 + log.Printf("DocumentInvite: create invite: %v", err) 542 + http.Error(w, "Failed to create invite", http.StatusInternalServerError) 543 + return 544 + } 545 + 546 + inviteLink := fmt.Sprintf("%s/doc/%s?invite=%s", os.Getenv("BASE_URL"), rKey, invite.Token) 547 + h.render(w, "document_edit.html", PageData{ 548 + Content: map[string]interface{}{ 549 + "document": doc, 550 + "inviteLink": inviteLink, 551 + }, 552 + }) 553 + } 554 + ``` 555 + 556 + - [ ] **Step 2: Register route in main.go** 557 + 558 + ```go 559 + mux.HandleFunc("POST /api/docs/{rkey}/invite", handler.DocumentInvite) 560 + ``` 561 + 562 + - [ ] **Step 3: Commit** 563 + 564 + ```bash 565 + git add internal/handler/handler.go cmd/server/main.go 566 + git commit -m "feat: add document invite handler" 567 + ``` 568 + 569 + ### Task 3.2: Accept invite handler 570 + 571 + **Files:** 572 + - Modify: `internal/handler/handler.go` 573 + 574 + - [ ] **Step 1: Add AcceptInvite handler** 575 + 576 + ```go 577 + func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { 578 + user := h.currentUser(r) 579 + if user == nil { 580 + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 581 + return 582 + } 583 + 584 + rKey := model.RKeyFromURI(r.URL.Path) 585 + inviteToken := r.URL.Query().Get("invite") 586 + if inviteToken == "" { 587 + http.Error(w, "Invalid invite", http.StatusBadRequest) 588 + return 589 + } 590 + 591 + // Validate invite 592 + invite, err := collaboration.ValidateInvite(h.db, inviteToken, rKey) 593 + if err != nil { 594 + http.Error(w, err.Error(), http.StatusBadRequest) 595 + return 596 + } 597 + 598 + // Get ATProto session 599 + session, err := h.db.GetATProtoSession(user.ID) 600 + if err != nil || session == nil { 601 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 602 + return 603 + } 604 + 605 + // Add user to collaborators via ATProto 606 + client, err := h.newXRPCClient(session) 607 + if err != nil { 608 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 609 + return 610 + } 611 + 612 + // Get current document 613 + doc, err := client.GetDocument(rKey) 614 + if err != nil { 615 + http.Error(w, "Document not found", http.StatusNotFound) 616 + return 617 + } 618 + 619 + // Check if already collaborator 620 + for _, c := range doc.Collaborators { 621 + if c == session.DID { 622 + http.Redirect(w, r, "/doc/"+rKey, http.StatusSeeOther) 623 + return 624 + } 625 + } 626 + 627 + // Add to collaborators 628 + doc.Collaborators = append(doc.Collaborators, session.DID) 629 + err = client.PutDocument(rKey, doc) 630 + if err != nil { 631 + log.Printf("AcceptInvite: add collaborator: %v", err) 632 + http.Error(w, "Failed to add collaborator", http.StatusInternalServerError) 633 + return 634 + } 635 + 636 + // Delete invite token after use 637 + h.db.DeleteInvite(invite.Token) 638 + 639 + http.Redirect(w, r, "/doc/"+rKey, http.StatusSeeOther) 640 + } 641 + ``` 642 + 643 + - [ ] **Step 2: Register route** 644 + 645 + ```go 646 + mux.HandleFunc("GET /doc/{rkey}/accept", handler.AcceptInvite) 647 + ``` 648 + 649 + - [ ] **Step 3: Commit** 650 + 651 + ```bash 652 + git add internal/handler/handler.go cmd/server/main.go 653 + git commit -m "feat: add accept invite handler" 654 + ``` 655 + 656 + ### Task 3.3: Comment handlers 657 + 658 + **Files:** 659 + - Modify: `internal/handler/handler.go` 660 + 661 + - [ ] **Step 1: Add CommentCreate handler** 662 + 663 + ```go 664 + func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { 665 + user := h.currentUser(r) 666 + if user == nil { 667 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 668 + return 669 + } 670 + 671 + rKey := model.RKeyFromURI(r.URL.Path) 672 + if rKey == "" { 673 + http.Error(w, "Invalid document", http.StatusBadRequest) 674 + return 675 + } 676 + 677 + // Parse request body 678 + var req struct { 679 + ParagraphID string `json:"paragraphId"` 680 + Text string `json:"text"` 681 + } 682 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 683 + http.Error(w, "Invalid request", http.StatusBadRequest) 684 + return 685 + } 686 + 687 + if req.Text == "" { 688 + http.Error(w, "Comment text required", http.StatusBadRequest) 689 + return 690 + } 691 + 692 + // Get ATProto session 693 + session, err := h.db.GetATProtoSession(user.ID) 694 + if err != nil || session == nil { 695 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 696 + return 697 + } 698 + 699 + client, err := h.newXRPCClient(session) 700 + if err != nil { 701 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 702 + return 703 + } 704 + 705 + // Create comment record 706 + comment := &model.Comment{ 707 + DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey), 708 + ParagraphID: req.ParagraphID, 709 + Text: req.Text, 710 + AuthorDID: session.DID, 711 + } 712 + 713 + uri, err := client.CreateComment(comment) 714 + if err != nil { 715 + log.Printf("CommentCreate: %v", err) 716 + http.Error(w, "Failed to create comment", http.StatusInternalServerError) 717 + return 718 + } 719 + 720 + h.jsonResponse(w, map[string]string{"uri": uri}, http.StatusCreated) 721 + } 722 + ``` 723 + 724 + - [ ] **Step 2: Add CommentList handler** 725 + 726 + ```go 727 + func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { 728 + rKey := model.RKeyFromURI(r.URL.Path) 729 + if rKey == "" { 730 + http.Error(w, "Invalid document", http.StatusBadRequest) 731 + return 732 + } 733 + 734 + user := h.currentUser(r) 735 + if user == nil { 736 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 737 + return 738 + } 739 + 740 + session, err := h.db.GetATProtoSession(user.ID) 741 + if err != nil || session == nil { 742 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 743 + return 744 + } 745 + 746 + client, err := h.newXRPCClient(session) 747 + if err != nil { 748 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 749 + return 750 + } 751 + 752 + comments, err := client.ListComments(rKey) 753 + if err != nil { 754 + log.Printf("CommentList: %v", err) 755 + http.Error(w, "Failed to list comments", http.StatusInternalServerError) 756 + return 757 + } 758 + 759 + h.jsonResponse(w, comments, http.StatusOK) 760 + } 761 + ``` 762 + 763 + - [ ] **Step 3: Register routes** 764 + 765 + ```go 766 + mux.HandleFunc("POST /api/docs/{rkey}/comments", handler.CommentCreate) 767 + mux.HandleFunc("GET /api/docs/{rkey}/comments", handler.CommentList) 768 + ``` 769 + 770 + - [ ] **Step 4: Commit** 771 + 772 + ```bash 773 + git add internal/handler/handler.go cmd/server/main.go 774 + git commit -m "feat: add comment handlers" 775 + ``` 776 + 777 + --- 778 + 779 + ## Chunk 4: WebSocket Handler 780 + 781 + ### Task 4.1: WebSocket upgrade handler 782 + 783 + **Files:** 784 + - Modify: `internal/handler/handler.go`, `cmd/server/main.go` 785 + 786 + - [ ] **Step 1: Add CollaboratorWebSocket handler** 787 + 788 + ```go 789 + var upgrader = websocket.Upgrader{ 790 + CheckOrigin: func(r *http.Request) bool { return true }, 791 + } 792 + 793 + func (h *Handler) CollaboratorWebSocket(w http.ResponseWriter, r *http.Request) { 794 + rKey := model.RKeyFromURI(r.URL.Path) 795 + if rKey == "" { 796 + http.Error(w, "Invalid document", http.StatusBadRequest) 797 + return 798 + } 799 + 800 + // Get access token and DPoP proof from query params 801 + accessToken := r.URL.Query().Get("access_token") 802 + dpopProof := r.URL.Query().Get("dpop_proof") 803 + if accessToken == "" || dpopProof == "" { 804 + http.Error(w, "Missing auth tokens", http.StatusUnauthorized) 805 + return 806 + } 807 + 808 + // Validate tokens and get DID 809 + did, name, err := h.validateWSToken(accessToken, dpopProof) 810 + if err != nil { 811 + http.Error(w, "Invalid tokens", http.StatusUnauthorized) 812 + return 813 + } 814 + 815 + // Get document and verify collaborator access 816 + session, _ := h.db.GetATProtoSessionByDID(did) 817 + if session == nil { 818 + http.Error(w, "No ATProto session", http.StatusUnauthorized) 819 + return 820 + } 821 + 822 + client, err := h.newXRPCClient(session) 823 + if err != nil { 824 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 825 + return 826 + } 827 + 828 + doc, err := client.GetDocument(rKey) 829 + if err != nil { 830 + http.Error(w, "Document not found", http.StatusNotFound) 831 + return 832 + } 833 + 834 + // Check if user is collaborator 835 + isCollaborator := false 836 + for _, c := range doc.Collaborators { 837 + if c == did { 838 + isCollaborator = true 839 + break 840 + } 841 + } 842 + if !isCollaborator { 843 + http.Error(w, "Not a collaborator", http.StatusForbidden) 844 + return 845 + } 846 + 847 + // Generate color based on DID 848 + color := colorFromDID(did) 849 + 850 + // Upgrade connection 851 + conn, err := upgrader.Upgrade(w, r, nil) 852 + if err != nil { 853 + log.Printf("WebSocket upgrade failed: %v", err) 854 + return 855 + } 856 + 857 + // Get room and register client 858 + room := h.CollaborationHub.GetOrCreateRoom(rKey) 859 + wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 860 + room.Register <- wsClient 861 + 862 + // Start pumps 863 + go wsClient.WritePump() 864 + wsClient.ReadPump() 865 + } 866 + 867 + func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) { 868 + // Validate JWT and DPoP proof, extract DID and name 869 + // Use existing ATProto token validation 870 + return "", "", nil 871 + } 872 + 873 + func colorFromDID(did string) string { 874 + colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"} 875 + hash := 0 876 + for _, c := range did { 877 + hash += int(c) 878 + } 879 + return colors[hash%len(colors)] 880 + } 881 + ``` 882 + 883 + - [ ] **Step 2: Wire up Hub in main.go** 884 + 885 + ```go 886 + // In main.go, add to Handler struct or global 887 + var collaborationHub = collaboration.NewHub() 888 + 889 + // Pass to handler 890 + handler := &handler.Handler{ 891 + DB: db, 892 + Store: store, 893 + Render: r, 894 + CollaborationHub: collaborationHub, 895 + } 896 + ``` 897 + 898 + - [ ] **Step 3: Register WebSocket route** 899 + 900 + ```go 901 + mux.HandleFunc("GET /ws/doc/{rkey}", handler.CollaboratorWebSocket) 902 + ``` 903 + 904 + - [ ] **Step 4: Commit** 905 + 906 + ```bash 907 + git add internal/handler/handler.go cmd/server/main.go 908 + git commit -m "feat: add WebSocket collaboration handler" 909 + ``` 910 + 911 + --- 912 + 913 + ## Chunk 5: Frontend Updates 914 + 915 + ### Task 5.1: WebSocket client and presence 916 + 917 + **Files:** 918 + - Modify: `templates/document_edit.html` 919 + 920 + - [ ] **Step 1: Add WebSocket connection** 921 + 922 + ```javascript 923 + // Add to document_edit.html 924 + let ws = null; 925 + let collaborators = []; 926 + 927 + function connectWebSocket(rkey) { 928 + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 929 + const wsUrl = `${protocol}//${window.location.host}/ws/doc/${rkey}?access_token=${encodeURIComponent(getAccessToken())}&dpop_proof=${encodeURIComponent(getDPoPProof())}`; 930 + 931 + ws = new WebSocket(wsUrl); 932 + 933 + ws.onopen = () => { 934 + console.log('WebSocket connected'); 935 + ws.send(JSON.stringify({ type: 'join', rkey: rkey, did: getCurrentDID() })); 936 + }; 937 + 938 + ws.onmessage = (event) => { 939 + const msg = JSON.parse(event.data); 940 + handleWSMessage(msg); 941 + }; 942 + 943 + ws.onclose = () => { 944 + console.log('WebSocket disconnected'); 945 + setTimeout(() => connectWebSocket(rkey), 3000); 946 + }; 947 + } 948 + 949 + function handleWSMessage(msg) { 950 + switch (msg.type) { 951 + case 'presence': 952 + updatePresenceSidebar(msg.users); 953 + break; 954 + case 'edit': 955 + applyRemoteEdit(msg.delta); 956 + break; 957 + case 'sync': 958 + setEditorContent(msg.content); 959 + break; 960 + } 961 + } 962 + 963 + function updatePresenceSidebar(users) { 964 + collaborators = users; 965 + const sidebar = document.getElementById('presence-sidebar'); 966 + if (!sidebar) return; 967 + 968 + sidebar.innerHTML = users.map(u => ` 969 + <div class="presence-user" style="display: flex; align-items: center; gap: 8px; padding: 8px;"> 970 + <span class="presence-avatar" style="width: 12px; height: 12px; border-radius: 50%; background: ${u.color};"></span> 971 + <span>${u.name}</span> 972 + </div> 973 + `).join(''); 974 + } 975 + 976 + function getAccessToken() { 977 + // Get from session storage or cookie 978 + return sessionStorage.getItem('atproto_access_token'); 979 + } 980 + 981 + function getDPoPProof() { 982 + return sessionStorage.getItem('atproto_dpop_proof'); 983 + } 984 + 985 + function getCurrentDID() { 986 + return sessionStorage.getItem('atproto_did'); 987 + } 988 + 989 + // Connect on page load if user is collaborator 990 + if (isCollaborator) { 991 + connectWebSocket(documentRKey); 992 + } 993 + ``` 994 + 995 + - [ ] **Step 2: Add presence sidebar to HTML** 996 + 997 + Add to document_edit.html: 998 + ```html 999 + <div id="presence-sidebar" style="position: fixed; right: 0; top: 50%; transform: translateY(-50%); background: #f5f5f5; padding: 16px; border-radius: 8px; z-index: 100;"> 1000 + <h3 style="margin: 0 0 12px; font-size: 14px;">Collaborators</h3> 1001 + </div> 1002 + ``` 1003 + 1004 + - [ ] **Step 3: Commit** 1005 + 1006 + ```bash 1007 + git add templates/document_edit.html 1008 + git commit -m "feat: add WebSocket client and presence sidebar" 1009 + ``` 1010 + 1011 + ### Task 5.2: Comment UI 1012 + 1013 + **Files:** 1014 + - Modify: `templates/document_edit.html` 1015 + 1016 + - [ ] **Step 1: Add comment functionality** 1017 + 1018 + ```javascript 1019 + // Add to document_edit.html 1020 + function addComment(paragraphId) { 1021 + const text = prompt('Enter your comment:'); 1022 + if (!text) return; 1023 + 1024 + fetch(`/api/docs/${documentRKey}/comments`, { 1025 + method: 'POST', 1026 + headers: { 'Content-Type': 'application/json' }, 1027 + body: JSON.stringify({ paragraphId, text }) 1028 + }) 1029 + .then(res => res.json()) 1030 + .then(data => { 1031 + renderCommentThread(paragraphId, [{ text, author: getCurrentDID(), createdAt: new Date().toISOString() }]); 1032 + }); 1033 + } 1034 + 1035 + function renderCommentThread(paragraphId, comments) { 1036 + const container = document.getElementById(`comments-${paragraphId}`); 1037 + if (!container) return; 1038 + 1039 + container.innerHTML = comments.map(c => ` 1040 + <div class="comment" style="padding: 8px; margin: 4px 0; background: #fff; border-radius: 4px;"> 1041 + <div class="comment-text">${c.text}</div> 1042 + <div class="comment-meta" style="font-size: 12px; color: #666;">${c.author} - ${new Date(c.createdAt).toLocaleString()}</div> 1043 + </div> 1044 + `).join(''); 1045 + } 1046 + 1047 + // Load comments on page load 1048 + fetch(`/api/docs/${documentRKey}/comments`) 1049 + .then(res => res.json()) 1050 + .then(comments => { 1051 + // Group by paragraphId and render 1052 + const byParagraph = {}; 1053 + comments.forEach(c => { 1054 + if (!byParagraph[c.paragraphId]) byParagraph[c.paragraphId] = []; 1055 + byParagraph[c.paragraphId].push(c); 1056 + }); 1057 + Object.keys(byParagraph).forEach(pid => { 1058 + renderCommentThread(pid, byParagraph[pid]); 1059 + }); 1060 + }); 1061 + ``` 1062 + 1063 + - [ ] **Step 2: Add comment button to each paragraph** 1064 + 1065 + Add click handler to editor that shows comment button on paragraph selection: 1066 + ```javascript 1067 + editor.on('selectionChange', (data) => { 1068 + const selectedNode = data.state.selection.$from.parent; 1069 + if (selectedNode) { 1070 + showCommentButton(selectedNode.attrs.id); 1071 + } 1072 + }); 1073 + 1074 + function showCommentButton(nodeId) { 1075 + // Show floating comment button near selected paragraph 1076 + } 1077 + ``` 1078 + 1079 + - [ ] **Step 3: Add comment CSS** 1080 + 1081 + ```css 1082 + .comment-thread { 1083 + margin-top: 8px; 1084 + padding: 8px; 1085 + background: #f9f9f9; 1086 + border-left: 3px solid #3498db; 1087 + } 1088 + 1089 + .comment-button { 1090 + position: absolute; 1091 + right: 8px; 1092 + padding: 4px 8px; 1093 + background: #3498db; 1094 + color: white; 1095 + border: none; 1096 + border-radius: 4px; 1097 + cursor: pointer; 1098 + } 1099 + ``` 1100 + 1101 + - [ ] **Step 4: Commit** 1102 + 1103 + ```bash 1104 + git add templates/document_edit.html static/css/editor.css 1105 + git commit -m "feat: add comment UI" 1106 + ``` 1107 + 1108 + --- 1109 + 1110 + ## Final Review 1111 + 1112 + After completing all chunks: 1113 + - Run `go build ./...` to verify compilation 1114 + - Verify all handlers are registered in main.go 1115 + - Ensure no conflicts with existing code 1116 + - Test basic flow: create document, generate invite, accept invite, see presence
+943
docs/superpowers/plans/2026-03-13-granular-delta-edits.md
··· 1 + # Granular Delta Edits 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:** Replace full-document WebSocket broadcasts with granular character-level deltas, reducing per-keystroke payload from O(document size) to O(change size). 6 + 7 + **Architecture:** CodeMirror exposes `update.changes` (a `ChangeSet`) directly — iterate it to extract precise `{from, to, insert}` operations. Milkdown only exposes the new markdown string, so we string-diff the previous vs new content using a bundled Myers diff to produce the same operation shape. The server-side `Operation` struct and `OTEngine` already handle granular ops correctly (after the `Insert==""` fix). Add a WebSocket send debounce (50ms) to batch rapid keystrokes. Receivers apply each delta in order using CodeMirror's `ChangeSet` API rather than replacing the whole document. 8 + 9 + **Tech Stack:** Go 1.22, CodeMirror 6 (`ChangeSet`, `ChangeDesc`), vanilla JS Myers diff (bundled inline, no new npm dep), existing `gorilla/websocket`, existing `Operation` / `OTEngine` types. 10 + 11 + --- 12 + 13 + ## Chunk 1: Server — accept and broadcast multiple deltas per message 14 + 15 + The current server receives one `Operation` per `edit` message. Granular edits from a single keystroke may produce multiple operations (e.g. CodeMirror reports each change in a transaction separately). We need the server to accept an array. 16 + 17 + ### Task 1: Update `Operation` and `ClientMessage` to support delta arrays 18 + 19 + **Files:** 20 + - Modify: `internal/collaboration/client.go` 21 + - Modify: `internal/collaboration/hub.go` 22 + - Test: `internal/collaboration/client_test.go` (create) 23 + 24 + The wire format changes from: 25 + ```json 26 + { "type": "edit", "delta": { "from": 5, "to": 10, "insert": "hello" } } 27 + ``` 28 + to: 29 + ```json 30 + { "type": "edit", "deltas": [{ "from": 5, "to": 10, "insert": "hello" }] } 31 + ``` 32 + 33 + `delta` (singular) is kept as a fallback field so existing clients don't break during the transition. 34 + 35 + - [ ] **Step 1: Write failing test for multi-delta parsing** 36 + 37 + Add `internal/collaboration/client_test.go`: 38 + 39 + ```go 40 + package collaboration 41 + 42 + import ( 43 + "encoding/json" 44 + "testing" 45 + ) 46 + 47 + func TestClientMessage_ParseDeltas_Multiple(t *testing.T) { 48 + raw := `{"type":"edit","deltas":[{"from":0,"to":5,"insert":"hello"},{"from":10,"to":10,"insert":"!"}]}` 49 + var msg ClientMessage 50 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 51 + t.Fatalf("unmarshal: %v", err) 52 + } 53 + if len(msg.Deltas) != 2 { 54 + t.Fatalf("expected 2 deltas, got %d", len(msg.Deltas)) 55 + } 56 + if msg.Deltas[0].From != 0 || msg.Deltas[0].To != 5 || msg.Deltas[0].Insert != "hello" { 57 + t.Errorf("delta[0]: %+v", msg.Deltas[0]) 58 + } 59 + if msg.Deltas[1].From != 10 || msg.Deltas[1].Insert != "!" { 60 + t.Errorf("delta[1]: %+v", msg.Deltas[1]) 61 + } 62 + } 63 + 64 + func TestClientMessage_ParseDeltas_SingleFallback(t *testing.T) { 65 + // Old wire format: singular "delta" field — must still work. 66 + raw := `{"type":"edit","delta":{"from":3,"to":7,"insert":"xyz"}}` 67 + var msg ClientMessage 68 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 69 + t.Fatalf("unmarshal: %v", err) 70 + } 71 + ops := msg.Operations() 72 + if len(ops) != 1 { 73 + t.Fatalf("expected 1 op from fallback, got %d", len(ops)) 74 + } 75 + if ops[0].From != 3 || ops[0].To != 7 || ops[0].Insert != "xyz" { 76 + t.Errorf("op: %+v", ops[0]) 77 + } 78 + } 79 + 80 + func TestClientMessage_ParseDeltas_EmptyDeltas(t *testing.T) { 81 + raw := `{"type":"edit","deltas":[]}` 82 + var msg ClientMessage 83 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 84 + t.Fatalf("unmarshal: %v", err) 85 + } 86 + if len(msg.Operations()) != 0 { 87 + t.Errorf("expected 0 ops for empty deltas") 88 + } 89 + } 90 + ``` 91 + 92 + - [ ] **Step 2: Run tests to verify they fail** 93 + 94 + ```bash 95 + go test -v ./internal/collaboration/ -run TestClientMessage 96 + ``` 97 + 98 + Expected: `FAIL` — `msg.Deltas` field doesn't exist, `msg.Operations()` method doesn't exist. 99 + 100 + - [ ] **Step 3: Update `ClientMessage` in `internal/collaboration/client.go`** 101 + 102 + Replace the existing `ClientMessage` struct and add the `Operations()` helper: 103 + 104 + ```go 105 + type ClientMessage struct { 106 + Type string `json:"type"` 107 + RKey string `json:"rkey,omitempty"` 108 + DID string `json:"did,omitempty"` 109 + // Deltas is the new plural field — a single edit message may carry 110 + // multiple operations (e.g. one per CodeMirror ChangeDesc). 111 + Deltas []Operation `json:"deltas,omitempty"` 112 + // Delta is the legacy singular field. Kept for backward compatibility. 113 + Delta json.RawMessage `json:"delta,omitempty"` 114 + Cursor *CursorPos `json:"cursor,omitempty"` 115 + Comment *CommentMsg `json:"comment,omitempty"` 116 + } 117 + 118 + // Operations returns the ops from this message, preferring Deltas over the 119 + // legacy singular Delta field. 120 + func (m *ClientMessage) Operations() []Operation { 121 + if len(m.Deltas) > 0 { 122 + return m.Deltas 123 + } 124 + if len(m.Delta) > 0 { 125 + var op Operation 126 + if err := json.Unmarshal(m.Delta, &op); err == nil { 127 + return []Operation{op} 128 + } 129 + } 130 + return nil 131 + } 132 + ``` 133 + 134 + - [ ] **Step 4: Update the `"edit"` case in `ReadPump` in `internal/collaboration/client.go`** 135 + 136 + Replace: 137 + ```go 138 + case "edit": 139 + var op Operation 140 + if err := json.Unmarshal(msg.Delta, &op); err != nil { 141 + log.Printf("Failed to parse delta from %s: %v", c.DID, err) 142 + continue 143 + } 144 + op.Author = c.DID 145 + room := c.hub.GetRoom(c.roomKey) 146 + if room != nil { 147 + room.ApplyEdit(op, c) 148 + } 149 + ``` 150 + 151 + With: 152 + ```go 153 + case "edit": 154 + ops := msg.Operations() 155 + if len(ops) == 0 { 156 + continue 157 + } 158 + room := c.hub.GetRoom(c.roomKey) 159 + if room == nil { 160 + continue 161 + } 162 + for i := range ops { 163 + ops[i].Author = c.DID 164 + } 165 + room.ApplyEdits(ops, c) 166 + ``` 167 + 168 + - [ ] **Step 5: Add `ApplyEdits` to `Room` in `internal/collaboration/hub.go`** 169 + 170 + Add alongside the existing `ApplyEdit` method: 171 + 172 + ```go 173 + // ApplyEdits applies a sequence of operations in order and broadcasts one 174 + // combined message to all other clients. Each op is applied to the text 175 + // resulting from the previous op, so positions in each op must be relative 176 + // to the document state after all prior ops in the same batch have been 177 + // applied — which is exactly what CodeMirror's iterChanges produces (fromA/toA 178 + // are positions in the pre-change document, already adjusted for prior changes 179 + // within the same transaction by CodeMirror itself). 180 + func (r *Room) ApplyEdits(ops []Operation, sender *Client) { 181 + if len(ops) == 0 { 182 + return 183 + } 184 + 185 + for i := range ops { 186 + r.ot.ApplyWithVersion(ops[i]) 187 + } 188 + 189 + // Include the full document text so receivers can detect and recover from 190 + // divergence without a reconnect. 191 + finalText := r.ot.GetText() 192 + type editsMsg struct { 193 + Type string `json:"type"` 194 + Deltas []Operation `json:"deltas"` 195 + Author string `json:"author"` 196 + Content string `json:"content"` 197 + } 198 + msg := editsMsg{ 199 + Type: "edit", 200 + Deltas: ops, 201 + Author: sender.DID, 202 + Content: finalText, 203 + } 204 + data, err := json.Marshal(msg) 205 + if err != nil { 206 + log.Printf("ApplyEdits: marshal: %v", err) 207 + return 208 + } 209 + r.BroadcastExcept(data, sender) 210 + } 211 + ``` 212 + 213 + Note: `Content` (full text) is included in the broadcast so receivers can sanity-check their state. The client applies deltas directly but falls back to full replacement if its local state diverges. 214 + 215 + - [ ] **Step 6: Run tests to verify they pass** 216 + 217 + ```bash 218 + go test -v -race ./internal/collaboration/ -run TestClientMessage 219 + ``` 220 + 221 + Expected: all 3 `TestClientMessage_*` tests PASS. 222 + 223 + - [ ] **Step 7: Run full suite** 224 + 225 + ```bash 226 + go test -v -race ./internal/collaboration/ 227 + ``` 228 + 229 + Expected: all tests PASS (no regressions). 230 + 231 + - [ ] **Step 8: Commit** 232 + 233 + ```bash 234 + git add internal/collaboration/client.go internal/collaboration/hub.go internal/collaboration/client_test.go 235 + git commit -m "feat: support multi-delta edit messages on server" 236 + ``` 237 + 238 + --- 239 + 240 + ## Chunk 2: Server — OT tests for multi-delta sequences 241 + 242 + Before touching the frontend, verify the server applies multi-op sequences correctly. 243 + 244 + ### Task 2: Tests for `ApplyEdits` on `Room` 245 + 246 + **Files:** 247 + - Modify: `internal/collaboration/hub_test.go` 248 + 249 + - [ ] **Step 1: Write failing tests** 250 + 251 + Add to `internal/collaboration/hub_test.go`: 252 + 253 + ```go 254 + func TestRoom_ApplyEdits_MultipleOpsAppliedInOrder(t *testing.T) { 255 + hub := NewHub() 256 + room := hub.GetOrCreateRoom("doc-multi-ops") 257 + 258 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ops") 259 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi-ops") 260 + room.RegisterClient(alice) 261 + room.RegisterClient(bob) 262 + time.Sleep(100 * time.Millisecond) 263 + drain(alice, 200*time.Millisecond) 264 + drain(bob, 200*time.Millisecond) 265 + 266 + // Two ops: set text to "hello", then append " world" 267 + ops := []Operation{ 268 + {From: 0, To: -1, Insert: "hello"}, 269 + {From: 5, To: 5, Insert: " world"}, 270 + } 271 + room.ApplyEdits(ops, alice) 272 + 273 + bobMsgs := waitForMessages(bob, 1, time.Second) 274 + if len(bobMsgs) == 0 { 275 + t.Fatal("bob: expected edit message") 276 + } 277 + 278 + var msg struct { 279 + Type string `json:"type"` 280 + Deltas []Operation `json:"deltas"` 281 + Content string `json:"content"` 282 + Author string `json:"author"` 283 + } 284 + if err := json.Unmarshal(bobMsgs[0], &msg); err != nil { 285 + t.Fatalf("unmarshal: %v", err) 286 + } 287 + if msg.Type != "edit" { 288 + t.Errorf("type: got %q, want edit", msg.Type) 289 + } 290 + if msg.Content != "hello world" { 291 + t.Errorf("content: got %q, want %q", msg.Content, "hello world") 292 + } 293 + if len(msg.Deltas) != 2 { 294 + t.Errorf("deltas: got %d, want 2", len(msg.Deltas)) 295 + } 296 + if msg.Author != "did:plc:alice" { 297 + t.Errorf("author: got %q", msg.Author) 298 + } 299 + } 300 + 301 + func TestRoom_ApplyEdits_UpdatesOTState(t *testing.T) { 302 + hub := NewHub() 303 + room := hub.GetOrCreateRoom("doc-multi-ot") 304 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ot") 305 + room.RegisterClient(alice) 306 + time.Sleep(100 * time.Millisecond) 307 + drain(alice, 200*time.Millisecond) 308 + 309 + room.ApplyEdits([]Operation{ 310 + {From: 0, To: -1, Insert: "abc"}, 311 + {From: 3, To: 3, Insert: "def"}, 312 + }, alice) 313 + 314 + time.Sleep(50 * time.Millisecond) 315 + if got := room.ot.GetText(); got != "abcdef" { 316 + t.Errorf("OT state: got %q, want %q", got, "abcdef") 317 + } 318 + } 319 + 320 + func TestRoom_ApplyEdits_EmptyOpsIsNoop(t *testing.T) { 321 + hub := NewHub() 322 + room := hub.GetOrCreateRoom("doc-empty-ops") 323 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-empty-ops") 324 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-empty-ops") 325 + room.RegisterClient(alice) 326 + room.RegisterClient(bob) 327 + time.Sleep(100 * time.Millisecond) 328 + drain(alice, 200*time.Millisecond) 329 + drain(bob, 200*time.Millisecond) 330 + 331 + room.ApplyEdits(nil, alice) 332 + 333 + bobMsgs := drain(bob, 300*time.Millisecond) 334 + if len(bobMsgs) > 0 { 335 + t.Errorf("expected no broadcast for empty ops, got %d messages", len(bobMsgs)) 336 + } 337 + } 338 + ``` 339 + 340 + - [ ] **Step 2: Run tests to confirm they pass** 341 + 342 + ```bash 343 + go test -v ./internal/collaboration/ -run TestRoom_ApplyEdits 344 + ``` 345 + 346 + Expected: PASS — `ApplyEdits` was added in Chunk 1 Task 1 Step 5. 347 + 348 + - [ ] **Step 3: Run tests to verify they pass** 349 + 350 + ```bash 351 + go test -v -race ./internal/collaboration/ -run TestRoom_ApplyEdits 352 + ``` 353 + 354 + Expected: all 3 tests PASS. 355 + 356 + - [ ] **Step 4: Run full suite** 357 + 358 + ```bash 359 + go test -v -race ./internal/collaboration/ 360 + ``` 361 + 362 + Expected: all tests PASS. 363 + 364 + - [ ] **Step 5: Commit** 365 + 366 + ```bash 367 + git add internal/collaboration/hub_test.go 368 + git commit -m "test: add multi-delta ApplyEdits tests" 369 + ``` 370 + 371 + --- 372 + 373 + ## Chunk 3: Frontend — CodeMirror granular deltas 374 + 375 + CodeMirror 6's `update.changes` is a `ChangeSet`. Iterating it with `iterChanges` gives `(fromA, toA, fromB, toB, inserted)` — exactly the `{from, to, insert}` we need. 376 + 377 + ### Task 3: Send granular deltas from CodeMirror source mode 378 + 379 + **Files:** 380 + - Modify: `templates/document_edit.html` 381 + 382 + The change is in the `EditorView.updateListener.of(...)` callback and the `sendEdit` / new `sendDeltas` functions. 383 + 384 + - [ ] **Step 1: Add `sendDeltas` function and debounce timer** 385 + 386 + In `document_edit.html`, find the `sendEdit` function (around line 608) and replace it with: 387 + 388 + ```js 389 + // Debounce timer for WebSocket sends (50ms batches rapid keystrokes). 390 + let wsEditTimer = null; 391 + let pendingDeltas = []; 392 + 393 + // Queue a set of deltas and flush after a short debounce. 394 + function queueDeltas(deltas) { 395 + if (!ws || ws.readyState !== WebSocket.OPEN || applyingRemote) return; 396 + pendingDeltas = pendingDeltas.concat(deltas); 397 + clearTimeout(wsEditTimer); 398 + wsEditTimer = setTimeout(flushDeltas, 50); 399 + } 400 + 401 + function flushDeltas() { 402 + if (!ws || ws.readyState !== WebSocket.OPEN || pendingDeltas.length === 0) { 403 + pendingDeltas = []; 404 + return; 405 + } 406 + ws.send(JSON.stringify({ type: 'edit', deltas: pendingDeltas })); 407 + pendingDeltas = []; 408 + } 409 + 410 + // sendEdit is kept for any future callers; Milkdown switches to diffToOps 411 + // in Chunk 4 and no longer calls this directly. 412 + function sendEdit(content) { 413 + if (!ws || ws.readyState !== WebSocket.OPEN || applyingRemote) return; 414 + queueDeltas([{ from: 0, to: -1, insert: content }]); 415 + } 416 + ``` 417 + 418 + - [ ] **Step 2: Update the CodeMirror `updateListener` to use granular deltas** 419 + 420 + Find the `EditorView.updateListener.of(...)` block (around line 180) and replace it: 421 + 422 + ```js 423 + EditorView.updateListener.of((update) => { 424 + if (update.docChanged && currentMode === 'source') { 425 + const content = update.state.doc.toString(); 426 + updatePreview(content); 427 + if (!update.transactions.some(tr => tr.annotation(remoteEditAnnotation))) { 428 + scheduleAutoSave(content); 429 + // Extract granular deltas from the ChangeSet. 430 + const deltas = []; 431 + update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { 432 + deltas.push({ from: fromA, to: toA, insert: inserted.toString() }); 433 + }); 434 + if (deltas.length > 0) { 435 + queueDeltas(deltas); 436 + } 437 + } 438 + } 439 + }), 440 + ``` 441 + 442 + Note: `fromA`/`toA` are positions in the **old** document (before the change), which is what the server's OT engine needs. `fromB`/`toB` are positions in the new document — not needed here. 443 + 444 + - [ ] **Step 3: Update `applyRemoteEdit` to apply deltas when available** 445 + 446 + Find `applyRemoteEdit` (around line 582) and replace: 447 + 448 + ```js 449 + function applyRemoteEdit(msg) { 450 + // msg may be a full-content string (legacy) or an object with deltas. 451 + if (applyingRemote) return; 452 + applyingRemote = true; 453 + try { 454 + // Determine content and deltas from the message. 455 + let content = typeof msg === 'string' ? msg : msg.content; 456 + const deltas = (typeof msg === 'object' && msg.deltas) ? msg.deltas : null; 457 + 458 + if (currentMode === 'source' && cmView) { 459 + if (deltas && deltas.length > 0) { 460 + // Apply each delta as a discrete CodeMirror change. 461 + // Build changes array, adjusting positions for prior changes. 462 + const docLen = cmView.state.doc.length; 463 + const changes = deltas.map(d => ({ 464 + from: Math.min(d.from < 0 ? 0 : d.from, docLen), 465 + to: Math.min(d.to < 0 ? docLen : d.to, docLen), 466 + insert: d.insert || '', 467 + })); 468 + cmView.dispatch({ 469 + changes, 470 + annotations: [remoteEditAnnotation.of(true)], 471 + }); 472 + // Use the full-content echo from the server as a sanity-check. 473 + if (content && cmView.state.doc.toString() !== content) { 474 + // State diverged — fall back to full replacement. 475 + cmView.dispatch({ 476 + changes: { from: 0, to: cmView.state.doc.length, insert: content }, 477 + annotations: [remoteEditAnnotation.of(true)], 478 + }); 479 + } 480 + } else if (content && cmView.state.doc.toString() !== content) { 481 + // Legacy full-replacement path. 482 + cmView.dispatch({ 483 + changes: { from: 0, to: cmView.state.doc.length, insert: content }, 484 + annotations: [remoteEditAnnotation.of(true)], 485 + }); 486 + } 487 + if (content) updatePreview(content); 488 + } else if (currentMode === 'rich' && milkdownEditor && content) { 489 + createMilkdownEditor(content); 490 + } 491 + } finally { 492 + applyingRemote = false; 493 + } 494 + } 495 + ``` 496 + 497 + - [ ] **Step 4: Update `handleWSMessage` to pass the full message object to `applyRemoteEdit`** 498 + 499 + Find `handleWSMessage` (around line 567) and update the `'edit'` and `'sync'` cases: 500 + 501 + ```js 502 + function handleWSMessage(msg) { 503 + switch (msg.type) { 504 + case 'presence': 505 + updatePresence(msg.users || []); 506 + break; 507 + case 'pong': 508 + wsMissedPings = 0; 509 + break; 510 + case 'edit': 511 + applyRemoteEdit(msg); // pass full message object, not just msg.content 512 + break; 513 + case 'sync': 514 + applyRemoteEdit(msg.content); // sync is always full-content 515 + break; 516 + } 517 + } 518 + ``` 519 + 520 + - [ ] **Step 5: Manual smoke test** 521 + 522 + ```bash 523 + # In the collaboration worktree: 524 + make run 525 + ``` 526 + 527 + 1. Open two browser tabs, both logged in as different ATProto users on the same document. 528 + 2. Type in tab A (source mode) — tab B should receive and apply the edit without full-page flicker. 529 + 3. Type simultaneously — verify no echo loop (your own edits don't come back). 530 + 4. Switch tab A to rich mode, type — tab B should still update (via legacy full-replacement path). 531 + 5. Open browser devtools → Network → WS — verify messages are small (just the changed characters) not full document. 532 + 533 + - [ ] **Step 6: Commit** 534 + 535 + ```bash 536 + git add templates/document_edit.html 537 + git commit -m "feat: send granular CodeMirror deltas over WebSocket with 50ms debounce" 538 + ``` 539 + 540 + --- 541 + 542 + ## Chunk 4: Frontend — Milkdown string-diff deltas 543 + 544 + Milkdown only exposes the complete new markdown string. We compute a Myers diff between the previous and current string to produce `{from, to, insert}` operations. 545 + 546 + ### Task 4: Inline Myers diff and wire up Milkdown listener 547 + 548 + **Files:** 549 + - Modify: `templates/document_edit.html` 550 + 551 + The Myers diff algorithm produces the minimal edit script between two strings. We inline a small implementation rather than adding an npm dependency, keeping the bundle unchanged. 552 + 553 + - [ ] **Step 1: Add inline Myers diff function** 554 + 555 + Just before the `queueDeltas` function added in Task 3, insert: 556 + 557 + ```js 558 + /** 559 + * Compute the minimal edit operations to transform `oldStr` into `newStr`. 560 + * Returns an array of {from, to, insert} suitable for the OT engine. 561 + * 562 + * Uses a line-level diff for performance, then falls back to a single 563 + * full-replacement op if the diff produces more than 20 operations 564 + * (pathological case — not worth the complexity). 565 + */ 566 + function diffToOps(oldStr, newStr) { 567 + if (oldStr === newStr) return []; 568 + 569 + const oldLines = oldStr.split('\n'); 570 + const newLines = newStr.split('\n'); 571 + 572 + // Build line-level LCS table (Myers / Wagner-Fischer). 573 + const m = oldLines.length, n = newLines.length; 574 + const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0)); 575 + for (let i = m - 1; i >= 0; i--) { 576 + for (let j = n - 1; j >= 0; j--) { 577 + dp[i][j] = oldLines[i] === newLines[j] 578 + ? dp[i+1][j+1] + 1 579 + : Math.max(dp[i+1][j], dp[i][j+1]); 580 + } 581 + } 582 + 583 + // Trace back to produce diff hunks. 584 + const ops = []; 585 + let i = 0, j = 0; 586 + // Track character offset into oldStr. 587 + let charOffset = 0; 588 + // +1 for the \n separator. The last line has no trailing \n, so its 589 + // length is exact; however the OT engine clamps out-of-range positions, 590 + // so an off-by-one on the final line is safe in practice. 591 + const oldLineLengths = oldLines.map(l => l.length + 1); 592 + 593 + while (i < m || j < n) { 594 + if (i < m && j < n && oldLines[i] === newLines[j]) { 595 + charOffset += oldLineLengths[i]; 596 + i++; j++; 597 + } else if (j < n && (i >= m || dp[i+1][j] <= dp[i][j+1])) { 598 + // Insert newLines[j] 599 + const insertText = newLines[j] + (j < n - 1 ? '\n' : ''); 600 + ops.push({ from: charOffset, to: charOffset, insert: insertText }); 601 + j++; 602 + } else { 603 + // Delete oldLines[i] 604 + const deleteLen = oldLineLengths[i]; 605 + ops.push({ from: charOffset, to: charOffset + deleteLen, insert: '' }); 606 + charOffset += deleteLen; 607 + i++; 608 + } 609 + } 610 + 611 + // Fallback: if diff is too fragmented, send a single full replacement. 612 + if (ops.length > 20) { 613 + return [{ from: 0, to: -1, insert: newStr }]; 614 + } 615 + 616 + return ops; 617 + } 618 + ``` 619 + 620 + - [ ] **Step 2: Update the Milkdown `markdownUpdated` listener to use `diffToOps`** 621 + 622 + Find the Milkdown listener (around line 246): 623 + 624 + ```js 625 + ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 626 + if (markdown !== prevMarkdown && !applyingRemote) { 627 + scheduleAutoSave(markdown); 628 + sendEdit(markdown); 629 + } 630 + }); 631 + ``` 632 + 633 + Replace with: 634 + 635 + ```js 636 + ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 637 + if (markdown !== prevMarkdown && !applyingRemote) { 638 + scheduleAutoSave(markdown); 639 + const ops = diffToOps(prevMarkdown || '', markdown); 640 + if (ops.length > 0) { 641 + queueDeltas(ops); 642 + } 643 + } 644 + }); 645 + ``` 646 + 647 + - [ ] **Step 3: Manual smoke test for rich text mode** 648 + 649 + ```bash 650 + make run 651 + ``` 652 + 653 + 1. Open two browser tabs on the same document, both in **rich** mode. 654 + 2. Type in tab A — tab B should update. 655 + 3. In devtools → Network → WS: verify messages contain `deltas` arrays (not a single large `insert`). 656 + 4. Make a large edit (paste a paragraph) — verify fallback fires (single full-replacement op) rather than 20+ tiny ops. 657 + 658 + - [ ] **Step 4: Commit** 659 + 660 + ```bash 661 + git add templates/document_edit.html 662 + git commit -m "feat: diff-based granular deltas for Milkdown rich text mode" 663 + ``` 664 + 665 + --- 666 + 667 + ## Chunk 5: Cleanup and edge cases 668 + 669 + ### Task 5: Handle pending deltas on WebSocket close 670 + 671 + If the browser tab closes or the WebSocket drops while `pendingDeltas` is non-empty, those changes are lost silently. Flush synchronously on close. 672 + 673 + **Files:** 674 + - Modify: `templates/document_edit.html` 675 + 676 + - [ ] **Step 1: Extract `closeWS` helper and wire it up** 677 + 678 + There is no named `closeWS` function in the current code. `ws.close()` is called in two places: 679 + - `ws.onerror` callback (around line 533) 680 + - Inside `startHeartbeat`'s ping timer when missed pings ≥ 3 (around line 553) 681 + 682 + Replace both call sites with a shared `closeWS` helper. Add the helper alongside `flushDeltas`: 683 + 684 + ```js 685 + function closeWS() { 686 + if (!ws) return; 687 + clearTimeout(wsEditTimer); 688 + flushDeltas(); // send any buffered deltas before closing 689 + ws.close(); 690 + ws = null; 691 + clearInterval(wsPingTimer); 692 + } 693 + ``` 694 + 695 + Then update `ws.onerror`: 696 + ```js 697 + ws.onerror = () => { 698 + closeWS(); 699 + }; 700 + ``` 701 + 702 + And update the heartbeat missed-ping branch: 703 + ```js 704 + if (wsMissedPings >= 3) { 705 + closeWS(); 706 + } 707 + ``` 708 + 709 + Also update `ws.onclose` to call `stopHeartbeat()` only (not `clearInterval` directly, since `closeWS` handles that when called from onerror/heartbeat; onclose fires for all close paths and should remain lightweight): 710 + ```js 711 + ws.onclose = () => { 712 + ws = null; 713 + updatePresence([]); 714 + scheduleReconnect(); 715 + }; 716 + ``` 717 + 718 + - [ ] **Step 2: Flush on page unload** 719 + 720 + Add a `beforeunload` handler at the end of the `{{define "scripts"}}` block, after the `connectWebSocket()` call: 721 + 722 + ```js 723 + window.addEventListener('beforeunload', () => { 724 + clearTimeout(wsEditTimer); 725 + flushDeltas(); 726 + }); 727 + ``` 728 + 729 + - [ ] **Step 3: Commit** 730 + 731 + ```bash 732 + git add templates/document_edit.html 733 + git commit -m "fix: flush pending WS deltas on close and page unload" 734 + ``` 735 + 736 + ### Task 6: OT engine — add `SetText` for room initialisation 737 + 738 + Currently `GetOrCreateRoom` initialises the OT engine with `""`. When a room is first created, the server fetches the document but doesn't seed the engine. If two users join simultaneously, the first edit from user A resets the OT state, which is fine for the current full-replacement protocol but wrong for granular deltas (user B's deltas will have stale offsets). 739 + 740 + **Files:** 741 + - Modify: `internal/collaboration/ot.go` 742 + - Modify: `internal/collaboration/hub.go` 743 + - Modify: `internal/handler/handler.go` 744 + - Test: `internal/collaboration/ot_test.go` 745 + 746 + - [ ] **Step 1: Write failing test for `SetText`** 747 + 748 + Add to `internal/collaboration/ot_test.go`: 749 + 750 + ```go 751 + func TestOTEngine_SetText(t *testing.T) { 752 + ot := NewOTEngine("") 753 + ot.SetText("initial content") 754 + if ot.GetText() != "initial content" { 755 + t.Errorf("SetText: got %q, want %q", ot.GetText(), "initial content") 756 + } 757 + // SetText should not change the version counter. 758 + if ot.GetVersion() != 0 { 759 + t.Errorf("SetText should not increment version: got %d", ot.GetVersion()) 760 + } 761 + } 762 + ``` 763 + 764 + - [ ] **Step 2: Run test to verify it fails** 765 + 766 + ```bash 767 + go test -v ./internal/collaboration/ -run TestOTEngine_SetText 768 + ``` 769 + 770 + Expected: FAIL — `SetText` undefined. 771 + 772 + - [ ] **Step 3: Add `SetText` to `internal/collaboration/ot.go`** 773 + 774 + ```go 775 + // SetText replaces the canonical document text without incrementing the version. 776 + // Call this once when a room is first created, before any edits are applied. 777 + func (ot *OTEngine) SetText(text string) { 778 + ot.mu.Lock() 779 + defer ot.mu.Unlock() 780 + ot.documentText = text 781 + } 782 + ``` 783 + 784 + - [ ] **Step 4: Run test to verify it passes** 785 + 786 + ```bash 787 + go test -v -race ./internal/collaboration/ -run TestOTEngine_SetText 788 + ``` 789 + 790 + Expected: PASS. 791 + 792 + - [ ] **Step 5: Seed the OT engine in `CollaboratorWebSocket` handler** 793 + 794 + In `internal/handler/handler.go`, find `CollaboratorWebSocket` (search for `GetOrCreateRoom`). After fetching the document and before registering the client, seed the room's OT engine if it's a new room: 795 + 796 + ```go 797 + room := h.CollaborationHub.GetOrCreateRoom(rKey) 798 + // Seed OT engine with the current document text on first join. 799 + // GetOrCreateRoom returns existing rooms unchanged — SetText is idempotent 800 + // only if called before the first edit, which is guaranteed here because 801 + // we check IsNewRoom() before seeding. 802 + if room.IsNewRoom() { 803 + var initialText string 804 + if doc.Content != nil { 805 + initialText = doc.Content.Text.RawMarkdown 806 + } 807 + room.SeedText(initialText) 808 + } 809 + ``` 810 + 811 + This requires adding `IsNewRoom()` and `SeedText()` to `Room`. 812 + 813 + - [ ] **Step 6: Add `IsNewRoom` and `SeedText` to `Room` in `internal/collaboration/hub.go`** 814 + 815 + Add a `seeded` field to `Room`: 816 + 817 + ```go 818 + type Room struct { 819 + documentRKey string 820 + clients map[*Client]bool 821 + broadcast chan *broadcastMsg 822 + register chan *Client 823 + unregister chan *Client 824 + mu sync.RWMutex 825 + ot *OTEngine 826 + seeded bool // true after SeedText has been called 827 + } 828 + ``` 829 + 830 + Add methods: 831 + 832 + ```go 833 + // IsNewRoom returns true if SeedText has not yet been called on this room. 834 + func (r *Room) IsNewRoom() bool { 835 + r.mu.RLock() 836 + defer r.mu.RUnlock() 837 + return !r.seeded 838 + } 839 + 840 + // SeedText sets the initial document text for the OT engine. 841 + // Safe to call only before the first edit is applied. 842 + func (r *Room) SeedText(text string) { 843 + r.mu.Lock() 844 + defer r.mu.Unlock() 845 + if !r.seeded { 846 + r.ot.SetText(text) 847 + r.seeded = true 848 + } 849 + } 850 + ``` 851 + 852 + - [ ] **Step 7: Write test for `SeedText`** 853 + 854 + Add to `internal/collaboration/hub_test.go`: 855 + 856 + ```go 857 + func TestRoom_SeedText_SetsInitialOTState(t *testing.T) { 858 + hub := NewHub() 859 + room := hub.GetOrCreateRoom("doc-seed") 860 + if !room.IsNewRoom() { 861 + t.Fatal("new room should report IsNewRoom=true") 862 + } 863 + room.SeedText("initial document content") 864 + if room.IsNewRoom() { 865 + t.Error("IsNewRoom should be false after seeding") 866 + } 867 + if got := room.ot.GetText(); got != "initial document content" { 868 + t.Errorf("OT text after seed: got %q, want %q", got, "initial document content") 869 + } 870 + } 871 + 872 + func TestRoom_SeedText_IdempotentAfterFirstCall(t *testing.T) { 873 + hub := NewHub() 874 + room := hub.GetOrCreateRoom("doc-seed-idem") 875 + room.SeedText("first") 876 + room.SeedText("second") // should be ignored 877 + if got := room.ot.GetText(); got != "first" { 878 + t.Errorf("second SeedText should be ignored: got %q, want %q", got, "first") 879 + } 880 + } 881 + ``` 882 + 883 + - [ ] **Step 8: Run all tests** 884 + 885 + ```bash 886 + go test -v -race ./internal/collaboration/ 887 + ``` 888 + 889 + Expected: all tests PASS. 890 + 891 + - [ ] **Step 9: Commit** 892 + 893 + ```bash 894 + git add internal/collaboration/ot.go internal/collaboration/hub.go \ 895 + internal/collaboration/ot_test.go internal/collaboration/hub_test.go \ 896 + internal/handler/handler.go 897 + git commit -m "feat: seed OT engine with document text on room creation" 898 + ``` 899 + 900 + --- 901 + 902 + ## Chunk 6: Final verification 903 + 904 + - [ ] **Run full test suite with race detector** 905 + 906 + ```bash 907 + go test -v -race ./... 908 + ``` 909 + 910 + Expected: all tests PASS, no race conditions. 911 + 912 + - [ ] **Build** 913 + 914 + ```bash 915 + make build 916 + ``` 917 + 918 + Expected: builds cleanly, no warnings. 919 + 920 + - [ ] **Lint** 921 + 922 + ```bash 923 + make lint 924 + ``` 925 + 926 + Expected: no lint errors. 927 + 928 + - [ ] **End-to-end smoke test** 929 + 930 + ```bash 931 + make run 932 + ``` 933 + 934 + 1. Two users, same document, source mode: type rapidly — verify small WS messages (devtools → Network → WS frames). 935 + 2. Two users, same document, rich mode: type — verify updates propagate correctly. 936 + 3. Disconnect one user mid-edit — verify reconnect gets `sync` message with correct state. 937 + 4. Empty the document entirely — verify the other user sees an empty document (tests the `Insert=""` deletion fix). 938 + 939 + - [ ] **Commit any fixups, then push** 940 + 941 + ```bash 942 + git push origin feature/collaboration 943 + ```
+4 -3
fly.toml
··· 1 - app = "markdownhub" 1 + app = "markdownhub-staging" 2 2 primary_region = "ewr" 3 3 4 4 [build] ··· 6 6 [env] 7 7 PORT = "8080" 8 8 DB_PATH = "/data/markdownhub.db" 9 - DIFFDOWN_BASE_URL = "https://diffdown.com" 10 - COMPOSED_BASE_URL = "https://diffdown.com" 9 + BASE_URL = "https://staging.diffdown.com" 10 + DIFFDOWN_BASE_URL = "https://staging.diffdown.com" 11 + 11 12 [http_service] 12 13 internal_port = 8080 13 14 force_https = true
+1
go.mod
··· 5 5 require ( 6 6 github.com/golang-jwt/jwt/v5 v5.3.1 7 7 github.com/gorilla/sessions v1.2.2 8 + github.com/gorilla/websocket v1.5.3 8 9 github.com/mattn/go-sqlite3 v1.14.22 9 10 github.com/oklog/ulid/v2 v2.1.0 10 11 github.com/yuin/goldmark v1.7.1
+2
go.sum
··· 6 6 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 7 7 github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 8 8 github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 9 + github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 10 + github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 9 11 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 10 12 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 11 13 github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
+36 -12
internal/atproto/xrpc/client.go
··· 16 16 "github.com/limeleaf/diffdown/internal/model" 17 17 ) 18 18 19 - const collectionComment = "com.diffdown.comment" 20 - 21 19 type Client struct { 22 20 db *db.DB 23 21 userID string ··· 294 292 return nil 295 293 } 296 294 295 + const collectionDocument = "com.diffdown.document" 296 + 297 + // GetDocument fetches a document by its rkey. 298 + func (c *Client) GetDocument(rkey string) (*model.Document, error) { 299 + value, _, err := c.GetRecord(c.session.DID, collectionDocument, rkey) 300 + if err != nil { 301 + return nil, err 302 + } 303 + 304 + var doc model.Document 305 + if err := json.Unmarshal(value, &doc); err != nil { 306 + return nil, fmt.Errorf("unmarshal document: %w", err) 307 + } 308 + doc.RKey = rkey 309 + return &doc, nil 310 + } 311 + 312 + // PutDocument creates or updates a document. 313 + func (c *Client) PutDocument(rkey string, doc *model.Document) (string, string, error) { 314 + return c.PutRecord(collectionDocument, rkey, doc) 315 + } 316 + 317 + const collectionComment = "com.diffdown.comment" 318 + 297 319 // CreateComment creates a new comment record. 298 320 func (c *Client) CreateComment(comment *model.Comment) (string, error) { 299 - now := time.Now().UTC().Format(time.RFC3339) 300 321 record := map[string]interface{}{ 301 322 "$type": "com.diffdown.comment", 302 - "documentUri": comment.DocumentURI, 323 + "documentURI": comment.DocumentURI, 303 324 "paragraphId": comment.ParagraphID, 304 325 "text": comment.Text, 305 - "authorDid": comment.AuthorDID, 306 - "createdAt": now, 326 + "author": comment.Author, 307 327 } 308 - 309 328 uri, _, err := c.CreateRecord(collectionComment, record) 310 329 if err != nil { 311 330 return "", err ··· 313 332 return uri, nil 314 333 } 315 334 316 - // ListComments lists all comments for a document. 317 - func (c *Client) ListComments(rkey string) ([]model.Comment, error) { 335 + // ListComments fetches all comments for a document. 336 + func (c *Client) ListComments(documentRKey string) ([]model.Comment, error) { 318 337 records, _, err := c.ListRecords(c.session.DID, collectionComment, 100, "") 319 338 if err != nil { 320 339 return nil, err 321 340 } 322 341 323 342 var comments []model.Comment 324 - for _, rec := range records { 343 + for _, r := range records { 325 344 var comment model.Comment 326 - if err := json.Unmarshal(rec.Value, &comment); err != nil { 345 + if err := json.Unmarshal(r.Value, &comment); err != nil { 346 + continue 347 + } 348 + // Filter by documentRKey: at://{did}/com.diffdown.document/{rkey} 349 + expectedSuffix := "/com.diffdown.document/" + documentRKey 350 + if documentRKey != "" && !strings.HasSuffix(comment.DocumentURI, expectedSuffix) { 327 351 continue 328 352 } 329 - comment.URI = rec.URI 353 + comment.URI = r.URI 330 354 comments = append(comments, comment) 331 355 } 332 356 return comments, nil
+137
internal/collaboration/client.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + 7 + "github.com/gorilla/websocket" 8 + ) 9 + 10 + type Client struct { 11 + hub *Hub 12 + conn *websocket.Conn 13 + send chan []byte 14 + DID string 15 + Name string 16 + Color string 17 + roomKey string 18 + } 19 + 20 + type ClientMessage struct { 21 + Type string `json:"type"` 22 + RKey string `json:"rkey,omitempty"` 23 + DID string `json:"did,omitempty"` 24 + // Deltas is the new plural field — a single edit message may carry 25 + // multiple operations (e.g. one per CodeMirror ChangeDesc). 26 + Deltas []Operation `json:"deltas,omitempty"` 27 + // Delta is the legacy singular field. Kept for backward compatibility. 28 + Delta json.RawMessage `json:"delta,omitempty"` 29 + Cursor *CursorPos `json:"cursor,omitempty"` 30 + Comment *CommentMsg `json:"comment,omitempty"` 31 + } 32 + 33 + // Operations returns the ops from this message, preferring Deltas over the 34 + // legacy singular Delta field. 35 + func (m *ClientMessage) Operations() []Operation { 36 + if len(m.Deltas) > 0 { 37 + return m.Deltas 38 + } 39 + if len(m.Delta) > 0 { 40 + var op Operation 41 + if err := json.Unmarshal(m.Delta, &op); err == nil { 42 + return []Operation{op} 43 + } 44 + } 45 + return nil 46 + } 47 + 48 + type CursorPos struct { 49 + Position int `json:"position"` 50 + SelectionEnd int `json:"selectionEnd"` 51 + } 52 + 53 + type CommentMsg struct { 54 + ParagraphID string `json:"paragraphId"` 55 + Text string `json:"text"` 56 + } 57 + 58 + type PresenceUser struct { 59 + DID string `json:"did"` 60 + Name string `json:"name"` 61 + Color string `json:"color"` 62 + } 63 + 64 + type PresenceMessage struct { 65 + Type string `json:"type"` 66 + Users []PresenceUser `json:"users"` 67 + } 68 + 69 + func NewClient(hub *Hub, conn *websocket.Conn, did, name, color, roomKey string) *Client { 70 + return &Client{ 71 + hub: hub, 72 + conn: conn, 73 + send: make(chan []byte, 256), 74 + DID: did, 75 + Name: name, 76 + Color: color, 77 + roomKey: roomKey, 78 + } 79 + } 80 + 81 + func (c *Client) ReadPump() { 82 + defer func() { 83 + if room := c.hub.GetRoom(c.roomKey); room != nil { 84 + room.unregister <- c 85 + } 86 + c.conn.Close() 87 + }() 88 + for { 89 + _, message, err := c.conn.ReadMessage() 90 + if err != nil { 91 + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 92 + log.Printf("WebSocket error: %v", err) 93 + } 94 + break 95 + } 96 + var msg ClientMessage 97 + if err := json.Unmarshal(message, &msg); err != nil { 98 + log.Printf("Failed to parse message from %s: %v", c.DID, err) 99 + continue 100 + } 101 + 102 + switch msg.Type { 103 + case "edit": 104 + ops := msg.Operations() 105 + if len(ops) == 0 { 106 + continue 107 + } 108 + room := c.hub.GetRoom(c.roomKey) 109 + if room == nil { 110 + continue 111 + } 112 + for i := range ops { 113 + ops[i].Author = c.DID 114 + } 115 + room.ApplyEdits(ops, c) 116 + case "ping": 117 + pong, _ := json.Marshal(map[string]string{"type": "pong"}) 118 + c.send <- pong 119 + default: 120 + log.Printf("Unhandled message type=%s from %s", msg.Type, c.DID) 121 + } 122 + } 123 + } 124 + 125 + func (c *Client) WritePump() { 126 + defer c.conn.Close() 127 + for { 128 + message, ok := <-c.send 129 + if !ok { 130 + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 131 + return 132 + } 133 + if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { 134 + return 135 + } 136 + } 137 + }
+50
internal/collaboration/client_test.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + ) 7 + 8 + func TestClientMessage_ParseDeltas_Multiple(t *testing.T) { 9 + raw := `{"type":"edit","deltas":[{"from":0,"to":5,"insert":"hello"},{"from":10,"to":10,"insert":"!"}]}` 10 + var msg ClientMessage 11 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 12 + t.Fatalf("unmarshal: %v", err) 13 + } 14 + if len(msg.Deltas) != 2 { 15 + t.Fatalf("expected 2 deltas, got %d", len(msg.Deltas)) 16 + } 17 + if msg.Deltas[0].From != 0 || msg.Deltas[0].To != 5 || msg.Deltas[0].Insert != "hello" { 18 + t.Errorf("delta[0]: %+v", msg.Deltas[0]) 19 + } 20 + if msg.Deltas[1].From != 10 || msg.Deltas[1].Insert != "!" { 21 + t.Errorf("delta[1]: %+v", msg.Deltas[1]) 22 + } 23 + } 24 + 25 + func TestClientMessage_ParseDeltas_SingleFallback(t *testing.T) { 26 + // Old wire format: singular "delta" field — must still work. 27 + raw := `{"type":"edit","delta":{"from":3,"to":7,"insert":"xyz"}}` 28 + var msg ClientMessage 29 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 30 + t.Fatalf("unmarshal: %v", err) 31 + } 32 + ops := msg.Operations() 33 + if len(ops) != 1 { 34 + t.Fatalf("expected 1 op from fallback, got %d", len(ops)) 35 + } 36 + if ops[0].From != 3 || ops[0].To != 7 || ops[0].Insert != "xyz" { 37 + t.Errorf("op: %+v", ops[0]) 38 + } 39 + } 40 + 41 + func TestClientMessage_ParseDeltas_EmptyDeltas(t *testing.T) { 42 + raw := `{"type":"edit","deltas":[]}` 43 + var msg ClientMessage 44 + if err := json.Unmarshal([]byte(raw), &msg); err != nil { 45 + t.Fatalf("unmarshal: %v", err) 46 + } 47 + if len(msg.Operations()) != 0 { 48 + t.Errorf("expected 0 ops for empty deltas") 49 + } 50 + }
+198
internal/collaboration/hub.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "sync" 7 + ) 8 + 9 + type Hub struct { 10 + rooms map[string]*Room 11 + mu sync.RWMutex 12 + } 13 + 14 + type Room struct { 15 + documentRKey string 16 + clients map[*Client]bool 17 + broadcast chan *broadcastMsg 18 + register chan *Client 19 + unregister chan *Client 20 + mu sync.RWMutex 21 + ot *OTEngine 22 + seeded bool // true after SeedText has been called 23 + } 24 + 25 + // broadcastMsg carries a message and an optional sender to exclude. 26 + type broadcastMsg struct { 27 + data []byte 28 + except *Client // nil = send to all 29 + } 30 + 31 + func NewHub() *Hub { 32 + return &Hub{ 33 + rooms: make(map[string]*Room), 34 + } 35 + } 36 + 37 + func (h *Hub) GetOrCreateRoom(rkey string) *Room { 38 + h.mu.Lock() 39 + defer h.mu.Unlock() 40 + if room, exists := h.rooms[rkey]; exists { 41 + return room 42 + } 43 + room := &Room{ 44 + documentRKey: rkey, 45 + clients: make(map[*Client]bool), 46 + broadcast: make(chan *broadcastMsg, 256), 47 + register: make(chan *Client), 48 + unregister: make(chan *Client), 49 + ot: NewOTEngine(""), 50 + } 51 + h.rooms[rkey] = room 52 + go room.run() 53 + return room 54 + } 55 + 56 + func (h *Hub) GetRoom(rkey string) *Room { 57 + h.mu.RLock() 58 + defer h.mu.RUnlock() 59 + return h.rooms[rkey] 60 + } 61 + 62 + func (r *Room) run() { 63 + for { 64 + select { 65 + case client := <-r.register: 66 + r.mu.Lock() 67 + r.clients[client] = true 68 + r.mu.Unlock() 69 + r.broadcastPresence() 70 + case client := <-r.unregister: 71 + r.mu.Lock() 72 + if _, ok := r.clients[client]; ok { 73 + delete(r.clients, client) 74 + close(client.send) 75 + } 76 + r.mu.Unlock() 77 + r.broadcastPresence() 78 + case msg := <-r.broadcast: 79 + r.mu.RLock() 80 + for client := range r.clients { 81 + if client == msg.except { 82 + continue 83 + } 84 + select { 85 + case client.send <- msg.data: 86 + default: 87 + close(client.send) 88 + delete(r.clients, client) 89 + } 90 + } 91 + r.mu.RUnlock() 92 + } 93 + } 94 + } 95 + 96 + // Broadcast sends to all clients in the room. 97 + func (r *Room) Broadcast(data []byte) { 98 + r.broadcast <- &broadcastMsg{data: data} 99 + } 100 + 101 + // BroadcastExcept sends to all clients except the given sender. 102 + func (r *Room) BroadcastExcept(data []byte, except *Client) { 103 + r.broadcast <- &broadcastMsg{data: data, except: except} 104 + } 105 + 106 + // ApplyEdits applies a sequence of operations in order and broadcasts one 107 + // combined message to all other clients. Each op is applied to the text 108 + // resulting from the previous op, so positions in each op must be relative 109 + // to the document state after all prior ops in the same batch have been 110 + // applied — which is exactly what CodeMirror's iterChanges produces (fromA/toA 111 + // are positions in the pre-change document, already adjusted for prior changes 112 + // within the same transaction by CodeMirror itself). 113 + func (r *Room) ApplyEdits(ops []Operation, sender *Client) { 114 + if len(ops) == 0 { 115 + return 116 + } 117 + 118 + // Capture the text returned by the final ApplyWithVersion so we don't 119 + // race against another goroutine calling GetText() separately. 120 + var finalText string 121 + for i := range ops { 122 + finalText, _ = r.ot.ApplyWithVersion(ops[i]) 123 + } 124 + 125 + // Include the full document text so receivers can detect and recover from 126 + // divergence without a reconnect. 127 + type editsMsg struct { 128 + Type string `json:"type"` 129 + Deltas []Operation `json:"deltas"` 130 + Author string `json:"author"` 131 + Content string `json:"content"` 132 + } 133 + msg := editsMsg{ 134 + Type: "edit", 135 + Deltas: ops, 136 + Author: sender.DID, 137 + Content: finalText, 138 + } 139 + data, err := json.Marshal(msg) 140 + if err != nil { 141 + log.Printf("ApplyEdits: marshal: %v", err) 142 + return 143 + } 144 + r.BroadcastExcept(data, sender) 145 + } 146 + 147 + func (r *Room) RegisterClient(client *Client) { 148 + r.register <- client 149 + } 150 + 151 + func (r *Room) UnregisterClient(client *Client) { 152 + r.unregister <- client 153 + } 154 + 155 + func (r *Room) GetPresence() []PresenceUser { 156 + r.mu.RLock() 157 + defer r.mu.RUnlock() 158 + users := make([]PresenceUser, 0, len(r.clients)) 159 + for client := range r.clients { 160 + users = append(users, PresenceUser{ 161 + DID: client.DID, 162 + Name: client.Name, 163 + Color: client.Color, 164 + }) 165 + } 166 + return users 167 + } 168 + 169 + func (r *Room) broadcastPresence() { 170 + presence := PresenceMessage{ 171 + Type: "presence", 172 + Users: r.GetPresence(), 173 + } 174 + data, err := json.Marshal(presence) 175 + if err != nil { 176 + log.Printf("broadcastPresence: marshal failed: %v", err) 177 + return 178 + } 179 + r.Broadcast(data) 180 + } 181 + 182 + // IsNewRoom returns true if SeedText has not yet been called on this room. 183 + func (r *Room) IsNewRoom() bool { 184 + r.mu.RLock() 185 + defer r.mu.RUnlock() 186 + return !r.seeded 187 + } 188 + 189 + // SeedText sets the initial document text for the OT engine. 190 + // Idempotent — only the first call has effect. 191 + func (r *Room) SeedText(text string) { 192 + r.mu.Lock() 193 + defer r.mu.Unlock() 194 + if !r.seeded { 195 + r.ot.SetText(text) 196 + r.seeded = true 197 + } 198 + }
+438
internal/collaboration/hub_test.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + // stubClient creates a Client with a real send channel but no WebSocket conn. 10 + // It is usable for hub/room tests that don't exercise the network layer. 11 + func stubClient(hub *Hub, did, name, color, roomKey string) *Client { 12 + return &Client{ 13 + hub: hub, 14 + conn: nil, // not used in hub logic 15 + send: make(chan []byte, 256), 16 + DID: did, 17 + Name: name, 18 + Color: color, 19 + roomKey: roomKey, 20 + } 21 + } 22 + 23 + // drain reads all messages from a client's send channel within timeout. 24 + func drain(c *Client, timeout time.Duration) [][]byte { 25 + deadline := time.After(timeout) 26 + var msgs [][]byte 27 + for { 28 + select { 29 + case msg, ok := <-c.send: 30 + if !ok { 31 + return msgs 32 + } 33 + msgs = append(msgs, msg) 34 + case <-deadline: 35 + return msgs 36 + } 37 + } 38 + } 39 + 40 + // waitForMessages blocks until n messages arrive on c.send or timeout. 41 + func waitForMessages(c *Client, n int, timeout time.Duration) [][]byte { 42 + deadline := time.After(timeout) 43 + var msgs [][]byte 44 + for len(msgs) < n { 45 + select { 46 + case msg, ok := <-c.send: 47 + if !ok { 48 + return msgs 49 + } 50 + msgs = append(msgs, msg) 51 + case <-deadline: 52 + return msgs 53 + } 54 + } 55 + return msgs 56 + } 57 + 58 + // --- Hub tests --- 59 + 60 + func TestHub_GetOrCreateRoom_CreatesNew(t *testing.T) { 61 + hub := NewHub() 62 + room := hub.GetOrCreateRoom("doc1") 63 + if room == nil { 64 + t.Fatal("expected non-nil room") 65 + } 66 + } 67 + 68 + func TestHub_GetOrCreateRoom_ReturnsSame(t *testing.T) { 69 + hub := NewHub() 70 + r1 := hub.GetOrCreateRoom("doc1") 71 + r2 := hub.GetOrCreateRoom("doc1") 72 + if r1 != r2 { 73 + t.Error("expected same room instance for same rkey") 74 + } 75 + } 76 + 77 + func TestHub_GetOrCreateRoom_DifferentRooms(t *testing.T) { 78 + hub := NewHub() 79 + r1 := hub.GetOrCreateRoom("doc1") 80 + r2 := hub.GetOrCreateRoom("doc2") 81 + if r1 == r2 { 82 + t.Error("expected different rooms for different rkeys") 83 + } 84 + } 85 + 86 + func TestHub_GetRoom_NilForUnknown(t *testing.T) { 87 + hub := NewHub() 88 + if hub.GetRoom("nonexistent") != nil { 89 + t.Error("expected nil for unknown room") 90 + } 91 + } 92 + 93 + func TestHub_GetRoom_AfterCreate(t *testing.T) { 94 + hub := NewHub() 95 + hub.GetOrCreateRoom("doc1") 96 + if hub.GetRoom("doc1") == nil { 97 + t.Error("expected room to be retrievable after creation") 98 + } 99 + } 100 + 101 + // --- Room presence tests --- 102 + 103 + func TestRoom_RegisterClient_AppearsInPresence(t *testing.T) { 104 + hub := NewHub() 105 + room := hub.GetOrCreateRoom("doc-presence") 106 + c := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-presence") 107 + 108 + room.RegisterClient(c) 109 + 110 + // Wait for presence broadcast (register → broadcastPresence) 111 + msgs := waitForMessages(c, 1, 2*time.Second) 112 + if len(msgs) == 0 { 113 + t.Fatal("expected presence message after register, got none") 114 + } 115 + 116 + var pres PresenceMessage 117 + if err := json.Unmarshal(msgs[0], &pres); err != nil { 118 + t.Fatalf("unmarshal presence: %v", err) 119 + } 120 + if pres.Type != "presence" { 121 + t.Errorf("expected type=presence, got %q", pres.Type) 122 + } 123 + if len(pres.Users) != 1 { 124 + t.Fatalf("expected 1 user in presence, got %d", len(pres.Users)) 125 + } 126 + if pres.Users[0].DID != "did:plc:alice" { 127 + t.Errorf("expected DID did:plc:alice, got %q", pres.Users[0].DID) 128 + } 129 + } 130 + 131 + func TestRoom_MultipleClients_PresenceContainsAll(t *testing.T) { 132 + hub := NewHub() 133 + room := hub.GetOrCreateRoom("doc-multi") 134 + 135 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi") 136 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi") 137 + 138 + room.RegisterClient(alice) 139 + // Drain alice's initial presence (just herself) 140 + waitForMessages(alice, 1, time.Second) 141 + 142 + room.RegisterClient(bob) 143 + // Both get a new presence broadcast; wait for 1 more on each 144 + aliceMsgs := waitForMessages(alice, 1, 2*time.Second) 145 + bobMsgs := waitForMessages(bob, 1, 2*time.Second) 146 + 147 + checkPresenceCount := func(name string, msgs [][]byte, want int) { 148 + t.Helper() 149 + if len(msgs) == 0 { 150 + t.Fatalf("%s: expected presence message, got none", name) 151 + } 152 + var pres PresenceMessage 153 + if err := json.Unmarshal(msgs[len(msgs)-1], &pres); err != nil { 154 + t.Fatalf("%s: unmarshal: %v", name, err) 155 + } 156 + if len(pres.Users) != want { 157 + t.Errorf("%s: expected %d users in presence, got %d", name, want, len(pres.Users)) 158 + } 159 + } 160 + 161 + checkPresenceCount("alice", aliceMsgs, 2) 162 + checkPresenceCount("bob", bobMsgs, 2) 163 + } 164 + 165 + func TestRoom_UnregisterClient_RemovedFromPresence(t *testing.T) { 166 + hub := NewHub() 167 + room := hub.GetOrCreateRoom("doc-unreg") 168 + 169 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-unreg") 170 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-unreg") 171 + 172 + room.RegisterClient(alice) 173 + room.RegisterClient(bob) 174 + // Drain initial presence messages 175 + time.Sleep(100 * time.Millisecond) 176 + drain(alice, 200*time.Millisecond) 177 + drain(bob, 200*time.Millisecond) 178 + 179 + // Now unregister alice 180 + room.UnregisterClient(alice) 181 + 182 + // Bob should receive a presence update with 1 user 183 + msgs := waitForMessages(bob, 1, 2*time.Second) 184 + if len(msgs) == 0 { 185 + t.Fatal("expected presence update after unregister, got none") 186 + } 187 + var pres PresenceMessage 188 + if err := json.Unmarshal(msgs[len(msgs)-1], &pres); err != nil { 189 + t.Fatalf("unmarshal: %v", err) 190 + } 191 + if len(pres.Users) != 1 { 192 + t.Errorf("expected 1 user after alice unregisters, got %d", len(pres.Users)) 193 + } 194 + if pres.Users[0].DID != "did:plc:bob" { 195 + t.Errorf("expected bob to remain, got %q", pres.Users[0].DID) 196 + } 197 + } 198 + 199 + // --- Room broadcast tests --- 200 + 201 + func TestRoom_Broadcast_SendsToAll(t *testing.T) { 202 + hub := NewHub() 203 + room := hub.GetOrCreateRoom("doc-broadcast") 204 + 205 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-broadcast") 206 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-broadcast") 207 + 208 + room.RegisterClient(alice) 209 + room.RegisterClient(bob) 210 + // Drain presence messages 211 + time.Sleep(100 * time.Millisecond) 212 + drain(alice, 200*time.Millisecond) 213 + drain(bob, 200*time.Millisecond) 214 + 215 + room.Broadcast([]byte(`{"type":"ping"}`)) 216 + 217 + aliceMsgs := waitForMessages(alice, 1, time.Second) 218 + bobMsgs := waitForMessages(bob, 1, time.Second) 219 + 220 + if len(aliceMsgs) == 0 { 221 + t.Error("alice: expected broadcast message") 222 + } 223 + if len(bobMsgs) == 0 { 224 + t.Error("bob: expected broadcast message") 225 + } 226 + } 227 + 228 + func TestRoom_BroadcastExcept_SkipsSender(t *testing.T) { 229 + hub := NewHub() 230 + room := hub.GetOrCreateRoom("doc-except") 231 + 232 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-except") 233 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-except") 234 + 235 + room.RegisterClient(alice) 236 + room.RegisterClient(bob) 237 + time.Sleep(100 * time.Millisecond) 238 + drain(alice, 200*time.Millisecond) 239 + drain(bob, 200*time.Millisecond) 240 + 241 + room.BroadcastExcept([]byte(`{"type":"edit"}`), alice) 242 + 243 + // Bob should receive it; alice should not 244 + bobMsgs := waitForMessages(bob, 1, time.Second) 245 + aliceMsgs := drain(alice, 300*time.Millisecond) 246 + 247 + if len(bobMsgs) == 0 { 248 + t.Error("bob: expected broadcast message") 249 + } 250 + if len(aliceMsgs) > 0 { 251 + t.Errorf("alice: should not receive her own broadcast, got %d messages", len(aliceMsgs)) 252 + } 253 + } 254 + 255 + // --- Room ApplyEdits tests --- 256 + 257 + func TestRoom_ApplyEdit_BroadcastsToOthers(t *testing.T) { 258 + hub := NewHub() 259 + room := hub.GetOrCreateRoom("doc-edit") 260 + 261 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-edit") 262 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-edit") 263 + 264 + room.RegisterClient(alice) 265 + room.RegisterClient(bob) 266 + time.Sleep(100 * time.Millisecond) 267 + drain(alice, 200*time.Millisecond) 268 + drain(bob, 200*time.Millisecond) 269 + 270 + room.ApplyEdits([]Operation{{From: 0, To: -1, Insert: "hello world"}}, alice) 271 + 272 + // Bob should receive the edit message; alice should not 273 + bobMsgs := waitForMessages(bob, 1, time.Second) 274 + aliceMsgs := drain(alice, 300*time.Millisecond) 275 + 276 + if len(bobMsgs) == 0 { 277 + t.Fatal("bob: expected edit message, got none") 278 + } 279 + if len(aliceMsgs) > 0 { 280 + t.Errorf("alice: should not receive her own edit, got %d messages", len(aliceMsgs)) 281 + } 282 + 283 + var msg map[string]interface{} 284 + if err := json.Unmarshal(bobMsgs[0], &msg); err != nil { 285 + t.Fatalf("unmarshal edit msg: %v", err) 286 + } 287 + if msg["type"] != "edit" { 288 + t.Errorf("expected type=edit, got %q", msg["type"]) 289 + } 290 + if msg["author"] != "did:plc:alice" { 291 + t.Errorf("expected author did:plc:alice, got %q", msg["author"]) 292 + } 293 + if msg["content"] != "hello world" { 294 + t.Errorf("expected content %q, got %q", "hello world", msg["content"]) 295 + } 296 + } 297 + 298 + func TestRoom_ApplyEdit_UpdatesOTState(t *testing.T) { 299 + hub := NewHub() 300 + room := hub.GetOrCreateRoom("doc-ot-state") 301 + 302 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-ot-state") 303 + room.RegisterClient(alice) 304 + time.Sleep(100 * time.Millisecond) 305 + drain(alice, 200*time.Millisecond) 306 + 307 + room.ApplyEdits([]Operation{{From: 0, To: -1, Insert: "first"}}, alice) 308 + room.ApplyEdits([]Operation{{From: 5, To: 5, Insert: " second"}}, alice) 309 + 310 + // Give room goroutine time to process 311 + time.Sleep(100 * time.Millisecond) 312 + 313 + if got := room.ot.GetText(); got != "first second" { 314 + t.Errorf("OT state: got %q, want %q", got, "first second") 315 + } 316 + } 317 + 318 + func TestRoom_ApplyEdits_MultipleOpsAppliedInOrder(t *testing.T) { 319 + hub := NewHub() 320 + room := hub.GetOrCreateRoom("doc-multi-ops") 321 + 322 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ops") 323 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi-ops") 324 + room.RegisterClient(alice) 325 + room.RegisterClient(bob) 326 + time.Sleep(100 * time.Millisecond) 327 + drain(alice, 200*time.Millisecond) 328 + drain(bob, 200*time.Millisecond) 329 + 330 + ops := []Operation{ 331 + {From: 0, To: -1, Insert: "hello"}, 332 + {From: 5, To: 5, Insert: " world"}, 333 + } 334 + room.ApplyEdits(ops, alice) 335 + 336 + bobMsgs := waitForMessages(bob, 1, time.Second) 337 + if len(bobMsgs) == 0 { 338 + t.Fatal("bob: expected edit message") 339 + } 340 + 341 + var msg struct { 342 + Type string `json:"type"` 343 + Deltas []Operation `json:"deltas"` 344 + Content string `json:"content"` 345 + Author string `json:"author"` 346 + } 347 + if err := json.Unmarshal(bobMsgs[0], &msg); err != nil { 348 + t.Fatalf("unmarshal: %v", err) 349 + } 350 + if msg.Type != "edit" { 351 + t.Errorf("type: got %q, want edit", msg.Type) 352 + } 353 + if msg.Content != "hello world" { 354 + t.Errorf("content: got %q, want %q", msg.Content, "hello world") 355 + } 356 + if len(msg.Deltas) != 2 { 357 + t.Errorf("deltas: got %d, want 2", len(msg.Deltas)) 358 + } 359 + if msg.Author != "did:plc:alice" { 360 + t.Errorf("author: got %q", msg.Author) 361 + } 362 + } 363 + 364 + func TestRoom_ApplyEdits_UpdatesOTState(t *testing.T) { 365 + hub := NewHub() 366 + room := hub.GetOrCreateRoom("doc-multi-ot") 367 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ot") 368 + room.RegisterClient(alice) 369 + time.Sleep(100 * time.Millisecond) 370 + drain(alice, 200*time.Millisecond) 371 + 372 + room.ApplyEdits([]Operation{ 373 + {From: 0, To: -1, Insert: "abc"}, 374 + {From: 3, To: 3, Insert: "def"}, 375 + }, alice) 376 + 377 + time.Sleep(50 * time.Millisecond) 378 + if got := room.ot.GetText(); got != "abcdef" { 379 + t.Errorf("OT state: got %q, want %q", got, "abcdef") 380 + } 381 + } 382 + 383 + func TestRoom_ApplyEdits_EmptyOpsIsNoop(t *testing.T) { 384 + hub := NewHub() 385 + room := hub.GetOrCreateRoom("doc-empty-ops") 386 + alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-empty-ops") 387 + bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-empty-ops") 388 + room.RegisterClient(alice) 389 + room.RegisterClient(bob) 390 + time.Sleep(100 * time.Millisecond) 391 + drain(alice, 200*time.Millisecond) 392 + drain(bob, 200*time.Millisecond) 393 + 394 + room.ApplyEdits(nil, alice) 395 + 396 + bobMsgs := drain(bob, 300*time.Millisecond) 397 + if len(bobMsgs) > 0 { 398 + t.Errorf("expected no broadcast for empty ops, got %d messages", len(bobMsgs)) 399 + } 400 + } 401 + 402 + // --- SeedText / IsNewRoom --- 403 + 404 + func TestRoom_SeedText_SetsInitialOTState(t *testing.T) { 405 + hub := NewHub() 406 + room := hub.GetOrCreateRoom("doc-seed") 407 + if !room.IsNewRoom() { 408 + t.Fatal("new room should report IsNewRoom=true") 409 + } 410 + room.SeedText("initial document content") 411 + if room.IsNewRoom() { 412 + t.Error("IsNewRoom should be false after seeding") 413 + } 414 + if got := room.ot.GetText(); got != "initial document content" { 415 + t.Errorf("OT text after seed: got %q, want %q", got, "initial document content") 416 + } 417 + } 418 + 419 + func TestRoom_SeedText_IdempotentAfterFirstCall(t *testing.T) { 420 + hub := NewHub() 421 + room := hub.GetOrCreateRoom("doc-seed-idem") 422 + room.SeedText("first") 423 + room.SeedText("second") // should be ignored 424 + if got := room.ot.GetText(); got != "first" { 425 + t.Errorf("second SeedText should be ignored: got %q, want %q", got, "first") 426 + } 427 + } 428 + 429 + // --- GetPresence --- 430 + 431 + func TestRoom_GetPresence_Empty(t *testing.T) { 432 + hub := NewHub() 433 + room := hub.GetOrCreateRoom("doc-empty-presence") 434 + users := room.GetPresence() 435 + if len(users) != 0 { 436 + t.Errorf("expected 0 users in new room, got %d", len(users)) 437 + } 438 + }
+54
internal/collaboration/invite.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "fmt" 8 + "time" 9 + 10 + "github.com/limeleaf/diffdown/internal/db" 11 + "github.com/limeleaf/diffdown/internal/model" 12 + ) 13 + 14 + func GenerateInviteToken() (string, error) { 15 + bytes := make([]byte, 32) 16 + if _, err := rand.Read(bytes); err != nil { 17 + return "", err 18 + } 19 + hash := sha256.Sum256(bytes) 20 + return hex.EncodeToString(hash[:]), nil 21 + } 22 + 23 + func CreateInvite(database *db.DB, documentRKey, createdByDID string) (*model.Invite, error) { 24 + token, err := GenerateInviteToken() 25 + if err != nil { 26 + return nil, err 27 + } 28 + 29 + invite := &model.Invite{ 30 + ID: db.NewID(), 31 + DocumentRKey: documentRKey, 32 + Token: token, 33 + CreatedBy: createdByDID, 34 + CreatedAt: time.Now(), 35 + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), 36 + } 37 + 38 + err = database.CreateInvite(invite) 39 + return invite, err 40 + } 41 + 42 + func ValidateInvite(database *db.DB, token, documentRKey string) (*model.Invite, error) { 43 + invite, err := database.GetInviteByToken(token) 44 + if err != nil { 45 + return nil, err 46 + } 47 + if invite.DocumentRKey != documentRKey { 48 + return nil, fmt.Errorf("invite does not match document") 49 + } 50 + if time.Now().After(invite.ExpiresAt) { 51 + return nil, fmt.Errorf("invite expired") 52 + } 53 + return invite, nil 54 + }
+171
internal/collaboration/invite_test.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + 9 + "github.com/limeleaf/diffdown/internal/db" 10 + "github.com/limeleaf/diffdown/internal/model" 11 + ) 12 + 13 + // openTestDB opens a temporary SQLite DB with migrations applied. 14 + func openTestDB(t *testing.T) *db.DB { 15 + t.Helper() 16 + tmp := t.TempDir() 17 + database, err := db.Open(filepath.Join(tmp, "test.db")) 18 + if err != nil { 19 + t.Fatalf("open test db: %v", err) 20 + } 21 + // Point migrations at the real migrations directory (relative to module root). 22 + db.SetMigrationsDir(filepath.Join("..", "..", "migrations")) 23 + if err := database.Migrate(); err != nil { 24 + t.Fatalf("migrate: %v", err) 25 + } 26 + t.Cleanup(func() { 27 + database.Close() 28 + os.RemoveAll(tmp) 29 + }) 30 + return database 31 + } 32 + 33 + // --- GenerateInviteToken --- 34 + 35 + func TestGenerateInviteToken_Length(t *testing.T) { 36 + token, err := GenerateInviteToken() 37 + if err != nil { 38 + t.Fatalf("unexpected error: %v", err) 39 + } 40 + // SHA-256 hex = 64 chars 41 + if len(token) != 64 { 42 + t.Errorf("expected token length 64, got %d", len(token)) 43 + } 44 + } 45 + 46 + func TestGenerateInviteToken_Unique(t *testing.T) { 47 + t1, err1 := GenerateInviteToken() 48 + t2, err2 := GenerateInviteToken() 49 + if err1 != nil || err2 != nil { 50 + t.Fatalf("unexpected errors: %v %v", err1, err2) 51 + } 52 + if t1 == t2 { 53 + t.Error("expected unique tokens, got duplicates") 54 + } 55 + } 56 + 57 + // --- CreateInvite --- 58 + 59 + func TestCreateInvite_ReturnsInvite(t *testing.T) { 60 + database := openTestDB(t) 61 + invite, err := CreateInvite(database, "rkey123", "did:plc:creator") 62 + if err != nil { 63 + t.Fatalf("CreateInvite: %v", err) 64 + } 65 + if invite.ID == "" { 66 + t.Error("expected non-empty ID") 67 + } 68 + if invite.Token == "" { 69 + t.Error("expected non-empty Token") 70 + } 71 + if invite.DocumentRKey != "rkey123" { 72 + t.Errorf("DocumentRKey: got %q, want %q", invite.DocumentRKey, "rkey123") 73 + } 74 + if invite.CreatedBy != "did:plc:creator" { 75 + t.Errorf("CreatedBy: got %q, want %q", invite.CreatedBy, "did:plc:creator") 76 + } 77 + } 78 + 79 + func TestCreateInvite_ExpiresInSevenDays(t *testing.T) { 80 + database := openTestDB(t) 81 + before := time.Now() 82 + invite, err := CreateInvite(database, "rkey123", "did:plc:creator") 83 + if err != nil { 84 + t.Fatalf("CreateInvite: %v", err) 85 + } 86 + after := time.Now() 87 + 88 + minExpiry := before.Add(7 * 24 * time.Hour) 89 + maxExpiry := after.Add(7 * 24 * time.Hour) 90 + 91 + if invite.ExpiresAt.Before(minExpiry) || invite.ExpiresAt.After(maxExpiry) { 92 + t.Errorf("ExpiresAt %v not in expected range [%v, %v]", invite.ExpiresAt, minExpiry, maxExpiry) 93 + } 94 + } 95 + 96 + func TestCreateInvite_PersistedInDB(t *testing.T) { 97 + database := openTestDB(t) 98 + invite, err := CreateInvite(database, "rkey123", "did:plc:creator") 99 + if err != nil { 100 + t.Fatalf("CreateInvite: %v", err) 101 + } 102 + 103 + fetched, err := database.GetInviteByToken(invite.Token) 104 + if err != nil { 105 + t.Fatalf("GetInviteByToken: %v", err) 106 + } 107 + if fetched.DocumentRKey != invite.DocumentRKey { 108 + t.Errorf("persisted DocumentRKey: got %q, want %q", fetched.DocumentRKey, invite.DocumentRKey) 109 + } 110 + } 111 + 112 + // --- ValidateInvite --- 113 + 114 + func TestValidateInvite_ValidToken(t *testing.T) { 115 + database := openTestDB(t) 116 + invite, err := CreateInvite(database, "rkey123", "did:plc:creator") 117 + if err != nil { 118 + t.Fatalf("CreateInvite: %v", err) 119 + } 120 + 121 + validated, err := ValidateInvite(database, invite.Token, "rkey123") 122 + if err != nil { 123 + t.Fatalf("ValidateInvite: %v", err) 124 + } 125 + if validated.Token != invite.Token { 126 + t.Errorf("got token %q, want %q", validated.Token, invite.Token) 127 + } 128 + } 129 + 130 + func TestValidateInvite_WrongRKey(t *testing.T) { 131 + database := openTestDB(t) 132 + invite, err := CreateInvite(database, "rkey123", "did:plc:creator") 133 + if err != nil { 134 + t.Fatalf("CreateInvite: %v", err) 135 + } 136 + 137 + _, err = ValidateInvite(database, invite.Token, "wrong-rkey") 138 + if err == nil { 139 + t.Error("expected error for mismatched rkey, got nil") 140 + } 141 + } 142 + 143 + func TestValidateInvite_UnknownToken(t *testing.T) { 144 + database := openTestDB(t) 145 + _, err := ValidateInvite(database, "nonexistent-token", "rkey123") 146 + if err == nil { 147 + t.Error("expected error for unknown token, got nil") 148 + } 149 + } 150 + 151 + func TestValidateInvite_ExpiredToken(t *testing.T) { 152 + database := openTestDB(t) 153 + 154 + // Insert an already-expired invite directly. 155 + expired := &model.Invite{ 156 + ID: db.NewID(), 157 + DocumentRKey: "rkey123", 158 + Token: "expiredtoken1234567890abcdef1234567890abcdef1234567890abcdef12", 159 + CreatedBy: "did:plc:creator", 160 + CreatedAt: time.Now().Add(-8 * 24 * time.Hour), 161 + ExpiresAt: time.Now().Add(-1 * time.Hour), 162 + } 163 + if err := database.CreateInvite(expired); err != nil { 164 + t.Fatalf("CreateInvite expired: %v", err) 165 + } 166 + 167 + _, err := ValidateInvite(database, expired.Token, "rkey123") 168 + if err == nil { 169 + t.Error("expected error for expired token, got nil") 170 + } 171 + }
+94
internal/collaboration/ot.go
··· 1 + package collaboration 2 + 3 + import "sync" 4 + 5 + type OTEngine struct { 6 + mu sync.Mutex 7 + documentText string 8 + version int 9 + } 10 + 11 + func NewOTEngine(initialText string) *OTEngine { 12 + return &OTEngine{ 13 + documentText: initialText, 14 + version: 0, 15 + } 16 + } 17 + 18 + type Operation struct { 19 + From int `json:"from"` 20 + To int `json:"to"` 21 + Insert string `json:"insert"` 22 + // Author is set by the client and used for logging/debugging purposes 23 + // to identify which user performed an operation 24 + Author string `json:"author"` 25 + } 26 + 27 + func (ot *OTEngine) Apply(op Operation) string { 28 + ot.mu.Lock() 29 + defer ot.mu.Unlock() 30 + 31 + newText, _ := ot.applyInternal(op) 32 + return newText 33 + } 34 + 35 + func (ot *OTEngine) ApplyWithVersion(op Operation) (string, int) { 36 + ot.mu.Lock() 37 + defer ot.mu.Unlock() 38 + 39 + newText, changed := ot.applyInternal(op) 40 + if !changed { 41 + return newText, ot.version 42 + } 43 + return newText, ot.version 44 + } 45 + 46 + func (ot *OTEngine) applyInternal(op Operation) (string, bool) { 47 + if op.From < 0 { 48 + op.From = 0 49 + } 50 + // -1 means "end of document" — treat as a full replacement when From==0. 51 + if op.To < 0 { 52 + op.To = len(ot.documentText) 53 + } 54 + if op.From > len(ot.documentText) { 55 + op.From = len(ot.documentText) 56 + } 57 + if op.To > len(ot.documentText) { 58 + op.To = len(ot.documentText) 59 + } 60 + if op.From > op.To { 61 + return ot.documentText, false 62 + } 63 + 64 + // True no-op: nothing deleted and nothing inserted. 65 + if op.From == op.To && op.Insert == "" { 66 + return ot.documentText, false 67 + } 68 + 69 + newText := ot.documentText[:op.From] + op.Insert + ot.documentText[op.To:] 70 + ot.documentText = newText 71 + ot.version++ 72 + 73 + return newText, true 74 + } 75 + 76 + // SetText replaces the canonical document text without incrementing the version. 77 + // Call this once when a room is first created, before any edits are applied. 78 + func (ot *OTEngine) SetText(text string) { 79 + ot.mu.Lock() 80 + defer ot.mu.Unlock() 81 + ot.documentText = text 82 + } 83 + 84 + func (ot *OTEngine) GetText() string { 85 + ot.mu.Lock() 86 + defer ot.mu.Unlock() 87 + return ot.documentText 88 + } 89 + 90 + func (ot *OTEngine) GetVersion() int { 91 + ot.mu.Lock() 92 + defer ot.mu.Unlock() 93 + return ot.version 94 + }
+165
internal/collaboration/ot_test.go
··· 1 + package collaboration 2 + 3 + import ( 4 + "sync" 5 + "testing" 6 + ) 7 + 8 + func TestOTEngine_NewEngine(t *testing.T) { 9 + ot := NewOTEngine("hello world") 10 + if ot.GetText() != "hello world" { 11 + t.Errorf("expected initial text %q, got %q", "hello world", ot.GetText()) 12 + } 13 + if ot.GetVersion() != 0 { 14 + t.Errorf("expected initial version 0, got %d", ot.GetVersion()) 15 + } 16 + } 17 + 18 + func TestOTEngine_Apply_Insert(t *testing.T) { 19 + ot := NewOTEngine("hello world") 20 + result := ot.Apply(Operation{From: 5, To: 5, Insert: " beautiful"}) 21 + want := "hello beautiful world" 22 + if result != want { 23 + t.Errorf("Apply insert: got %q, want %q", result, want) 24 + } 25 + } 26 + 27 + func TestOTEngine_Apply_Replace(t *testing.T) { 28 + ot := NewOTEngine("hello world") 29 + result := ot.Apply(Operation{From: 6, To: 11, Insert: "Go"}) 30 + want := "hello Go" 31 + if result != want { 32 + t.Errorf("Apply replace: got %q, want %q", result, want) 33 + } 34 + } 35 + 36 + func TestOTEngine_Apply_Delete(t *testing.T) { 37 + ot := NewOTEngine("hello world") 38 + // Delete " world" by replacing with empty string. 39 + result := ot.Apply(Operation{From: 5, To: 11, Insert: ""}) 40 + want := "hello" 41 + if result != want { 42 + t.Errorf("Apply delete (empty insert): got %q, want %q", result, want) 43 + } 44 + } 45 + 46 + func TestOTEngine_Apply_DeleteIncrementsVersion(t *testing.T) { 47 + ot := NewOTEngine("hello world") 48 + ot.Apply(Operation{From: 5, To: 11, Insert: ""}) 49 + if ot.GetVersion() != 1 { 50 + t.Errorf("delete should increment version: got %d, want 1", ot.GetVersion()) 51 + } 52 + } 53 + 54 + func TestOTEngine_Apply_EmptyInsertAtSamePosition_IsNoOp(t *testing.T) { 55 + ot := NewOTEngine("hello") 56 + result := ot.Apply(Operation{From: 3, To: 3, Insert: ""}) 57 + if result != "hello" { 58 + t.Errorf("From==To and Insert==\"\" should be no-op: got %q", result) 59 + } 60 + if ot.GetVersion() != 0 { 61 + t.Errorf("no-op should not increment version: got %d", ot.GetVersion()) 62 + } 63 + } 64 + 65 + func TestOTEngine_Apply_FullReplace(t *testing.T) { 66 + ot := NewOTEngine("old text") 67 + result := ot.Apply(Operation{From: 0, To: -1, Insert: "brand new content"}) 68 + want := "brand new content" 69 + if result != want { 70 + t.Errorf("Apply full replace (To=-1): got %q, want %q", result, want) 71 + } 72 + } 73 + 74 + func TestOTEngine_Apply_FromBeyondEnd_Clamps(t *testing.T) { 75 + ot := NewOTEngine("hi") 76 + // From beyond length should be clamped to len(text) 77 + result := ot.Apply(Operation{From: 100, To: 200, Insert: "!"}) 78 + want := "hi!" 79 + if result != want { 80 + t.Errorf("Apply clamped From: got %q, want %q", result, want) 81 + } 82 + } 83 + 84 + func TestOTEngine_Apply_NegativeFrom_Clamps(t *testing.T) { 85 + ot := NewOTEngine("hello") 86 + result := ot.Apply(Operation{From: -5, To: 0, Insert: ">> "}) 87 + want := ">> hello" 88 + if result != want { 89 + t.Errorf("Apply negative From: got %q, want %q", result, want) 90 + } 91 + } 92 + 93 + func TestOTEngine_Apply_FromGreaterThanTo_NoOp(t *testing.T) { 94 + ot := NewOTEngine("hello") 95 + result := ot.Apply(Operation{From: 3, To: 1, Insert: "X"}) 96 + // From > To → no-op 97 + if result != "hello" { 98 + t.Errorf("Apply From>To should be no-op: got %q, want %q", result, "hello") 99 + } 100 + } 101 + 102 + func TestOTEngine_VersionIncrements(t *testing.T) { 103 + ot := NewOTEngine("abc") 104 + if ot.GetVersion() != 0 { 105 + t.Fatalf("initial version should be 0") 106 + } 107 + ot.Apply(Operation{From: 0, To: 0, Insert: "X"}) 108 + if ot.GetVersion() != 1 { 109 + t.Errorf("version after apply: got %d, want 1", ot.GetVersion()) 110 + } 111 + ot.Apply(Operation{From: 0, To: 0, Insert: "Y"}) 112 + if ot.GetVersion() != 2 { 113 + t.Errorf("version after second apply: got %d, want 2", ot.GetVersion()) 114 + } 115 + } 116 + 117 + func TestOTEngine_NoOpDoesNotIncrementVersion(t *testing.T) { 118 + ot := NewOTEngine("abc") 119 + ot.Apply(Operation{From: 1, To: 1, Insert: ""}) // no-op 120 + if ot.GetVersion() != 0 { 121 + t.Errorf("no-op should not increment version: got %d, want 0", ot.GetVersion()) 122 + } 123 + } 124 + 125 + func TestOTEngine_ApplyWithVersion(t *testing.T) { 126 + ot := NewOTEngine("hello") 127 + text, ver := ot.ApplyWithVersion(Operation{From: 5, To: 5, Insert: "!"}) 128 + if text != "hello!" { 129 + t.Errorf("ApplyWithVersion text: got %q, want %q", text, "hello!") 130 + } 131 + if ver != 1 { 132 + t.Errorf("ApplyWithVersion version: got %d, want 1", ver) 133 + } 134 + } 135 + 136 + func TestOTEngine_SetText(t *testing.T) { 137 + ot := NewOTEngine("") 138 + ot.SetText("initial content") 139 + if ot.GetText() != "initial content" { 140 + t.Errorf("SetText: got %q, want %q", ot.GetText(), "initial content") 141 + } 142 + // SetText should not change the version counter. 143 + if ot.GetVersion() != 0 { 144 + t.Errorf("SetText should not increment version: got %d", ot.GetVersion()) 145 + } 146 + } 147 + 148 + func TestOTEngine_ConcurrentApply_DataRace(t *testing.T) { 149 + // Run with -race to catch races. This test just verifies no panic / race. 150 + ot := NewOTEngine("start") 151 + var wg sync.WaitGroup 152 + for i := 0; i < 20; i++ { 153 + wg.Add(1) 154 + go func() { 155 + defer wg.Done() 156 + ot.Apply(Operation{From: 0, To: 0, Insert: "x"}) 157 + }() 158 + } 159 + wg.Wait() 160 + // After 20 inserts of "x" at position 0, we have 20 extra chars 161 + text := ot.GetText() 162 + if len(text) != len("start")+20 { 163 + t.Errorf("concurrent apply: expected length %d, got %d (text=%q)", len("start")+20, len(text), text) 164 + } 165 + }
+24
internal/db/db.go
··· 156 156 return err 157 157 } 158 158 159 + // --- Invites --- 160 + 161 + func (db *DB) CreateInvite(invite *model.Invite) error { 162 + _, err := db.Exec(` 163 + INSERT INTO invites (id, document_rkey, token, created_by_did, created_at, expires_at) 164 + VALUES (?, ?, ?, ?, ?, ?)`, 165 + invite.ID, invite.DocumentRKey, invite.Token, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt) 166 + return err 167 + } 168 + 169 + func (db *DB) GetInviteByToken(token string) (*model.Invite, error) { 170 + row := db.QueryRow(`SELECT id, document_rkey, token, created_by_did, created_at, expires_at FROM invites WHERE token = ?`, token) 171 + var invite model.Invite 172 + err := row.Scan(&invite.ID, &invite.DocumentRKey, &invite.Token, &invite.CreatedBy, &invite.CreatedAt, &invite.ExpiresAt) 173 + if err != nil { 174 + return nil, err 175 + } 176 + return &invite, nil 177 + } 178 + 179 + func (db *DB) DeleteInvite(token string) error { 180 + _, err := db.Exec(`DELETE FROM invites WHERE token = ?`, token) 181 + return err 182 + }
+3
internal/handler/atproto.go
··· 326 326 handle = tokenBody.Sub 327 327 } 328 328 did := tokenBody.Sub 329 + // Use DID as synthetic email — ATProto users have no email, 330 + // but the users table requires a unique non-empty value. 329 331 user = &model.User{ 330 332 Name: handle, 333 + Email: did, 331 334 DID: &did, 332 335 PDSURL: pdsURL, 333 336 }
+453 -44
internal/handler/handler.go
··· 6 6 "html/template" 7 7 "log" 8 8 "net/http" 9 + "net/url" 9 10 "regexp" 10 11 "strings" 11 12 "time" 12 13 14 + "github.com/golang-jwt/jwt/v5" 15 + "github.com/gorilla/websocket" 16 + 13 17 "github.com/limeleaf/diffdown/internal/atproto/xrpc" 14 18 "github.com/limeleaf/diffdown/internal/auth" 19 + "github.com/limeleaf/diffdown/internal/collaboration" 15 20 "github.com/limeleaf/diffdown/internal/db" 16 21 "github.com/limeleaf/diffdown/internal/model" 17 22 "github.com/limeleaf/diffdown/internal/render" ··· 21 26 const collectionComment = "com.diffdown.comment" 22 27 23 28 type Handler struct { 24 - DB *db.DB 25 - Tmpls map[string]*template.Template 29 + DB *db.DB 30 + Tmpls map[string]*template.Template 31 + BaseURL string 32 + CollaborationHub *collaboration.Hub 26 33 } 27 34 28 - func New(database *db.DB, tmpls map[string]*template.Template) *Handler { 29 - return &Handler{DB: database, Tmpls: tmpls} 35 + func New(database *db.DB, tmpls map[string]*template.Template, baseURL string, collabHub *collaboration.Hub) *Handler { 36 + return &Handler{DB: database, Tmpls: tmpls, BaseURL: baseURL, CollaborationHub: collabHub} 30 37 } 31 38 32 39 // --- Template helpers --- ··· 40 47 OGImage string 41 48 } 42 49 50 + // DocumentEditData is passed to document_edit.html. 51 + type DocumentEditData struct { 52 + *model.Document 53 + // AccessToken is the ATProto access token for WebSocket auth. 54 + // Empty string if user has no ATProto session. 55 + AccessToken string 56 + // IsOwner is true when the current user owns (created) the document. 57 + IsOwner bool 58 + // IsCollaborator is true when the current user is in the collaborators list. 59 + IsCollaborator bool 60 + // OwnerDID is the document owner's ATProto DID. Empty when IsOwner is true. 61 + // Used by collaborators to route save requests to the owner's PDS. 62 + OwnerDID string 63 + } 64 + 43 65 func (h *Handler) currentUser(r *http.Request) *model.User { 44 66 uid := auth.UserIDFromContext(r.Context()) 45 67 if uid == "" { ··· 66 88 } 67 89 } 68 90 69 - func (h *Handler) jsonResponse(w http.ResponseWriter, data interface{}) { 91 + func (h *Handler) jsonResponse(w http.ResponseWriter, data interface{}, statusCode int) { 70 92 w.Header().Set("Content-Type", "application/json") 93 + w.WriteHeader(statusCode) 71 94 json.NewEncoder(w).Encode(data) 72 95 } 73 96 ··· 250 273 http.Redirect(w, r, fmt.Sprintf("/docs/%s/edit", rkey), http.StatusSeeOther) 251 274 } 252 275 253 - // DocumentView renders a document as HTML. 254 - func (h *Handler) DocumentView(w http.ResponseWriter, r *http.Request) { 255 - user := h.currentUser(r) 256 - if user == nil { 257 - http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 258 - return 259 - } 260 - 261 - rkey := r.PathValue("rkey") 262 - client, err := h.xrpcClient(user.ID) 276 + // documentView is the shared implementation for viewing a document given an ownerDID and rkey. 277 + // isOwner should be true when the current user owns the document; it suppresses the ownerDID 278 + // in the template so the edit button links to /docs/{rkey}/edit rather than the collaborator URL. 279 + func (h *Handler) documentView(w http.ResponseWriter, r *http.Request, ownerUserID, ownerDID, rkey string, isOwner bool) { 280 + client, err := h.xrpcClient(ownerUserID) 263 281 if err != nil { 264 282 http.Error(w, "Could not connect to PDS", 500) 265 283 return 266 284 } 267 285 268 - value, _, err := client.GetRecord(client.DID(), collectionDocument, rkey) 286 + value, _, err := client.GetRecord(ownerDID, collectionDocument, rkey) 269 287 if err != nil { 270 288 http.NotFound(w, r) 271 289 return ··· 283 301 rendered, _ = render.Markdown([]byte(doc.Content.Text.RawMarkdown)) 284 302 } 285 303 304 + user := h.currentUser(r) 286 305 type DocumentViewData struct { 287 306 Doc *model.Document 288 307 Rendered template.HTML 308 + OwnerDID string // non-empty when viewing a collaborator's document 309 + } 310 + // Only set OwnerDID when the viewer is not the owner; the template uses 311 + // a non-empty OwnerDID to generate the collaborator edit URL. 312 + templateOwnerDID := ownerDID 313 + if isOwner { 314 + templateOwnerDID = "" 289 315 } 290 316 h.render(w, "document_view.html", PageData{ 291 317 Title: doc.Title, 292 318 User: user, 293 - Content: DocumentViewData{Doc: doc, Rendered: template.HTML(rendered)}, 319 + Content: DocumentViewData{Doc: doc, Rendered: template.HTML(rendered), OwnerDID: templateOwnerDID}, 294 320 }) 295 321 } 296 322 297 - // DocumentEdit renders the editor for a document. 298 - func (h *Handler) DocumentEdit(w http.ResponseWriter, r *http.Request) { 323 + // DocumentView renders a document as HTML (owner viewing their own doc). 324 + func (h *Handler) DocumentView(w http.ResponseWriter, r *http.Request) { 299 325 user := h.currentUser(r) 300 326 if user == nil { 301 327 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) ··· 308 334 http.Error(w, "Could not connect to PDS", 500) 309 335 return 310 336 } 337 + h.documentView(w, r, user.ID, client.DID(), rkey, true) 338 + } 311 339 312 - value, _, err := client.GetRecord(client.DID(), collectionDocument, rkey) 340 + // CollaboratorDocumentView renders a document owned by another user (collaborator access). 341 + func (h *Handler) CollaboratorDocumentView(w http.ResponseWriter, r *http.Request) { 342 + user := h.currentUser(r) 343 + if user == nil { 344 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 345 + return 346 + } 347 + 348 + ownerDID := r.PathValue("did") 349 + rkey := r.PathValue("rkey") 350 + 351 + ownerUser, err := h.DB.GetUserByDID(ownerDID) 352 + if err != nil { 353 + http.NotFound(w, r) 354 + return 355 + } 356 + 357 + h.documentView(w, r, ownerUser.ID, ownerDID, rkey, false) 358 + } 359 + 360 + // documentEdit is the shared implementation for the edit page. 361 + // ownerUserID/ownerDID identify whose PDS holds the document; isOwner is true for the creator. 362 + func (h *Handler) documentEdit(w http.ResponseWriter, r *http.Request, user *model.User, ownerUserID, ownerDID, rkey string, isOwner bool) { 363 + ownerClient, err := h.xrpcClient(ownerUserID) 364 + if err != nil { 365 + http.Error(w, "Could not connect to PDS", 500) 366 + return 367 + } 368 + 369 + value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rkey) 313 370 if err != nil { 314 371 http.NotFound(w, r) 315 372 return ··· 322 379 } 323 380 doc.RKey = rkey 324 381 382 + editData := &DocumentEditData{Document: doc, IsOwner: isOwner} 383 + if !isOwner { 384 + editData.OwnerDID = ownerDID 385 + } 386 + if session, err := h.DB.GetATProtoSession(user.ID); err == nil && session != nil { 387 + editData.AccessToken = session.AccessToken 388 + userDID := session.DID 389 + for _, did := range doc.Collaborators { 390 + if did == userDID { 391 + editData.IsCollaborator = true 392 + break 393 + } 394 + } 395 + } 396 + 325 397 h.render(w, "document_edit.html", PageData{ 326 398 Title: "Edit " + doc.Title, 327 399 User: user, 328 - Content: doc, 400 + Content: editData, 329 401 }) 330 402 } 331 403 404 + // DocumentEdit renders the editor for a document (owner). 405 + func (h *Handler) DocumentEdit(w http.ResponseWriter, r *http.Request) { 406 + user := h.currentUser(r) 407 + if user == nil { 408 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 409 + return 410 + } 411 + 412 + rkey := r.PathValue("rkey") 413 + client, err := h.xrpcClient(user.ID) 414 + if err != nil { 415 + http.Error(w, "Could not connect to PDS", 500) 416 + return 417 + } 418 + 419 + h.documentEdit(w, r, user, user.ID, client.DID(), rkey, true) 420 + } 421 + 422 + // CollaboratorDocumentEdit renders the editor for a document owned by another user. 423 + func (h *Handler) CollaboratorDocumentEdit(w http.ResponseWriter, r *http.Request) { 424 + user := h.currentUser(r) 425 + if user == nil { 426 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 427 + return 428 + } 429 + 430 + ownerDID := r.PathValue("did") 431 + rkey := r.PathValue("rkey") 432 + 433 + ownerUser, err := h.DB.GetUserByDID(ownerDID) 434 + if err != nil { 435 + http.NotFound(w, r) 436 + return 437 + } 438 + 439 + h.documentEdit(w, r, user, ownerUser.ID, ownerDID, rkey, false) 440 + } 441 + 332 442 // APIDocumentSave saves a document to the PDS. 333 443 func (h *Handler) APIDocumentSave(w http.ResponseWriter, r *http.Request) { 334 444 user := h.currentUser(r) ··· 339 449 340 450 rkey := r.PathValue("rkey") 341 451 var req struct { 342 - Content string `json:"content"` 343 - Title string `json:"title"` 452 + Content string `json:"content"` 453 + Title string `json:"title"` 454 + OwnerDID string `json:"ownerDID"` // non-empty when saving on behalf of another user (collaborator) 344 455 } 345 456 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 346 457 http.Error(w, "Bad request", 400) 347 458 return 348 459 } 349 460 350 - client, err := h.xrpcClient(user.ID) 351 - if err != nil { 352 - http.Error(w, "Could not connect to PDS", 500) 353 - return 461 + // For collaborators, save to the document owner's PDS, not the collaborator's. 462 + var client *xrpc.Client 463 + var repoDID string 464 + if req.OwnerDID != "" { 465 + ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 466 + if err != nil { 467 + log.Printf("APIDocumentSave: get owner by DID %s: %v", req.OwnerDID, err) 468 + http.Error(w, "Document owner not found", 404) 469 + return 470 + } 471 + client, err = h.xrpcClient(ownerUser.ID) 472 + if err != nil { 473 + http.Error(w, "Could not connect to owner PDS", 500) 474 + return 475 + } 476 + repoDID = req.OwnerDID 477 + } else { 478 + var err error 479 + client, err = h.xrpcClient(user.ID) 480 + if err != nil { 481 + http.Error(w, "Could not connect to PDS", 500) 482 + return 483 + } 484 + repoDID = client.DID() 354 485 } 355 486 356 487 // Fetch existing record to preserve fields 357 - value, _, err := client.GetRecord(client.DID(), collectionDocument, rkey) 488 + value, _, err := client.GetRecord(repoDID, collectionDocument, rkey) 358 489 if err != nil { 359 490 http.Error(w, "Document not found", 404) 360 491 return ··· 390 521 return 391 522 } 392 523 393 - h.jsonResponse(w, map[string]string{"status": "ok"}) 524 + h.jsonResponse(w, map[string]string{"status": "ok"}, http.StatusOK) 394 525 } 395 526 396 527 // APIDocumentAutoSave is the same as save, called on debounce from editor. ··· 419 550 return 420 551 } 421 552 422 - h.jsonResponse(w, map[string]string{"status": "ok"}) 553 + h.jsonResponse(w, map[string]string{"status": "ok"}, http.StatusOK) 554 + } 555 + 556 + // DocumentInvite creates an invite link for a document. 557 + func (h *Handler) DocumentInvite(w http.ResponseWriter, r *http.Request) { 558 + user := h.currentUser(r) 559 + if user == nil { 560 + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 561 + return 562 + } 563 + 564 + rkey := r.PathValue("rkey") 565 + if rkey == "" { 566 + http.Error(w, "Invalid document", http.StatusBadRequest) 567 + return 568 + } 569 + 570 + client, err := h.xrpcClient(user.ID) 571 + if err != nil { 572 + log.Printf("DocumentInvite: xrpc client: %v", err) 573 + h.render(w, "document_edit.html", PageData{Error: "Not authenticated with ATProto"}) 574 + return 575 + } 576 + 577 + value, _, err := client.GetRecord(client.DID(), collectionDocument, rkey) 578 + if err != nil { 579 + http.Error(w, "Document not found", http.StatusNotFound) 580 + return 581 + } 582 + 583 + doc := &model.Document{} 584 + if err := json.Unmarshal(value, doc); err != nil { 585 + http.Error(w, "Invalid document", http.StatusInternalServerError) 586 + return 587 + } 588 + doc.RKey = rkey 589 + 590 + // The document was fetched via client.DID(), so the current user is always the owner. 591 + if len(doc.Collaborators) >= 5 { 592 + http.Error(w, "Maximum collaborators reached", http.StatusBadRequest) 593 + return 594 + } 595 + 596 + invite, err := collaboration.CreateInvite(h.DB, rkey, client.DID()) 597 + if err != nil { 598 + log.Printf("DocumentInvite: create invite: %v", err) 599 + http.Error(w, "Failed to create invite", http.StatusInternalServerError) 600 + return 601 + } 602 + 603 + inviteLink := fmt.Sprintf("%s/docs/%s/accept?invite=%s", h.BaseURL, rkey, invite.Token) 604 + h.jsonResponse(w, map[string]string{"inviteLink": inviteLink}, http.StatusOK) 605 + } 606 + 607 + // AcceptInvite handles an invite acceptance. 608 + func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { 609 + user := h.currentUser(r) 610 + if user == nil { 611 + // Preserve invite token through the login redirect. 612 + http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther) 613 + return 614 + } 615 + 616 + rKey := r.PathValue("rkey") 617 + inviteToken := r.URL.Query().Get("invite") 618 + if inviteToken == "" { 619 + http.Error(w, "Invalid invite", http.StatusBadRequest) 620 + return 621 + } 622 + 623 + invite, err := collaboration.ValidateInvite(h.DB, inviteToken, rKey) 624 + if err != nil { 625 + log.Printf("AcceptInvite: validate invite rkey=%s: %v", rKey, err) 626 + http.Error(w, "Invite not found, already used, or expired.", http.StatusBadRequest) 627 + return 628 + } 629 + 630 + // The collaborator's session — needed to get their DID. 631 + collabSession, err := h.DB.GetATProtoSession(user.ID) 632 + if err != nil || collabSession == nil { 633 + http.Redirect(w, r, "/auth/atproto?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther) 634 + return 635 + } 636 + 637 + // Fetch and update the document from the OWNER's PDS, not the collaborator's. 638 + // The invite records the owner's DID in CreatedBy. 639 + ownerUser, err := h.DB.GetUserByDID(invite.CreatedBy) 640 + if err != nil { 641 + log.Printf("AcceptInvite: get owner by DID %s: %v", invite.CreatedBy, err) 642 + http.Error(w, "Document owner not found", http.StatusInternalServerError) 643 + return 644 + } 645 + 646 + ownerClient, err := h.xrpcClient(ownerUser.ID) 647 + if err != nil { 648 + log.Printf("AcceptInvite: owner xrpc client: %v", err) 649 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 650 + return 651 + } 652 + 653 + doc, err := ownerClient.GetDocument(rKey) 654 + if err != nil { 655 + log.Printf("AcceptInvite: get document: %v", err) 656 + http.Error(w, "Document not found", http.StatusNotFound) 657 + return 658 + } 659 + 660 + // Already a collaborator — redirect to the owner-scoped URL. 661 + for _, c := range doc.Collaborators { 662 + if c == collabSession.DID { 663 + http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) 664 + return 665 + } 666 + } 667 + 668 + // Add collaborator DID and PUT back to owner's PDS. 669 + doc.Collaborators = append(doc.Collaborators, collabSession.DID) 670 + if _, _, err = ownerClient.PutDocument(rKey, doc); err != nil { 671 + log.Printf("AcceptInvite: put document: %v", err) 672 + http.Error(w, "Failed to add collaborator", http.StatusInternalServerError) 673 + return 674 + } 675 + 676 + h.DB.DeleteInvite(invite.Token) 677 + 678 + // Redirect to owner-scoped document URL so the view handler knows whose PDS to query. 679 + http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) 423 680 } 424 681 425 682 // --- API: Comments --- ··· 451 708 return 452 709 } 453 710 454 - client, err := h.xrpcClient(user.ID) 455 - if err != nil { 456 - log.Printf("CommentCreate: xrpc client: %v", err) 711 + session, err := h.DB.GetATProtoSession(user.ID) 712 + if err != nil || session == nil { 457 713 http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 458 714 return 459 715 } 460 716 461 - session, err := h.DB.GetATProtoSession(user.ID) 462 - if err != nil || session == nil { 463 - http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 717 + client, err := h.xrpcClient(user.ID) 718 + if err != nil { 719 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 464 720 return 465 721 } 466 722 ··· 468 724 DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey), 469 725 ParagraphID: req.ParagraphID, 470 726 Text: req.Text, 471 - AuthorDID: session.DID, 727 + Author: session.DID, 472 728 } 473 729 474 730 uri, err := client.CreateComment(comment) ··· 478 734 return 479 735 } 480 736 481 - w.WriteHeader(http.StatusCreated) 482 - h.jsonResponse(w, map[string]string{"uri": uri}) 737 + h.jsonResponse(w, map[string]string{"uri": uri}, http.StatusCreated) 483 738 } 484 739 485 740 func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { ··· 495 750 return 496 751 } 497 752 753 + session, err := h.DB.GetATProtoSession(user.ID) 754 + if err != nil || session == nil { 755 + http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 756 + return 757 + } 758 + 498 759 client, err := h.xrpcClient(user.ID) 499 760 if err != nil { 500 - log.Printf("CommentList: xrpc client: %v", err) 501 - http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 761 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 502 762 return 503 763 } 504 764 ··· 509 769 return 510 770 } 511 771 512 - w.WriteHeader(http.StatusOK) 513 - h.jsonResponse(w, comments) 772 + h.jsonResponse(w, comments, http.StatusOK) 514 773 } 515 774 516 775 // --- API: Render markdown --- ··· 530 789 return 531 790 } 532 791 533 - h.jsonResponse(w, map[string]string{"html": rendered}) 792 + h.jsonResponse(w, map[string]string{"html": rendered}, http.StatusOK) 793 + } 794 + 795 + var upgrader = websocket.Upgrader{ 796 + CheckOrigin: func(r *http.Request) bool { return true }, 797 + } 798 + 799 + func (h *Handler) CollaboratorWebSocket(w http.ResponseWriter, r *http.Request) { 800 + rKey := r.PathValue("rkey") 801 + if rKey == "" { 802 + http.Error(w, "Invalid document", http.StatusBadRequest) 803 + return 804 + } 805 + 806 + accessToken := r.URL.Query().Get("access_token") 807 + dpopProof := r.URL.Query().Get("dpop_proof") 808 + if accessToken == "" || dpopProof == "" { 809 + http.Error(w, "Missing auth tokens", http.StatusUnauthorized) 810 + return 811 + } 812 + 813 + did, name, err := h.validateWSToken(accessToken, dpopProof) 814 + if err != nil { 815 + http.Error(w, "Invalid tokens", http.StatusUnauthorized) 816 + return 817 + } 818 + 819 + user, err := h.DB.GetUserByDID(did) 820 + if err != nil { 821 + http.Error(w, "No user found", http.StatusUnauthorized) 822 + return 823 + } 824 + 825 + session, err := h.DB.GetATProtoSession(user.ID) 826 + if err != nil || session == nil { 827 + http.Error(w, "No ATProto session", http.StatusUnauthorized) 828 + return 829 + } 830 + 831 + // If owner_did is provided, fetch the document from the owner's PDS 832 + // (used by collaborators whose copy lives on a different PDS). 833 + ownerDID := r.URL.Query().Get("owner_did") 834 + var docClient *xrpc.Client 835 + var docRepoDID string 836 + if ownerDID != "" { 837 + ownerUser, err := h.DB.GetUserByDID(ownerDID) 838 + if err != nil { 839 + log.Printf("CollaboratorWebSocket: get owner by DID %s: %v", ownerDID, err) 840 + http.Error(w, "Document owner not found", http.StatusForbidden) 841 + return 842 + } 843 + docClient, err = h.xrpcClient(ownerUser.ID) 844 + if err != nil { 845 + http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 846 + return 847 + } 848 + docRepoDID = ownerDID 849 + } else { 850 + docClient, err = h.xrpcClient(session.UserID) 851 + if err != nil { 852 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 853 + return 854 + } 855 + docRepoDID = did 856 + } 857 + 858 + value, _, err := docClient.GetRecord(docRepoDID, collectionDocument, rKey) 859 + if err != nil { 860 + http.Error(w, "Document not found", http.StatusNotFound) 861 + return 862 + } 863 + doc := &model.Document{} 864 + if err := json.Unmarshal(value, doc); err != nil { 865 + http.Error(w, "Invalid document", http.StatusInternalServerError) 866 + return 867 + } 868 + 869 + // Owner always has access; collaborators must be in the collaborators list. 870 + isCollaborator := did == docRepoDID 871 + for _, c := range doc.Collaborators { 872 + if c == did { 873 + isCollaborator = true 874 + break 875 + } 876 + } 877 + if !isCollaborator { 878 + http.Error(w, "Not a collaborator", http.StatusForbidden) 879 + return 880 + } 881 + 882 + color := colorFromDID(did) 883 + 884 + conn, err := upgrader.Upgrade(w, r, nil) 885 + if err != nil { 886 + log.Printf("WebSocket upgrade failed: %v", err) 887 + return 888 + } 889 + 890 + room := h.CollaborationHub.GetOrCreateRoom(rKey) 891 + if room.IsNewRoom() { 892 + var initialText string 893 + if doc.Content != nil { 894 + initialText = doc.Content.Text.RawMarkdown 895 + } 896 + room.SeedText(initialText) 897 + } 898 + wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 899 + room.RegisterClient(wsClient) 900 + 901 + go wsClient.WritePump() 902 + wsClient.ReadPump() 903 + } 904 + 905 + func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) { 906 + claims := &jwt.MapClaims{} 907 + parser := jwt.Parser{} 908 + _, _, err := parser.ParseUnverified(accessToken, claims) 909 + if err != nil { 910 + return "", "", fmt.Errorf("parse token: %w", err) 911 + } 912 + 913 + did, ok := (*claims)["sub"].(string) 914 + if !ok { 915 + return "", "", fmt.Errorf("no sub in token") 916 + } 917 + 918 + user, err := h.DB.GetUserByDID(did) 919 + if err != nil { 920 + return "", "", fmt.Errorf("user not found: %w", err) 921 + } 922 + 923 + session, err := h.DB.GetATProtoSession(user.ID) 924 + if err != nil { 925 + return "", "", fmt.Errorf("session not found: %w", err) 926 + } 927 + 928 + if time.Now().After(session.ExpiresAt) { 929 + return "", "", fmt.Errorf("session expired") 930 + } 931 + 932 + name, _ := (*claims)["name"].(string) 933 + return did, name, nil 934 + } 935 + 936 + func colorFromDID(did string) string { 937 + colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"} 938 + hash := 0 939 + for _, c := range did { 940 + hash += int(c) 941 + } 942 + return colors[hash%len(colors)] 534 943 }
+22 -11
internal/model/models.go
··· 39 39 CID string `json:"cid,omitempty"` 40 40 RKey string `json:"rkey,omitempty"` 41 41 // Document fields 42 - Title string `json:"title"` 43 - Content *MarkdownContent `json:"content,omitempty"` 44 - TextContent string `json:"textContent,omitempty"` 45 - CreatedAt string `json:"createdAt"` 46 - UpdatedAt string `json:"updatedAt,omitempty"` 42 + Title string `json:"title"` 43 + Content *MarkdownContent `json:"content,omitempty"` 44 + TextContent string `json:"textContent,omitempty"` 45 + Collaborators []string `json:"collaborators,omitempty"` 46 + CreatedAt string `json:"createdAt"` 47 + UpdatedAt string `json:"updatedAt,omitempty"` 47 48 } 48 49 49 50 type MarkdownContent struct { ··· 56 57 RawMarkdown string `json:"rawMarkdown"` 57 58 } 58 59 60 + type Invite struct { 61 + ID string `json:"id"` 62 + DocumentRKey string `json:"document_rkey"` 63 + Token string `json:"token"` 64 + CreatedBy string `json:"created_by"` 65 + CreatedAt time.Time `json:"created_at"` 66 + ExpiresAt time.Time `json:"expires_at"` 67 + } 68 + 59 69 type Comment struct { 60 - URI string `json:"uri,omitempty"` 61 - DocumentURI string `json:"documentUri"` 62 - ParagraphID string `json:"paragraphId"` 63 - Text string `json:"text"` 64 - AuthorDID string `json:"authorDid"` 65 - CreatedAt string `json:"createdAt,omitempty"` 70 + URI string `json:"uri"` 71 + DocumentURI string `json:"documentURI"` 72 + ParagraphID string `json:"paragraphId"` 73 + Text string `json:"text"` 74 + Author string `json:"author"` 75 + AuthorName string `json:"authorName"` 76 + CreatedAt time.Time `json:"createdAt"` 66 77 } 67 78 68 79 // RKeyFromURI extracts the rkey (last path segment) from an at:// URI.
+13
migrations/005_create_invites.sql
··· 1 + -- 005_create_invites.sql 2 + 3 + CREATE TABLE IF NOT EXISTS invites ( 4 + id TEXT PRIMARY KEY, 5 + document_rkey TEXT NOT NULL, 6 + token TEXT NOT NULL UNIQUE, 7 + created_by_did TEXT NOT NULL, 8 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 9 + expires_at DATETIME NOT NULL 10 + ); 11 + 12 + CREATE INDEX IF NOT EXISTS idx_invites_document ON invites(document_rkey); 13 + CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
+218
static/css/editor.css
··· 199 199 } 200 200 201 201 .link-tooltip button:hover { opacity: 0.8; } 202 + 203 + /* ── Collaboration: Presence ─────────────────────────────────────────────── */ 204 + 205 + .presence-list { 206 + display: flex; 207 + align-items: center; 208 + gap: 4px; 209 + } 210 + 211 + .presence-avatar { 212 + display: inline-block; 213 + width: 22px; 214 + height: 22px; 215 + border-radius: 50%; 216 + border: 2px solid var(--bg-card); 217 + box-shadow: 0 0 0 1px var(--border); 218 + flex-shrink: 0; 219 + transition: transform 0.15s; 220 + } 221 + 222 + .presence-avatar:hover { 223 + transform: scale(1.15); 224 + } 225 + 226 + /* ── Collaboration: Comment sidebar ──────────────────────────────────────── */ 227 + 228 + .comment-sidebar { 229 + position: fixed; 230 + right: 0; 231 + top: 49px; /* below navbar */ 232 + bottom: 0; 233 + width: 260px; 234 + background: var(--bg-card); 235 + border-left: 1px solid var(--border); 236 + display: flex; 237 + flex-direction: column; 238 + z-index: 50; 239 + overflow: hidden; 240 + } 241 + 242 + .comment-sidebar-header { 243 + padding: 0.75rem 1rem; 244 + font-size: 0.85rem; 245 + font-weight: 600; 246 + border-bottom: 1px solid var(--border); 247 + flex-shrink: 0; 248 + color: var(--text-muted); 249 + text-transform: uppercase; 250 + letter-spacing: 0.05em; 251 + } 252 + 253 + .comment-threads { 254 + flex: 1; 255 + overflow-y: auto; 256 + padding: 0.75rem; 257 + display: flex; 258 + flex-direction: column; 259 + gap: 0.75rem; 260 + } 261 + 262 + .comment-empty { 263 + color: var(--text-muted); 264 + font-size: 0.85rem; 265 + text-align: center; 266 + margin-top: 2rem; 267 + } 268 + 269 + .comment-thread { 270 + background: var(--bg); 271 + border: 1px solid var(--border); 272 + border-radius: var(--radius); 273 + overflow: hidden; 274 + } 275 + 276 + .comment-thread-label { 277 + font-size: 0.75rem; 278 + color: var(--text-muted); 279 + padding: 0.3rem 0.6rem; 280 + background: var(--border); 281 + border-bottom: 1px solid var(--border); 282 + } 283 + 284 + .comment-item { 285 + padding: 0.5rem 0.6rem; 286 + border-top: 1px solid var(--border); 287 + } 288 + 289 + .comment-item:first-of-type { 290 + border-top: none; 291 + } 292 + 293 + .comment-author { 294 + font-size: 0.75rem; 295 + font-weight: 600; 296 + color: var(--primary); 297 + margin-bottom: 0.2rem; 298 + } 299 + 300 + .comment-text { 301 + font-size: 0.85rem; 302 + line-height: 1.45; 303 + word-break: break-word; 304 + } 305 + 306 + .comment-time { 307 + font-size: 0.7rem; 308 + color: var(--text-muted); 309 + margin-top: 0.25rem; 310 + } 311 + 312 + /* ── Collaboration: Comment button & form ────────────────────────────────── */ 313 + 314 + .comment-btn { 315 + position: fixed; 316 + z-index: 100; 317 + padding: 0.25rem 0.6rem; 318 + font-size: 0.78rem; 319 + background: var(--primary); 320 + color: #fff; 321 + border: none; 322 + border-radius: var(--radius); 323 + cursor: pointer; 324 + box-shadow: 0 2px 8px rgba(0,0,0,0.15); 325 + } 326 + 327 + .comment-btn:hover { 328 + opacity: 0.88; 329 + } 330 + 331 + .comment-form { 332 + position: fixed; 333 + z-index: 110; 334 + background: var(--bg-card); 335 + border: 1px solid var(--border); 336 + border-radius: var(--radius); 337 + box-shadow: 0 4px 20px rgba(0,0,0,0.15); 338 + padding: 0.75rem; 339 + width: 280px; 340 + } 341 + 342 + .comment-form textarea { 343 + width: 100%; 344 + box-sizing: border-box; 345 + background: var(--bg); 346 + border: 1px solid var(--border); 347 + border-radius: var(--radius); 348 + color: var(--text); 349 + padding: 0.4rem 0.5rem; 350 + font-size: 0.85rem; 351 + resize: vertical; 352 + outline: none; 353 + font-family: inherit; 354 + } 355 + 356 + .comment-form textarea:focus { 357 + border-color: var(--primary); 358 + } 359 + 360 + .comment-form-actions { 361 + display: flex; 362 + gap: 0.5rem; 363 + margin-top: 0.5rem; 364 + justify-content: flex-end; 365 + } 366 + 367 + /* When comment sidebar is visible, shrink editor to avoid overlap */ 368 + .editor-page:has(~ .comment-sidebar) { 369 + right: 260px; 370 + } 371 + 372 + /* ── Collaboration: Invite modal ─────────────────────────────────────────── */ 373 + 374 + .invite-modal { 375 + position: fixed; 376 + inset: 0; 377 + background: rgba(0,0,0,0.4); 378 + display: flex; 379 + align-items: center; 380 + justify-content: center; 381 + z-index: 200; 382 + } 383 + 384 + .invite-modal-box { 385 + background: var(--bg-card); 386 + border: 1px solid var(--border); 387 + border-radius: var(--radius); 388 + box-shadow: 0 8px 32px rgba(0,0,0,0.2); 389 + width: 440px; 390 + max-width: calc(100vw - 2rem); 391 + } 392 + 393 + .invite-modal-header { 394 + display: flex; 395 + align-items: center; 396 + justify-content: space-between; 397 + padding: 0.75rem 1rem; 398 + border-bottom: 1px solid var(--border); 399 + font-weight: 600; 400 + font-size: 0.9rem; 401 + } 402 + 403 + .invite-modal-close { 404 + background: none; 405 + border: none; 406 + color: var(--text-muted); 407 + cursor: pointer; 408 + font-size: 1rem; 409 + padding: 0.1rem 0.3rem; 410 + line-height: 1; 411 + } 412 + 413 + .invite-modal-close:hover { 414 + color: var(--text); 415 + } 416 + 417 + .invite-modal-body { 418 + padding: 1rem; 419 + }
+1
static/vendor/editor.js
··· 30151 30151 ]); 30152 30152 var oneDark = [oneDarkTheme, /* @__PURE__ */ syntaxHighlighting(oneDarkHighlightStyle)]; 30153 30153 export { 30154 + Annotation, 30154 30155 Compartment, 30155 30156 EditorView, 30156 30157 basicSetup,
+1 -1
templates/base.html
··· 33 33 <body> 34 34 <nav class="navbar"> 35 35 <a href="/" class="logo"> 36 - <img src="/static/img/logo-document.svg" alt="" width="22" height="28" style="vertical-align:middle;margin-right:0.4rem">Diffdown 36 + <img src="/static/img/dd-logo.svg" alt="" width="22" height="28" style="vertical-align:middle;margin-right:0.4rem">Diffdown 37 37 </a> 38 38 <div class="nav-right"> 39 39 {{if .User}}
+474 -6
templates/document_edit.html
··· 14 14 <input type="text" id="doc-title" value="{{.Title}}" placeholder="Document title" class="title-input"> 15 15 </div> 16 16 <div class="toolbar-actions"> 17 + {{if .IsCollaborator}} 18 + <div id="presence-list" class="presence-list" title="Active collaborators"></div> 19 + {{end}} 20 + {{if .IsOwner}} 21 + <button class="btn btn-sm btn-outline" id="btn-share" onclick="generateInvite()">Share</button> 22 + {{end}} 17 23 <button class="btn btn-sm btn-outline source-only active" id="btn-preview" onclick="togglePreview()">Preview</button> 18 24 <button class="btn btn-sm btn-outline source-only" id="btn-wrap" onclick="toggleWrap()">Wrap</button> 19 25 <button class="btn btn-sm btn-outline rich-only" id="btn-undo" onclick="richUndo()" title="Undo (⌘Z)">↩</button> ··· 28 34 <!-- Rich text editor (default) --> 29 35 <div id="editor-rich" class="editor-rich"></div> 30 36 37 + <!-- Comment button (shown on paragraph hover/selection) --> 38 + <button id="comment-btn" class="comment-btn" style="display:none" onclick="openCommentForm()">Comment</button> 39 + 40 + <!-- Comment form (floating) --> 41 + <div id="comment-form" class="comment-form" style="display:none"> 42 + <textarea id="comment-text" placeholder="Add a comment..." rows="3"></textarea> 43 + <div class="comment-form-actions"> 44 + <button class="btn btn-sm" onclick="submitComment()">Post</button> 45 + <button class="btn btn-sm btn-outline" onclick="closeCommentForm()">Cancel</button> 46 + </div> 47 + </div> 48 + 31 49 <!-- Link editing tooltip --> 32 50 <div id="link-tooltip" class="link-tooltip"> 33 51 <input type="url" id="link-tooltip-input" placeholder="https://" spellcheck="false"> ··· 47 65 </div> 48 66 </div> 49 67 </div> 68 + 69 + <!-- Invite modal --> 70 + {{if .IsOwner}} 71 + <div id="invite-modal" class="invite-modal" style="display:none"> 72 + <div class="invite-modal-box"> 73 + <div class="invite-modal-header"> 74 + <span>Share document</span> 75 + <button class="invite-modal-close" onclick="closeInviteModal()">✕</button> 76 + </div> 77 + <div id="invite-modal-body" class="invite-modal-body"> 78 + <p>Generating invite link...</p> 79 + </div> 80 + </div> 81 + </div> 82 + {{end}} 83 + 84 + <!-- Comment sidebar --> 85 + {{if .IsCollaborator}} 86 + <div id="comment-sidebar" class="comment-sidebar"> 87 + <div class="comment-sidebar-header">Comments</div> 88 + <div id="comment-threads" class="comment-threads"></div> 89 + </div> 90 + {{end}} 50 91 {{end}} 51 92 {{end}} 52 93 53 94 {{define "scripts"}} 54 95 <script type="module"> 55 - import {EditorView, basicSetup, markdown, oneDark, Compartment} from '/static/vendor/editor.js'; 96 + import {EditorView, basicSetup, markdown, oneDark, Compartment, Annotation} from '/static/vendor/editor.js'; 56 97 import { 57 98 Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx, 58 99 commonmark, ··· 65 106 const saveStatus = document.getElementById('save-status'); 66 107 const titleInput = document.getElementById('doc-title'); 67 108 const rkey = '{{.Content.RKey}}'; 109 + const accessToken = '{{.Content.AccessToken}}'; 110 + const isCollaborator = {{if .Content.IsCollaborator}}true{{else}}false{{end}}; 111 + const ownerDID = '{{.Content.OwnerDID}}'; // empty string when current user is owner 68 112 69 113 const STORAGE_KEY = 'editor-mode'; 70 114 let currentMode = localStorage.getItem(STORAGE_KEY) || 'rich'; // 'rich' | 'source' 71 115 72 116 let autoSaveTimer = null; 117 + 118 + // Annotation to tag dispatches that originate from remote edits, 119 + // so the update listener can skip re-broadcasting them. 120 + const remoteEditAnnotation = Annotation.define(); 73 121 74 122 // ── Shared helpers ──────────────────────────────────────────────────────── 75 123 ··· 88 136 await fetch(`/api/docs/${rkey}/autosave`, { 89 137 method: 'PUT', 90 138 headers: {'Content-Type': 'application/json'}, 91 - body: JSON.stringify({content, title: titleInput.value}), 139 + body: JSON.stringify({content, title: titleInput.value, ownerDID}), 92 140 }); 93 141 saveStatus.textContent = 'Auto-saved'; 94 142 saveStatus.className = 'status-saved'; ··· 132 180 wrapCompartment.of([]), 133 181 EditorView.updateListener.of((update) => { 134 182 if (update.docChanged && currentMode === 'source') { 135 - updatePreview(update.state.doc.toString()); 136 - scheduleAutoSave(update.state.doc.toString()); 183 + const content = update.state.doc.toString(); 184 + updatePreview(content); 185 + if (!update.transactions.some(tr => tr.annotation(remoteEditAnnotation))) { 186 + scheduleAutoSave(content); 187 + // Extract granular deltas from the ChangeSet. 188 + // fromA/toA are positions in the OLD document (pre-change), 189 + // which is what the server's OT engine needs. 190 + const deltas = []; 191 + update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { 192 + deltas.push({ from: fromA, to: toA, insert: inserted.toString() }); 193 + }); 194 + if (deltas.length > 0) { 195 + queueDeltas(deltas); 196 + } 197 + } 137 198 } 138 199 }), 139 200 ], ··· 192 253 .use(listener) 193 254 .config((ctx) => { 194 255 ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => { 195 - if (markdown !== prevMarkdown) { 256 + if (markdown !== prevMarkdown && !applyingRemote) { 196 257 scheduleAutoSave(markdown); 258 + const ops = diffToOps(prevMarkdown || '', markdown); 259 + if (ops.length > 0) { 260 + queueDeltas(ops); 261 + } 197 262 } 198 263 }); 199 264 }) ··· 267 332 const resp = await fetch(`/api/docs/${rkey}/save`, { 268 333 method: 'POST', 269 334 headers: {'Content-Type': 'application/json'}, 270 - body: JSON.stringify({content, title: titleInput.value}), 335 + body: JSON.stringify({content, title: titleInput.value, ownerDID}), 271 336 }); 272 337 if (resp.ok) { 273 338 saveStatus.textContent = 'Saved!'; ··· 390 455 } 391 456 }); 392 457 458 + // ── Invite ──────────────────────────────────────────────────────────────── 459 + 460 + window.generateInvite = async function generateInvite() { 461 + const modal = document.getElementById('invite-modal'); 462 + const body = document.getElementById('invite-modal-body'); 463 + if (!modal) return; 464 + body.innerHTML = '<p>Generating invite link...</p>'; 465 + modal.style.display = 'flex'; 466 + 467 + try { 468 + const resp = await fetch(`/api/docs/${rkey}/invite`, { method: 'POST' }); 469 + const data = await resp.json(); 470 + if (!resp.ok) throw new Error(data.error || resp.statusText); 471 + const link = data.invite_url || data.inviteLink || data.url || ''; 472 + body.innerHTML = ` 473 + <p style="margin:0 0 0.5rem;font-size:0.85rem;color:var(--text-muted)"> 474 + Share this link. It expires in 7 days and can be used once. 475 + </p> 476 + <div style="display:flex;gap:0.5rem;align-items:center"> 477 + <input id="invite-link-input" type="text" value="${escHtml(link)}" readonly 478 + style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius); 479 + padding:0.4rem 0.5rem;font-size:0.85rem;color:var(--text);outline:none"> 480 + <button class="btn btn-sm" onclick="copyInviteLink()">Copy</button> 481 + </div> 482 + <p id="invite-copy-msg" style="margin:0.4rem 0 0;font-size:0.8rem;color:var(--primary);display:none">Copied!</p> 483 + `; 484 + } catch (e) { 485 + body.innerHTML = `<p style="color:var(--danger)">Failed to generate invite: ${escHtml(e.message)}</p>`; 486 + } 487 + } 488 + 489 + window.copyInviteLink = function copyInviteLink() { 490 + const input = document.getElementById('invite-link-input'); 491 + if (!input) return; 492 + navigator.clipboard.writeText(input.value).then(() => { 493 + const msg = document.getElementById('invite-copy-msg'); 494 + if (msg) { msg.style.display = 'block'; setTimeout(() => msg.style.display = 'none', 2000); } 495 + }); 496 + } 497 + 498 + window.closeInviteModal = function closeInviteModal() { 499 + const modal = document.getElementById('invite-modal'); 500 + if (modal) modal.style.display = 'none'; 501 + } 502 + 503 + // Close invite modal on backdrop click 504 + document.getElementById('invite-modal')?.addEventListener('click', e => { 505 + if (e.target === document.getElementById('invite-modal')) closeInviteModal(); 506 + }); 507 + 508 + // ── WebSocket / Collaboration ───────────────────────────────────────────── 509 + 510 + let ws = null; 511 + let wsReconnectDelay = 1000; 512 + let wsReconnectTimer = null; 513 + let wsPingTimer = null; 514 + let wsMissedPings = 0; 515 + 516 + function connectWebSocket() { 517 + if (!accessToken) return; 518 + 519 + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 520 + const ownerParam = ownerDID ? `&owner_did=${encodeURIComponent(ownerDID)}` : ''; 521 + const wsUrl = `${protocol}//${window.location.host}/ws/docs/${rkey}?access_token=${encodeURIComponent(accessToken)}&dpop_proof=placeholder${ownerParam}`; 522 + 523 + ws = new WebSocket(wsUrl); 524 + 525 + ws.onopen = () => { 526 + wsReconnectDelay = 1000; 527 + wsMissedPings = 0; 528 + startHeartbeat(); 529 + }; 530 + 531 + ws.onmessage = (event) => { 532 + try { 533 + const msg = JSON.parse(event.data); 534 + handleWSMessage(msg); 535 + } catch (e) { 536 + console.error('WS parse error:', e); 537 + } 538 + }; 539 + 540 + ws.onclose = () => { 541 + clearTimeout(wsEditTimer); 542 + pendingDeltas = []; 543 + stopHeartbeat(); 544 + ws = null; 545 + updatePresence([]); 546 + scheduleReconnect(); 547 + }; 548 + 549 + ws.onerror = () => { 550 + closeWS(); 551 + }; 552 + } 553 + 554 + function scheduleReconnect() { 555 + clearTimeout(wsReconnectTimer); 556 + wsReconnectTimer = setTimeout(() => { 557 + connectWebSocket(); 558 + wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30000); 559 + }, wsReconnectDelay); 560 + } 561 + 562 + function startHeartbeat() { 563 + stopHeartbeat(); 564 + wsPingTimer = setInterval(() => { 565 + if (ws && ws.readyState === WebSocket.OPEN) { 566 + ws.send(JSON.stringify({ type: 'ping' })); 567 + wsMissedPings++; 568 + if (wsMissedPings >= 3) { 569 + closeWS(); 570 + } 571 + } 572 + }, 30000); 573 + } 574 + 575 + function stopHeartbeat() { 576 + clearInterval(wsPingTimer); 577 + } 578 + 579 + // Guard against applying a remote edit while we're already applying one 580 + // (prevents echo loops). 581 + let applyingRemote = false; 582 + 583 + function handleWSMessage(msg) { 584 + switch (msg.type) { 585 + case 'presence': 586 + updatePresence(msg.users || []); 587 + break; 588 + case 'pong': 589 + wsMissedPings = 0; 590 + break; 591 + case 'edit': 592 + applyRemoteEdit(msg); // pass full message object 593 + break; 594 + case 'sync': 595 + applyRemoteEdit(msg.content); // sync is always full-content string 596 + break; 597 + } 598 + } 599 + 600 + function applyRemoteEdit(msg) { 601 + // msg may be a full-content string (legacy sync path) or an object with content. 602 + if (applyingRemote) return; 603 + applyingRemote = true; 604 + try { 605 + // Always use the server's authoritative content echo for applying remote edits. 606 + // Attempting to apply deltas directly is unreliable because the positions in 607 + // the delta batch are relative to the sender's pre-change document, not the 608 + // receiver's current state (which may have diverged). 609 + const content = typeof msg === 'string' ? msg : msg.content; 610 + if (!content) return; 611 + 612 + if (currentMode === 'source' && cmView) { 613 + if (cmView.state.doc.toString() !== content) { 614 + cmView.dispatch({ 615 + changes: { from: 0, to: cmView.state.doc.length, insert: content }, 616 + annotations: [remoteEditAnnotation.of(true)], 617 + }); 618 + } 619 + updatePreview(content); 620 + } else if (currentMode === 'rich' && milkdownEditor) { 621 + createMilkdownEditor(content); 622 + } 623 + } finally { 624 + applyingRemote = false; 625 + } 626 + } 627 + 628 + // Debounce timer for WebSocket sends (50ms batches rapid keystrokes). 629 + let wsEditTimer = null; 630 + let pendingDeltas = []; 631 + 632 + /** 633 + * Compute the minimal edit operations to transform `oldStr` into `newStr`. 634 + * Returns an array of {from, to, insert} suitable for the OT engine. 635 + * 636 + * Uses a line-level diff for performance, then falls back to a single 637 + * full-replacement op if the diff produces more than 20 operations 638 + * (pathological case — not worth the complexity). 639 + */ 640 + function diffToOps(oldStr, newStr) { 641 + if (oldStr === newStr) return []; 642 + 643 + const oldLines = oldStr.split('\n'); 644 + const newLines = newStr.split('\n'); 645 + 646 + const m = oldLines.length, n = newLines.length; 647 + const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0)); 648 + for (let i = m - 1; i >= 0; i--) { 649 + for (let j = n - 1; j >= 0; j--) { 650 + dp[i][j] = oldLines[i] === newLines[j] 651 + ? dp[i+1][j+1] + 1 652 + : Math.max(dp[i+1][j], dp[i][j+1]); 653 + } 654 + } 655 + 656 + // Line length in old string. Last line has no trailing \n. 657 + const oldLineLengths = oldLines.map((l, idx) => 658 + l.length + (idx < oldLines.length - 1 ? 1 : 0)); 659 + 660 + const ops = []; 661 + let i = 0, j = 0, charOffset = 0; 662 + 663 + while (i < m || j < n) { 664 + if (i < m && j < n && oldLines[i] === newLines[j]) { 665 + // Matching line — advance past it. 666 + charOffset += oldLineLengths[i]; 667 + i++; j++; 668 + } else { 669 + // Non-matching run: collect all consecutive deletions and 670 + // insertions into a single replace op so positions stay consistent. 671 + const hunkFrom = charOffset; 672 + let hunkTo = charOffset; 673 + let hunkInsert = ''; 674 + 675 + while (i < m && (j >= n || dp[i][j] === dp[i+1][j])) { 676 + hunkTo += oldLineLengths[i]; 677 + charOffset += oldLineLengths[i]; 678 + i++; 679 + } 680 + while (j < n && (i >= m || dp[i][j] === dp[i][j+1])) { 681 + hunkInsert += newLines[j] + (j < newLines.length - 1 || hunkTo > hunkFrom ? '\n' : ''); 682 + j++; 683 + } 684 + 685 + // Only emit if something actually changed. 686 + if (hunkTo > hunkFrom || hunkInsert !== '') { 687 + ops.push({ from: hunkFrom, to: hunkTo, insert: hunkInsert }); 688 + } 689 + } 690 + } 691 + 692 + if (ops.length > 20) return [{ from: 0, to: -1, insert: newStr }]; 693 + return ops; 694 + } 695 + 696 + // Queue a set of deltas and flush after a short debounce. 697 + function queueDeltas(deltas) { 698 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 699 + pendingDeltas = pendingDeltas.concat(deltas); 700 + clearTimeout(wsEditTimer); 701 + wsEditTimer = setTimeout(flushDeltas, 50); 702 + } 703 + 704 + function flushDeltas() { 705 + if (!ws || ws.readyState !== WebSocket.OPEN || pendingDeltas.length === 0) { 706 + pendingDeltas = []; 707 + return; 708 + } 709 + ws.send(JSON.stringify({ type: 'edit', deltas: pendingDeltas })); 710 + pendingDeltas = []; 711 + } 712 + 713 + function closeWS() { 714 + if (!ws) return; 715 + clearTimeout(wsEditTimer); 716 + flushDeltas(); // send any buffered deltas before closing 717 + ws.close(); 718 + ws = null; 719 + stopHeartbeat(); 720 + } 721 + 722 + // sendEdit sends a full-document replacement via the granular-delta path. 723 + // to: -1 is a sentinel meaning "end of document" — the server OT engine 724 + // clamps it to len(documentText), and applyRemoteEdit clamps it to docLen. 725 + // Milkdown switches to diffToOps in Chunk 4 and no longer calls this directly. 726 + function sendEdit(content) { 727 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 728 + queueDeltas([{ from: 0, to: -1, insert: content }]); 729 + } 730 + 731 + // ── Presence ────────────────────────────────────────────────────────────── 732 + 733 + function updatePresence(users) { 734 + const list = document.getElementById('presence-list'); 735 + if (!list) return; 736 + list.innerHTML = users.map(u => ` 737 + <span class="presence-avatar" style="background:${u.color}" title="${escHtml(u.name || u.did)}"></span> 738 + `).join(''); 739 + } 740 + 741 + function escHtml(str) { 742 + return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 743 + } 744 + 745 + // ── Comments ────────────────────────────────────────────────────────────── 746 + 747 + let activeCommentParagraphId = null; 748 + 749 + const commentBtn = document.getElementById('comment-btn'); 750 + const commentForm = document.getElementById('comment-form'); 751 + const commentTextEl = document.getElementById('comment-text'); 752 + 753 + window.openCommentForm = function openCommentForm() { 754 + if (!commentBtn || !commentForm) return; 755 + const rect = commentBtn.getBoundingClientRect(); 756 + commentForm.style.top = rect.bottom + window.scrollY + 4 + 'px'; 757 + commentForm.style.left = Math.max(8, rect.left) + 'px'; 758 + commentForm.style.display = 'block'; 759 + commentTextEl.value = ''; 760 + commentTextEl.focus(); 761 + } 762 + 763 + window.closeCommentForm = function closeCommentForm() { 764 + if (commentForm) commentForm.style.display = 'none'; 765 + if (commentBtn) commentBtn.style.display = 'none'; 766 + activeCommentParagraphId = null; 767 + } 768 + 769 + window.submitComment = async function submitComment() { 770 + if (!activeCommentParagraphId) return; 771 + const text = commentTextEl.value.trim(); 772 + if (!text) return; 773 + 774 + try { 775 + const resp = await fetch(`/api/docs/${rkey}/comments`, { 776 + method: 'POST', 777 + headers: { 'Content-Type': 'application/json' }, 778 + body: JSON.stringify({ paragraphId: activeCommentParagraphId, text }), 779 + }); 780 + if (!resp.ok) throw new Error(await resp.text()); 781 + closeCommentForm(); 782 + loadComments(); 783 + } catch (e) { 784 + console.error('Comment post failed:', e); 785 + } 786 + } 787 + 788 + commentTextEl && commentTextEl.addEventListener('keydown', e => { 789 + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitComment(); 790 + if (e.key === 'Escape') closeCommentForm(); 791 + }); 792 + 793 + // Close comment form on outside click 794 + document.addEventListener('click', e => { 795 + if (commentForm && commentForm.style.display !== 'none') { 796 + if (!commentForm.contains(e.target) && e.target !== commentBtn) { 797 + closeCommentForm(); 798 + } 799 + } 800 + }); 801 + 802 + function renderCommentThreads(comments) { 803 + const container = document.getElementById('comment-threads'); 804 + if (!container) return; 805 + 806 + if (!comments || comments.length === 0) { 807 + container.innerHTML = '<p class="comment-empty">No comments yet.</p>'; 808 + return; 809 + } 810 + 811 + // Group by paragraphId 812 + const byParagraph = {}; 813 + for (const c of comments) { 814 + const pid = c.paragraphId || 'general'; 815 + if (!byParagraph[pid]) byParagraph[pid] = []; 816 + byParagraph[pid].push(c); 817 + } 818 + 819 + container.innerHTML = Object.entries(byParagraph).map(([pid, thread]) => ` 820 + <div class="comment-thread" data-paragraph="${escHtml(pid)}"> 821 + <div class="comment-thread-label">¶ ${escHtml(pid)}</div> 822 + ${thread.map(c => ` 823 + <div class="comment-item"> 824 + <div class="comment-author">${escHtml(c.authorName || c.author)}</div> 825 + <div class="comment-text">${escHtml(c.text)}</div> 826 + <div class="comment-time">${formatTime(c.createdAt)}</div> 827 + </div> 828 + `).join('')} 829 + </div> 830 + `).join(''); 831 + } 832 + 833 + function formatTime(ts) { 834 + if (!ts) return ''; 835 + try { return new Date(ts).toLocaleString(); } catch { return ts; } 836 + } 837 + 838 + async function loadComments() { 839 + if (!accessToken) return; 840 + try { 841 + const resp = await fetch(`/api/docs/${rkey}/comments`); 842 + if (!resp.ok) return; 843 + const comments = await resp.json(); 844 + renderCommentThreads(comments); 845 + } catch (e) { 846 + console.error('Load comments failed:', e); 847 + } 848 + } 849 + 393 850 // ── Init ────────────────────────────────────────────────────────────────── 394 851 395 852 const initialMarkdown = textarea.value; ··· 403 860 } 404 861 405 862 applyMode(currentMode); 863 + 864 + // Start collaboration features (both owner and collaborators join the WS room) 865 + if (accessToken) { 866 + connectWebSocket(); 867 + loadComments(); 868 + } 869 + 870 + window.addEventListener('beforeunload', () => { 871 + clearTimeout(wsEditTimer); 872 + flushDeltas(); 873 + }); 406 874 </script> 407 875 {{end}}
+4
templates/document_view.html
··· 13 13 {{else if .Doc.CreatedAt}}<time>Created {{.Doc.CreatedAt | fmtdate}}</time>{{end}} 14 14 </div> 15 15 <div class="file-actions"> 16 + {{if .OwnerDID}} 17 + <a href="/docs/{{.OwnerDID}}/{{.Doc.RKey}}/edit" class="btn btn-sm">Edit</a> 18 + {{else}} 16 19 <a href="/docs/{{.Doc.RKey}}/edit" class="btn btn-sm">Edit</a> 17 20 <button class="btn btn-sm btn-outline btn-danger" onclick="deleteDocument('{{.Doc.RKey}}')">Delete</button> 21 + {{end}} 18 22 </div> 19 23 </div> 20 24 <div class="markdown-body">
+2 -2
templates/landing.html
··· 2 2 {{define "content"}} 3 3 <div class="landing"> 4 4 <h1>Collaborative Markdown Editing</h1> 5 - <p>Write, review, and collaborate on Markdown documents with your team. Uh, eventually. Right now, you can only write and edit solo documents.</p> 6 - <p><strong>Note:</strong> This app is a toy I built to learn <a href="https://atproto.com/">AT Protocol</a>, lexicons, and <a href="https://claude.com/">Claude Code</a>. It is not meant for actual use. Any documents you create will be visible to anyone with the URL and may be deleted at any time.</p> 5 + <p>Write, review, and collaborate on Markdown documents with your team in <a href="https://www.bskyinfo.com/glossary/atmosphere/">the ATmosphere</a>.</p> 6 + <p><strong>Note:</strong> This app is a toy I built to learn <a href="https://atproto.com/">AT Protocol</a>, lexicons, and <a href="https://agentic-coding.github.io/">agentic coding</a>. It is not meant for actual use. Any documents you create will be visible to anyone with the URL and may be deleted at any time.</p> 7 7 <div class="landing-actions"> 8 8 <a href="/auth/register" class="btn btn-lg">Get Started</a> 9 9 <a href="/auth/login" class="btn btn-lg btn-outline">Log In</a>