a proof of concept realtime collaborative todo list
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}