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`
e.stopPropagation()}>
Editors
${this.props.editors.length === 0
? html`
No editors yet.
`
: html`
${this.props.editors.map(
(did) => html`
-
${this.handles.value.get(did) || did}
`
)}
`}
${this.error.value && html`
${this.error.value}
`}
`;
}
}
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.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)}
/>`}
`;
}
}