a proof of concept realtime collaborative todo list
0

Configure Feed

Select the types of activity you want to include in your feed.

atodo / list.js
22 kB 659 lines
1import { Component } from "preact"; 2import { html } from "htm/preact"; 3import { signal } from "@preact/signals"; 4import { 5 session, 6 navigate, 7 BASE, 8 LIST_NSID, 9 ITEM_NSID, 10 TOMBSTONE_NSID, 11 parseAtUri, 12 listName, 13 listIsOwned, 14 listEditors, 15 showEditorsModal, 16} from "./state.js"; 17import { 18 listRecords, 19 getRecord, 20 tid, 21 resolveHandleToDid, 22 resolveHandle, 23} from "./atproto.js"; 24import { mergeRecords, applyUpdates, crdtEqual } from "./crdt.js"; 25import { subscribe } from "./subscribe.js"; 26import { safePut, safeDelete, setCid, getQueue } from "./offline.js"; 27 28// Fractional indexing. atproto's data model forbids floats, so order can't be a 29// numeric midpoint — instead `index` is a lexicographically-sortable string key, 30// and reordering writes a new key strictly between the two neighbors. Only the 31// moved item's `index` changes, so concurrent reorders by different editors 32// don't clobber each other. 33const KEY_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"; 34 35// Generate a key strictly between `a` (lower bound, null = start) and `b` (upper 36// bound, null = end), lexicographically. There's always room for another key 37// between any two, so reordering never needs to renumber neighbors. 38function generateKeyBetween(a, b) { 39 const lower = a ?? ""; 40 let upper = b ?? null; 41 let prefix = ""; 42 for (let i = 0; ; i++) { 43 if (upper != null && i >= upper.length) upper = null; // equal bounds: degrade to "after" 44 const ia = KEY_DIGITS.indexOf(i < lower.length ? lower[i] : KEY_DIGITS[0]); 45 const ib = upper != null ? KEY_DIGITS.indexOf(upper[i]) : KEY_DIGITS.length; 46 if (ib - ia > 1) return prefix + KEY_DIGITS[ia + ((ib - ia) >> 1)]; 47 prefix += KEY_DIGITS[ia]; 48 if (ib - ia === 1) upper = null; // adjacent digits: unbounded room below this one 49 } 50} 51 52// Order items by `index`. Legacy records carry a numeric index; those sort 53// before any string key until migrateIndices() rewrites them (see load()). 54function cmpIndex(a, b) { 55 const sa = typeof a === "string"; 56 const sb = typeof b === "string"; 57 if (sa && sb) return a < b ? -1 : a > b ? 1 : 0; 58 if (!sa && !sb) return (a ?? 0) - (b ?? 0); 59 return sa ? 1 : -1; 60} 61 62class ItemRow extends Component { 63 editing = signal(false); 64 text = signal(""); 65 66 onToggle = () => { 67 this.props.onUpdate(this.props.item.rkey, { done: !this.props.item.done }); 68 }; 69 70 onDragStart = (e) => { 71 e.dataTransfer.effectAllowed = "move"; 72 e.dataTransfer.setData("text/plain", this.props.item.rkey); 73 this.props.onDragStart(this.props.item.rkey); 74 }; 75 76 onDragOver = (e) => { 77 if (!this.props.onDragOver) return; 78 e.preventDefault(); 79 e.dataTransfer.dropEffect = "move"; 80 const rect = e.currentTarget.getBoundingClientRect(); 81 const after = e.clientY > rect.top + rect.height / 2; 82 this.props.onDragOver(this.props.item.rkey, after); 83 }; 84 85 onDrop = (e) => { 86 e.preventDefault(); 87 this.props.onDrop(); 88 }; 89 90 startEdit = () => { 91 this.text.value = this.props.item.text ?? ""; 92 this.editing.value = true; 93 }; 94 95 commitEdit = () => { 96 this.editing.value = false; 97 if (this.text.value !== this.props.item.text) { 98 this.props.onUpdate(this.props.item.rkey, { text: this.text.value }); 99 } 100 }; 101 102 onKey = (e) => { 103 if (e.key === "Enter") { 104 e.preventDefault(); 105 this.commitEdit(); 106 } else if (e.key === "Escape") { 107 this.editing.value = false; 108 } 109 }; 110 111 render() { 112 const { item, drop } = this.props; 113 const cls = 114 "item" + 115 (item.done ? " done" : "") + 116 (this.props.dragging ? " dragging" : "") + 117 (drop === "before" ? " drop-before" : drop === "after" ? " drop-after" : ""); 118 return html` 119 <li 120 class=${cls} 121 draggable=${!this.editing.value} 122 onDragStart=${this.onDragStart} 123 onDragOver=${this.onDragOver} 124 onDrop=${this.onDrop} 125 onDragEnd=${this.props.onDragEnd} 126 > 127 <span class="grip" aria-hidden="true">⠿</span> 128 <input type="checkbox" checked=${!!item.done} onChange=${this.onToggle} /> 129 ${this.editing.value 130 ? html`<input 131 class="edit" 132 autofocus 133 value=${this.text.value} 134 onInput=${(e) => (this.text.value = e.target.value)} 135 onBlur=${this.commitEdit} 136 onKeyDown=${this.onKey} 137 />` 138 : html`<span class="text" onClick=${this.startEdit} 139 >${item.text || html`<em class="empty">empty</em>`}</span 140 >`} 141 <button class="x" onClick=${() => this.props.onDelete(item.rkey)}>×</button> 142 </li> 143 `; 144 } 145} 146 147class EditorsModal extends Component { 148 handle = signal(""); 149 busy = signal(false); 150 error = signal(""); 151 // did -> handle, resolved lazily; falls back to the did until resolved. 152 handles = signal(new Map()); 153 154 componentDidMount() { 155 this.resolveHandles(); 156 } 157 158 componentDidUpdate() { 159 this.resolveHandles(); 160 } 161 162 resolveHandles() { 163 for (const did of this.props.editors) { 164 if (this.handles.value.has(did)) continue; 165 this.handles.value = new Map(this.handles.value).set(did, did); 166 resolveHandle(did).then((handle) => { 167 this.handles.value = new Map(this.handles.value).set(did, handle); 168 }); 169 } 170 } 171 172 add = async (e) => { 173 e.preventDefault(); 174 const input = this.handle.value.trim(); 175 if (!input) return; 176 this.busy.value = true; 177 this.error.value = ""; 178 try { 179 const did = input.startsWith("did:") ? input : await resolveHandleToDid(input); 180 const editors = this.props.editors.includes(did) 181 ? this.props.editors 182 : [...this.props.editors, did]; 183 await this.props.onSave(editors); 184 this.handle.value = ""; 185 } catch (err) { 186 this.error.value = err.message; 187 } finally { 188 this.busy.value = false; 189 } 190 }; 191 192 remove = async (did) => { 193 try { 194 await this.props.onSave(this.props.editors.filter((e) => e !== did)); 195 } catch (err) { 196 this.error.value = err.message; 197 } 198 }; 199 200 render() { 201 return html` 202 <div class="modal-bg" onClick=${this.props.onClose}> 203 <div class="modal" onClick=${(e) => e.stopPropagation()}> 204 <h3>Editors</h3> 205 ${this.props.editors.length === 0 206 ? html`<p class="empty">No editors yet.</p>` 207 : html`<ul class="editors"> 208 ${this.props.editors.map( 209 (did) => html` 210 <li key=${did}> 211 <code title=${did}>${this.handles.value.get(did) || did}</code> 212 <button onClick=${() => this.remove(did)}>Remove</button> 213 </li> 214 ` 215 )} 216 </ul>`} 217 <form onSubmit=${this.add}> 218 <input 219 placeholder="handle or did" 220 value=${this.handle.value} 221 onInput=${(e) => (this.handle.value = e.target.value)} 222 disabled=${this.busy.value} 223 /> 224 <button disabled=${this.busy.value}>Add</button> 225 </form> 226 ${this.error.value && html`<p class="error">${this.error.value}</p>`} 227 <button class="close" onClick=${this.props.onClose}>Close</button> 228 </div> 229 </div> 230 `; 231 } 232} 233 234export class ListView extends Component { 235 listRecord = signal(null); 236 loadError = signal(""); 237 itemsLoaded = signal(false); 238 239 // Map<did, Map<rkey, recordValue>> of item records for this list 240 itemsByDid = signal(new Map()); 241 // Set<rkey> of tombstoned items (from any editor) 242 tombstones = signal(new Set()); 243 // Set<rkey> of items for which WE have written a tombstone record 244 myTombstoneItems = signal(new Set()); 245 246 newItemText = signal(""); 247 248 // Drag-to-reorder state 249 dragRkey = signal(null); 250 dragOverRkey = signal(null); 251 dragOverAfter = signal(false); 252 253 unsubscribe = null; 254 255 async componentDidMount() { 256 await this.load(); 257 } 258 259 componentWillUnmount() { 260 if (this.unsubscribe) this.unsubscribe(); 261 listName.value = ""; 262 listIsOwned.value = false; 263 listEditors.value = []; 264 showEditorsModal.value = false; 265 } 266 267 async componentDidUpdate(prevProps) { 268 if (prevProps.uri !== this.props.uri) { 269 if (this.unsubscribe) this.unsubscribe(); 270 this.unsubscribe = null; 271 this.listRecord.value = null; 272 this.itemsLoaded.value = false; 273 this.itemsByDid.value = new Map(); 274 this.tombstones.value = new Set(); 275 this.myTombstoneItems.value = new Set(); 276 await this.load(); 277 } 278 } 279 280 parsed() { 281 return parseAtUri(this.props.uri); 282 } 283 284 async load() { 285 const parsed = this.parsed(); 286 if (!parsed) { 287 this.loadError.value = "Invalid URI"; 288 return; 289 } 290 const { did: ownerDid, rkey: listRkey } = parsed; 291 const me = session.value.did; 292 293 try { 294 const ownerRec = await getRecord(ownerDid, LIST_NSID, listRkey); 295 if (!ownerRec) { 296 this.loadError.value = "List not found"; 297 return; 298 } 299 this.listRecord.value = ownerRec.value; 300 listName.value = ownerRec.value.name || "(unnamed)"; 301 listIsOwned.value = ownerDid === me && ownerRec.value.editors !== undefined; 302 listEditors.value = ownerRec.value.editors || []; 303 if (ownerDid === me) setCid(LIST_NSID, listRkey, ownerRec.cid); 304 305 // Auto-create a reference record in our own repo if we're not the owner 306 if (ownerDid !== me) { 307 const myLists = await listRecords(me, LIST_NSID); 308 const hasRef = myLists.some((r) => r.value.ref === this.props.uri); 309 if (!hasRef) await safePut(LIST_NSID, tid(), { ref: this.props.uri }); 310 } 311 312 const editors = ownerRec.value.editors || []; 313 const dids = Array.from(new Set([ownerDid, ...editors, me])); 314 315 const itemsByDid = new Map(); 316 const tombstones = new Set(); 317 const myTombItems = new Set(); // item rkeys for which we already own a tombstone 318 await Promise.all( 319 dids.map(async (did) => { 320 const [items, tombs] = await Promise.all([ 321 listRecords(did, ITEM_NSID).catch(() => []), 322 listRecords(did, TOMBSTONE_NSID).catch(() => []), 323 ]); 324 const map = new Map(); 325 for (const r of items) { 326 if (r.value.listId !== listRkey) continue; 327 const rkey = r.uri.split("/").pop(); 328 map.set(rkey, r.value); 329 if (did === me) setCid(ITEM_NSID, rkey, r.cid); 330 } 331 itemsByDid.set(did, map); 332 for (const t of tombs) { 333 if (t.value.item) { 334 tombstones.add(t.value.item); 335 if (did === me) myTombItems.add(t.value.item); 336 } 337 } 338 }) 339 ); 340 341 // Reflect any buffered offline writes for this list so the UI shows 342 // them on reload, before the queue actually flushes. 343 const me_inner = itemsByDid.get(me) || new Map(); 344 for (const e of getQueue()) { 345 if (e.op !== "put") continue; 346 if (e.collection === ITEM_NSID && e.record?.listId === listRkey) { 347 me_inner.set(e.rkey, e.record); 348 } else if (e.collection === TOMBSTONE_NSID && e.record?.item) { 349 tombstones.add(e.record.item); 350 myTombItems.add(e.record.item); 351 } 352 } 353 itemsByDid.set(me, me_inner); 354 355 this.itemsByDid.value = itemsByDid; 356 this.tombstones.value = tombstones; 357 this.itemsLoaded.value = true; 358 359 // Replicate any tombstones from other repos that we don't yet have. 360 // Done before subscribing so the writes are in flight before live events arrive. 361 for (const rkey of tombstones) { 362 if (!myTombItems.has(rkey)) { 363 myTombItems.add(rkey); 364 safePut(TOMBSTONE_NSID, tid(), { item: rkey }).catch(() => {}); 365 } 366 } 367 this.myTombstoneItems.value = myTombItems; 368 369 this.unsubscribe = await subscribe({ 370 dids, 371 collections: [LIST_NSID, ITEM_NSID, TOMBSTONE_NSID], 372 onCommit: this.onCommit, 373 }); 374 375 // After the initial fetch, write the merged state into our own repo for 376 // any items where we're missing peer updates. 377 for (const rkey of new Set( 378 [...itemsByDid.values()].flatMap((m) => [...m.keys()]) 379 )) { 380 if (tombstones.has(rkey)) { 381 if (itemsByDid.get(me)?.has(rkey)) safeDelete(ITEM_NSID, rkey).catch(() => {}); 382 continue; 383 } 384 this.syncOwnRecord(rkey); 385 } 386 387 // Convert any legacy numeric indices to string keys (one-time, idempotent). 388 this.migrateIndices().catch((err) => console.error("migrateIndices", err)); 389 } catch (err) { 390 this.loadError.value = err.message; 391 } 392 } 393 394 // Rewrite legacy numeric `index` values as fractional string keys, preserving 395 // current order. Runs after load; no-ops once every item already has a string. 396 async migrateIndices() { 397 const items = this.mergedItems(); 398 if (items.every((it) => typeof it.index === "string")) return; 399 let prev = null; 400 for (let i = 0; i < items.length; i++) { 401 const it = items[i]; 402 if (typeof it.index === "string") { 403 prev = it.index; 404 continue; 405 } 406 let next = null; 407 for (let j = i + 1; j < items.length; j++) { 408 if (typeof items[j].index === "string") { 409 next = items[j].index; 410 break; 411 } 412 } 413 const key = generateKeyBetween(prev, next); 414 prev = key; 415 await this.onUpdate(it.rkey, { index: key }); 416 } 417 } 418 419 onCommit = ({ did, collection, rkey, cid, action, record }) => { 420 const parsed = this.parsed(); 421 if (!parsed) return; 422 423 if (did === session.value.did) { 424 setCid(collection, rkey, action === "delete" ? null : cid); 425 } 426 427 if (collection === LIST_NSID) { 428 if (did === parsed.did && rkey === parsed.rkey) { 429 this.listRecord.value = action === "delete" ? null : record; 430 } 431 return; 432 } 433 if (collection === ITEM_NSID) { 434 const next = new Map(this.itemsByDid.value); 435 const inner = new Map(next.get(did) || []); 436 if (action === "delete") inner.delete(rkey); 437 else if (record && record.listId === parsed.rkey) { 438 // Merge with whatever's already in the cache so an out-of-order or 439 // delayed firehose echo can't regress our state to an older clock. 440 const current = inner.get(rkey); 441 inner.set(rkey, current ? mergeRecords([current, record]) : record); 442 } else inner.delete(rkey); 443 next.set(did, inner); 444 this.itemsByDid.value = next; 445 if (did !== session.value.did && action !== "delete" && !this.tombstones.value.has(rkey)) this.syncOwnRecord(rkey); 446 return; 447 } 448 if (collection === TOMBSTONE_NSID) { 449 if (action !== "delete" && record?.item) { 450 const ts = new Set(this.tombstones.value); 451 ts.add(record.item); 452 this.tombstones.value = ts; 453 const me = session.value.did; 454 if (this.itemsByDid.value.get(me)?.has(record.item)) { 455 safeDelete(ITEM_NSID, record.item).catch(() => {}); 456 } 457 if (did !== session.value.did && !this.myTombstoneItems.value.has(record.item)) { 458 this.myTombstoneItems.value = new Set([...this.myTombstoneItems.value, record.item]); 459 safePut(TOMBSTONE_NSID, tid(), { item: record.item }).catch(() => {}); 460 } 461 } 462 } 463 }; 464 465 mergedFor(rkey) { 466 const versions = []; 467 for (const m of this.itemsByDid.value.values()) { 468 const r = m.get(rkey); 469 if (r) versions.push(r); 470 } 471 return mergeRecords(versions); 472 } 473 474 mergedItems() { 475 const rkeys = new Set(); 476 for (const m of this.itemsByDid.value.values()) for (const k of m.keys()) rkeys.add(k); 477 const tomb = this.tombstones.value; 478 const out = []; 479 for (const rkey of rkeys) { 480 if (tomb.has(rkey)) continue; 481 const merged = this.mergedFor(rkey); 482 if (merged) out.push({ rkey, ...merged }); 483 } 484 // rkey breaks index ties so every editor renders the same order. 485 out.sort((a, b) => cmpIndex(a.index, b.index) || a.rkey.localeCompare(b.rkey)); 486 return out; 487 } 488 489 // If the merged state for this rkey is ahead of our own record, write it 490 // back so every editor's repo converges to the same data. 491 async syncOwnRecord(rkey) { 492 const me = session.value.did; 493 const merged = this.mergedFor(rkey); 494 if (!merged) return; 495 const mine = this.itemsByDid.value.get(me)?.get(rkey); 496 if (mine && crdtEqual(mine.$crdt, merged.$crdt)) return; 497 this.applyLocal(rkey, merged); 498 try { 499 await safePut(ITEM_NSID, rkey, merged); 500 } catch (err) { 501 console.error("syncOwnRecord", rkey, err); 502 } 503 } 504 505 // Optimistically update our local cache so the UI reacts immediately while 506 // the network request settles; the firehose event will reconcile later. 507 applyLocal(rkey, record) { 508 const next = new Map(this.itemsByDid.value); 509 const me = session.value.did; 510 const inner = new Map(next.get(me) || []); 511 inner.set(rkey, record); 512 next.set(me, inner); 513 this.itemsByDid.value = next; 514 } 515 516 onAdd = async (e) => { 517 e.preventDefault(); 518 const text = this.newItemText.value.trim(); 519 if (!text) return; 520 this.newItemText.value = ""; 521 const parsed = this.parsed(); 522 const me = session.value.did; 523 const items = this.mergedItems(); 524 const last = items[items.length - 1]; 525 const lastKey = typeof last?.index === "string" ? last.index : null; 526 const record = applyUpdates( 527 null, 528 { listId: parsed.rkey, text, done: false, index: generateKeyBetween(lastKey, null) }, 529 me 530 ); 531 const rkey = tid(); 532 this.applyLocal(rkey, record); 533 try { 534 await safePut(ITEM_NSID, rkey, record); 535 } catch (err) { 536 this.loadError.value = err.message; 537 } 538 }; 539 540 onUpdate = async (rkey, updates) => { 541 const me = session.value.did; 542 const merged = this.mergedFor(rkey); 543 if (!merged) return; 544 const record = applyUpdates(merged, updates, me); 545 this.applyLocal(rkey, record); 546 try { 547 await safePut(ITEM_NSID, rkey, record); 548 } catch (err) { 549 this.loadError.value = err.message; 550 } 551 }; 552 553 onDragStart = (rkey) => { 554 this.dragRkey.value = rkey; 555 }; 556 557 onDragOver = (rkey, after) => { 558 this.dragOverRkey.value = rkey; 559 this.dragOverAfter.value = after; 560 }; 561 562 onDragEnd = () => { 563 this.dragRkey.value = null; 564 this.dragOverRkey.value = null; 565 }; 566 567 onDrop = async () => { 568 const source = this.dragRkey.value; 569 const target = this.dragOverRkey.value; 570 const after = this.dragOverAfter.value; 571 this.onDragEnd(); 572 if (!source || !target || source === target) return; 573 574 // Find where the dragged item lands among its peers, ignoring its own 575 // current slot, then index it between the new neighbors. 576 const order = this.mergedItems().filter((i) => i.rkey !== source); 577 const targetPos = order.findIndex((i) => i.rkey === target); 578 if (targetPos === -1) return; 579 const insertAt = after ? targetPos + 1 : targetPos; 580 const before = order[insertAt - 1]; 581 const next = order[insertAt]; 582 if (before?.rkey === source || next?.rkey === source) return; 583 const a = typeof before?.index === "string" ? before.index : null; 584 const b = typeof next?.index === "string" ? next.index : null; 585 await this.onUpdate(source, { index: generateKeyBetween(a, b) }); 586 }; 587 588 onDeleteItem = async (rkey) => { 589 const me = session.value.did; 590 const ts = new Set(this.tombstones.value); 591 ts.add(rkey); 592 this.tombstones.value = ts; 593 try { 594 if (this.itemsByDid.value.get(me)?.has(rkey)) { 595 await safeDelete(ITEM_NSID, rkey).catch(() => {}); 596 } 597 await safePut(TOMBSTONE_NSID, tid(), { item: rkey }); 598 } catch (err) { 599 this.loadError.value = err.message; 600 } 601 }; 602 603 onSaveEditors = async (editors) => { 604 const parsed = this.parsed(); 605 const next = { ...this.listRecord.value, editors }; 606 await safePut(LIST_NSID, parsed.rkey, next); 607 this.listRecord.value = next; 608 listEditors.value = editors; 609 }; 610 611 render() { 612 if (this.loadError.value) 613 return html`<p class="error">${this.loadError.value}</p>`; 614 if (!this.listRecord.value) return html`<p>Loading…</p>`; 615 616 const items = this.mergedItems(); 617 618 return html` 619 <form onSubmit=${this.onAdd}> 620 <input 621 placeholder="New item" 622 value=${this.newItemText.value} 623 onInput=${(e) => (this.newItemText.value = e.target.value)} 624 /> 625 <button>Add</button> 626 </form> 627 ${!this.itemsLoaded.value 628 ? html`<p class="empty">Loading…</p>` 629 : items.length === 0 630 ? html`<p class="empty">No items yet.</p>` 631 : html`<ul class="items"> 632 ${items.map( 633 (item) => html`<${ItemRow} 634 key=${item.rkey} 635 item=${item} 636 onUpdate=${this.onUpdate} 637 onDelete=${this.onDeleteItem} 638 onDragStart=${this.onDragStart} 639 onDragOver=${this.onDragOver} 640 onDrop=${this.onDrop} 641 onDragEnd=${this.onDragEnd} 642 dragging=${this.dragRkey.value === item.rkey} 643 drop=${this.dragOverRkey.value === item.rkey 644 ? this.dragOverAfter.value 645 ? "after" 646 : "before" 647 : null} 648 />` 649 )} 650 </ul>`} 651 ${showEditorsModal.value && 652 html`<${EditorsModal} 653 editors=${this.listRecord.value.editors || []} 654 onSave=${this.onSaveEditors} 655 onClose=${() => (showEditorsModal.value = false)} 656 />`} 657 `; 658 } 659}