import { Component } from "preact"; import { html } from "htm/preact"; import { signal } from "@preact/signals"; import { session, navigate, BASE, LIST_NSID, ITEM_NSID, TOMBSTONE_NSID, parseAtUri, listName, listIsOwned, listEditors, showEditorsModal, } from "./state.js"; import { listRecords, getRecord, tid, resolveHandleToDid, resolveHandle, } from "./atproto.js"; import { mergeRecords, applyUpdates, crdtEqual } from "./crdt.js"; import { subscribe } from "./subscribe.js"; import { safePut, safeDelete, setCid, getQueue } from "./offline.js"; // Fractional indexing. atproto's data model forbids floats, so order can't be a // numeric midpoint — instead `index` is a lexicographically-sortable string key, // and reordering writes a new key strictly between the two neighbors. Only the // moved item's `index` changes, so concurrent reorders by different editors // don't clobber each other. const KEY_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"; // Generate a key strictly between `a` (lower bound, null = start) and `b` (upper // bound, null = end), lexicographically. There's always room for another key // between any two, so reordering never needs to renumber neighbors. function generateKeyBetween(a, b) { const lower = a ?? ""; let upper = b ?? null; let prefix = ""; for (let i = 0; ; i++) { if (upper != null && i >= upper.length) upper = null; // equal bounds: degrade to "after" const ia = KEY_DIGITS.indexOf(i < lower.length ? lower[i] : KEY_DIGITS[0]); const ib = upper != null ? KEY_DIGITS.indexOf(upper[i]) : KEY_DIGITS.length; if (ib - ia > 1) return prefix + KEY_DIGITS[ia + ((ib - ia) >> 1)]; prefix += KEY_DIGITS[ia]; if (ib - ia === 1) upper = null; // adjacent digits: unbounded room below this one } } // Order items by `index`. Legacy records carry a numeric index; those sort // before any string key until migrateIndices() rewrites them (see load()). function cmpIndex(a, b) { const sa = typeof a === "string"; const sb = typeof b === "string"; if (sa && sb) return a < b ? -1 : a > b ? 1 : 0; if (!sa && !sb) return (a ?? 0) - (b ?? 0); return sa ? 1 : -1; } class ItemRow extends Component { editing = signal(false); text = signal(""); onToggle = () => { this.props.onUpdate(this.props.item.rkey, { done: !this.props.item.done }); }; onDragStart = (e) => { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", this.props.item.rkey); this.props.onDragStart(this.props.item.rkey); }; onDragOver = (e) => { if (!this.props.onDragOver) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const rect = e.currentTarget.getBoundingClientRect(); const after = e.clientY > rect.top + rect.height / 2; this.props.onDragOver(this.props.item.rkey, after); }; onDrop = (e) => { e.preventDefault(); this.props.onDrop(); }; startEdit = () => { this.text.value = this.props.item.text ?? ""; this.editing.value = true; }; commitEdit = () => { this.editing.value = false; if (this.text.value !== this.props.item.text) { this.props.onUpdate(this.props.item.rkey, { text: this.text.value }); } }; onKey = (e) => { if (e.key === "Enter") { e.preventDefault(); this.commitEdit(); } else if (e.key === "Escape") { this.editing.value = false; } }; render() { const { item, drop } = this.props; const cls = "item" + (item.done ? " done" : "") + (this.props.dragging ? " dragging" : "") + (drop === "before" ? " drop-before" : drop === "after" ? " drop-after" : ""); return html`
  • ${this.editing.value ? html` (this.text.value = e.target.value)} onBlur=${this.commitEdit} onKeyDown=${this.onKey} />` : html`${item.text || html`empty`}`}
  • `; } } class EditorsModal extends Component { handle = signal(""); busy = signal(false); error = signal(""); // did -> handle, resolved lazily; falls back to the did until resolved. handles = signal(new Map()); componentDidMount() { this.resolveHandles(); } componentDidUpdate() { this.resolveHandles(); } resolveHandles() { for (const did of this.props.editors) { if (this.handles.value.has(did)) continue; this.handles.value = new Map(this.handles.value).set(did, did); resolveHandle(did).then((handle) => { this.handles.value = new Map(this.handles.value).set(did, handle); }); } } add = async (e) => { e.preventDefault(); const input = this.handle.value.trim(); if (!input) return; this.busy.value = true; this.error.value = ""; try { const did = input.startsWith("did:") ? input : await resolveHandleToDid(input); const editors = this.props.editors.includes(did) ? this.props.editors : [...this.props.editors, did]; await this.props.onSave(editors); this.handle.value = ""; } catch (err) { this.error.value = err.message; } finally { this.busy.value = false; } }; remove = async (did) => { try { await this.props.onSave(this.props.editors.filter((e) => e !== did)); } catch (err) { this.error.value = err.message; } }; render() { return html` `; } } export class ListView extends Component { listRecord = signal(null); loadError = signal(""); itemsLoaded = signal(false); // Map> of item records for this list itemsByDid = signal(new Map()); // Set of tombstoned items (from any editor) tombstones = signal(new Set()); // Set of items for which WE have written a tombstone record myTombstoneItems = signal(new Set()); newItemText = signal(""); // Drag-to-reorder state dragRkey = signal(null); dragOverRkey = signal(null); dragOverAfter = signal(false); unsubscribe = null; async componentDidMount() { await this.load(); } componentWillUnmount() { if (this.unsubscribe) this.unsubscribe(); listName.value = ""; listIsOwned.value = false; listEditors.value = []; showEditorsModal.value = false; } async componentDidUpdate(prevProps) { if (prevProps.uri !== this.props.uri) { if (this.unsubscribe) this.unsubscribe(); this.unsubscribe = null; this.listRecord.value = null; this.itemsLoaded.value = false; this.itemsByDid.value = new Map(); this.tombstones.value = new Set(); this.myTombstoneItems.value = new Set(); await this.load(); } } parsed() { return parseAtUri(this.props.uri); } async load() { const parsed = this.parsed(); if (!parsed) { this.loadError.value = "Invalid URI"; return; } const { did: ownerDid, rkey: listRkey } = parsed; const me = session.value.did; try { const ownerRec = await getRecord(ownerDid, LIST_NSID, listRkey); if (!ownerRec) { this.loadError.value = "List not found"; return; } this.listRecord.value = ownerRec.value; listName.value = ownerRec.value.name || "(unnamed)"; listIsOwned.value = ownerDid === me && ownerRec.value.editors !== undefined; listEditors.value = ownerRec.value.editors || []; if (ownerDid === me) setCid(LIST_NSID, listRkey, ownerRec.cid); // Auto-create a reference record in our own repo if we're not the owner if (ownerDid !== me) { const myLists = await listRecords(me, LIST_NSID); const hasRef = myLists.some((r) => r.value.ref === this.props.uri); if (!hasRef) await safePut(LIST_NSID, tid(), { ref: this.props.uri }); } const editors = ownerRec.value.editors || []; const dids = Array.from(new Set([ownerDid, ...editors, me])); const itemsByDid = new Map(); const tombstones = new Set(); const myTombItems = new Set(); // item rkeys for which we already own a tombstone await Promise.all( dids.map(async (did) => { const [items, tombs] = await Promise.all([ listRecords(did, ITEM_NSID).catch(() => []), listRecords(did, TOMBSTONE_NSID).catch(() => []), ]); const map = new Map(); for (const r of items) { if (r.value.listId !== listRkey) continue; const rkey = r.uri.split("/").pop(); map.set(rkey, r.value); if (did === me) setCid(ITEM_NSID, rkey, r.cid); } itemsByDid.set(did, map); for (const t of tombs) { if (t.value.item) { tombstones.add(t.value.item); if (did === me) myTombItems.add(t.value.item); } } }) ); // Reflect any buffered offline writes for this list so the UI shows // them on reload, before the queue actually flushes. const me_inner = itemsByDid.get(me) || new Map(); for (const e of getQueue()) { if (e.op !== "put") continue; if (e.collection === ITEM_NSID && e.record?.listId === listRkey) { me_inner.set(e.rkey, e.record); } else if (e.collection === TOMBSTONE_NSID && e.record?.item) { tombstones.add(e.record.item); myTombItems.add(e.record.item); } } itemsByDid.set(me, me_inner); this.itemsByDid.value = itemsByDid; this.tombstones.value = tombstones; this.itemsLoaded.value = true; // Replicate any tombstones from other repos that we don't yet have. // Done before subscribing so the writes are in flight before live events arrive. for (const rkey of tombstones) { if (!myTombItems.has(rkey)) { myTombItems.add(rkey); safePut(TOMBSTONE_NSID, tid(), { item: rkey }).catch(() => {}); } } this.myTombstoneItems.value = myTombItems; this.unsubscribe = await subscribe({ dids, collections: [LIST_NSID, ITEM_NSID, TOMBSTONE_NSID], onCommit: this.onCommit, }); // After the initial fetch, write the merged state into our own repo for // any items where we're missing peer updates. for (const rkey of new Set( [...itemsByDid.values()].flatMap((m) => [...m.keys()]) )) { if (tombstones.has(rkey)) { if (itemsByDid.get(me)?.has(rkey)) safeDelete(ITEM_NSID, rkey).catch(() => {}); continue; } this.syncOwnRecord(rkey); } // Convert any legacy numeric indices to string keys (one-time, idempotent). this.migrateIndices().catch((err) => console.error("migrateIndices", err)); } catch (err) { this.loadError.value = err.message; } } // Rewrite legacy numeric `index` values as fractional string keys, preserving // current order. Runs after load; no-ops once every item already has a string. async migrateIndices() { const items = this.mergedItems(); if (items.every((it) => typeof it.index === "string")) return; let prev = null; for (let i = 0; i < items.length; i++) { const it = items[i]; if (typeof it.index === "string") { prev = it.index; continue; } let next = null; for (let j = i + 1; j < items.length; j++) { if (typeof items[j].index === "string") { next = items[j].index; break; } } const key = generateKeyBetween(prev, next); prev = key; await this.onUpdate(it.rkey, { index: key }); } } onCommit = ({ did, collection, rkey, cid, action, record }) => { const parsed = this.parsed(); if (!parsed) return; if (did === session.value.did) { setCid(collection, rkey, action === "delete" ? null : cid); } if (collection === LIST_NSID) { if (did === parsed.did && rkey === parsed.rkey) { this.listRecord.value = action === "delete" ? null : record; } return; } if (collection === ITEM_NSID) { const next = new Map(this.itemsByDid.value); const inner = new Map(next.get(did) || []); if (action === "delete") inner.delete(rkey); else if (record && record.listId === parsed.rkey) { // Merge with whatever's already in the cache so an out-of-order or // delayed firehose echo can't regress our state to an older clock. const current = inner.get(rkey); inner.set(rkey, current ? mergeRecords([current, record]) : record); } else inner.delete(rkey); next.set(did, inner); this.itemsByDid.value = next; if (did !== session.value.did && action !== "delete" && !this.tombstones.value.has(rkey)) this.syncOwnRecord(rkey); return; } if (collection === TOMBSTONE_NSID) { if (action !== "delete" && record?.item) { const ts = new Set(this.tombstones.value); ts.add(record.item); this.tombstones.value = ts; const me = session.value.did; if (this.itemsByDid.value.get(me)?.has(record.item)) { safeDelete(ITEM_NSID, record.item).catch(() => {}); } if (did !== session.value.did && !this.myTombstoneItems.value.has(record.item)) { this.myTombstoneItems.value = new Set([...this.myTombstoneItems.value, record.item]); safePut(TOMBSTONE_NSID, tid(), { item: record.item }).catch(() => {}); } } } }; mergedFor(rkey) { const versions = []; for (const m of this.itemsByDid.value.values()) { const r = m.get(rkey); if (r) versions.push(r); } return mergeRecords(versions); } mergedItems() { const rkeys = new Set(); for (const m of this.itemsByDid.value.values()) for (const k of m.keys()) rkeys.add(k); const tomb = this.tombstones.value; const out = []; for (const rkey of rkeys) { if (tomb.has(rkey)) continue; const merged = this.mergedFor(rkey); if (merged) out.push({ rkey, ...merged }); } // rkey breaks index ties so every editor renders the same order. out.sort((a, b) => cmpIndex(a.index, b.index) || a.rkey.localeCompare(b.rkey)); return out; } // If the merged state for this rkey is ahead of our own record, write it // back so every editor's repo converges to the same data. async syncOwnRecord(rkey) { const me = session.value.did; const merged = this.mergedFor(rkey); if (!merged) return; const mine = this.itemsByDid.value.get(me)?.get(rkey); if (mine && crdtEqual(mine.$crdt, merged.$crdt)) return; this.applyLocal(rkey, merged); try { await safePut(ITEM_NSID, rkey, merged); } catch (err) { console.error("syncOwnRecord", rkey, err); } } // Optimistically update our local cache so the UI reacts immediately while // the network request settles; the firehose event will reconcile later. applyLocal(rkey, record) { const next = new Map(this.itemsByDid.value); const me = session.value.did; const inner = new Map(next.get(me) || []); inner.set(rkey, record); next.set(me, inner); this.itemsByDid.value = next; } onAdd = async (e) => { e.preventDefault(); const text = this.newItemText.value.trim(); if (!text) return; this.newItemText.value = ""; const parsed = this.parsed(); const me = session.value.did; const items = this.mergedItems(); const last = items[items.length - 1]; const lastKey = typeof last?.index === "string" ? last.index : null; const record = applyUpdates( null, { listId: parsed.rkey, text, done: false, index: generateKeyBetween(lastKey, null) }, me ); const rkey = tid(); this.applyLocal(rkey, record); try { await safePut(ITEM_NSID, rkey, record); } catch (err) { this.loadError.value = err.message; } }; onUpdate = async (rkey, updates) => { const me = session.value.did; const merged = this.mergedFor(rkey); if (!merged) return; const record = applyUpdates(merged, updates, me); this.applyLocal(rkey, record); try { await safePut(ITEM_NSID, rkey, record); } catch (err) { this.loadError.value = err.message; } }; onDragStart = (rkey) => { this.dragRkey.value = rkey; }; onDragOver = (rkey, after) => { this.dragOverRkey.value = rkey; this.dragOverAfter.value = after; }; onDragEnd = () => { this.dragRkey.value = null; this.dragOverRkey.value = null; }; onDrop = async () => { const source = this.dragRkey.value; const target = this.dragOverRkey.value; const after = this.dragOverAfter.value; this.onDragEnd(); if (!source || !target || source === target) return; // Find where the dragged item lands among its peers, ignoring its own // current slot, then index it between the new neighbors. const order = this.mergedItems().filter((i) => i.rkey !== source); const targetPos = order.findIndex((i) => i.rkey === target); if (targetPos === -1) return; const insertAt = after ? targetPos + 1 : targetPos; const before = order[insertAt - 1]; const next = order[insertAt]; if (before?.rkey === source || next?.rkey === source) return; const a = typeof before?.index === "string" ? before.index : null; const b = typeof next?.index === "string" ? next.index : null; await this.onUpdate(source, { index: generateKeyBetween(a, b) }); }; onDeleteItem = async (rkey) => { const me = session.value.did; const ts = new Set(this.tombstones.value); ts.add(rkey); this.tombstones.value = ts; try { if (this.itemsByDid.value.get(me)?.has(rkey)) { await safeDelete(ITEM_NSID, rkey).catch(() => {}); } await safePut(TOMBSTONE_NSID, tid(), { item: rkey }); } catch (err) { this.loadError.value = err.message; } }; onSaveEditors = async (editors) => { const parsed = this.parsed(); const next = { ...this.listRecord.value, editors }; await safePut(LIST_NSID, parsed.rkey, next); this.listRecord.value = next; listEditors.value = editors; }; render() { if (this.loadError.value) return html`

    ${this.loadError.value}

    `; if (!this.listRecord.value) return html`

    Loading…

    `; const items = this.mergedItems(); return html`
    (this.newItemText.value = e.target.value)} />
    ${!this.itemsLoaded.value ? html`

    Loading…

    ` : items.length === 0 ? html`

    No items yet.

    ` : html`
      ${items.map( (item) => html`<${ItemRow} key=${item.rkey} item=${item} onUpdate=${this.onUpdate} onDelete=${this.onDeleteItem} onDragStart=${this.onDragStart} onDragOver=${this.onDragOver} onDrop=${this.onDrop} onDragEnd=${this.onDragEnd} dragging=${this.dragRkey.value === item.rkey} drop=${this.dragOverRkey.value === item.rkey ? this.dragOverAfter.value ? "after" : "before" : null} />` )}
    `} ${showEditorsModal.value && html`<${EditorsModal} editors=${this.listRecord.value.editors || []} onSave=${this.onSaveEditors} onClose=${() => (showEditorsModal.value = false)} />`} `; } }