a proof of concept realtime collaborative todo list
9.2 kB
268 lines
1import { Component } from "preact";
2import { html } from "htm/preact";
3import { signal } from "@preact/signals";
4import { LIST_NSID, ITEM_NSID, TOMBSTONE_NSID, parseAtUri } from "./state.js";
5import { listRecords, getRecord, resolveHandle } from "./atproto.js";
6import { subscribe } from "./subscribe.js";
7
8const TABS = [
9 { id: "list", nsid: LIST_NSID, label: "Lists" },
10 { id: "item", nsid: ITEM_NSID, label: "Items" },
11 { id: "tombstone", nsid: TOMBSTONE_NSID, label: "Tombstones" },
12];
13
14// Field order in the underlying record isn't stable (it depends on CRDT merge
15// order), so sort keys alphabetically rather than display whatever order the
16// record happens to carry. $crdt carries per-field bookkeeping that's only
17// interesting after the data itself, so it always goes last.
18function formatRecord(value) {
19 if (!value || typeof value !== "object") return JSON.stringify(value, null, 2);
20 const { $crdt, ...rest } = value;
21 const sorted = Object.fromEntries(Object.keys(rest).sort().map((k) => [k, rest[k]]));
22 return JSON.stringify($crdt !== undefined ? { ...sorted, $crdt } : sorted, null, 2);
23}
24
25export class DevToolsPanel extends Component {
26 activeTab = signal("list");
27 selectedRepo = signal(null);
28 recordsByNsid = signal({});
29 selected = signal(null);
30 dids = signal([]);
31 handles = signal({});
32
33 #unsubscribe = null;
34
35 async loadData() {
36 const did = this.props.did;
37 if (!did) return;
38
39 let dids = [did];
40
41 if (this.props.listUri) {
42 try {
43 const parsed = parseAtUri(this.props.listUri);
44 if (parsed) {
45 const rec = await getRecord(parsed.did, LIST_NSID, parsed.rkey);
46 if (rec?.value) {
47 const editors = rec.value.editors ?? [];
48 dids = [...new Set([...dids, parsed.did, ...editors])];
49 }
50 }
51 } catch {}
52 }
53
54 this.dids.value = dids;
55
56 const [records] = await Promise.all([
57 Promise.all(
58 TABS.flatMap(({ nsid }) =>
59 dids.map(async (d) => ({ nsid, did: d, recs: await listRecords(d, nsid).catch(() => []) }))
60 )
61 ),
62 Promise.all(
63 dids
64 .filter((d) => !this.handles.value[d])
65 .map(async (d) => {
66 const h = await resolveHandle(d).catch(() => d);
67 this.handles.value = { ...this.handles.value, [d]: h };
68 })
69 ),
70 ]);
71
72 const next = {};
73 for (const { nsid, did: d, recs } of records) {
74 if (!next[nsid]) next[nsid] = {};
75 next[nsid][d] = recs;
76 }
77 this.recordsByNsid.value = next;
78
79 this.#unsubscribe?.();
80 this.#unsubscribe = subscribe({
81 dids,
82 collections: TABS.map((t) => t.nsid),
83 onCommit: this.#onCommit,
84 });
85 }
86
87 #onCommit = ({ did, collection, rkey, action, record }) => {
88 const uri = `at://${did}/${collection}/${rkey}`;
89 const outer = { ...this.recordsByNsid.value };
90 if (!outer[collection]) outer[collection] = {};
91 const inner = [...(outer[collection][did] ?? [])];
92
93 if (action === "delete") {
94 outer[collection] = { ...outer[collection], [did]: inner.filter((r) => r.uri !== uri) };
95 } else if (record) {
96 const idx = inner.findIndex((r) => r.uri === uri);
97 const updated = idx >= 0 ? inner.with(idx, { uri, value: record }) : [...inner, { uri, value: record }];
98 outer[collection] = { ...outer[collection], [did]: updated };
99 }
100
101 this.recordsByNsid.value = outer;
102 };
103
104 componentDidMount() {
105 this.loadData();
106 }
107
108 componentDidUpdate(prev) {
109 if (prev.did !== this.props.did || prev.listUri !== this.props.listUri) {
110 this.#unsubscribe?.();
111 this.selected.value = null;
112 this.selectedRepo.value = null;
113 this.loadData();
114 }
115 }
116
117 componentWillUnmount() {
118 this.#unsubscribe?.();
119 }
120
121 // rkeys of items belonging to the active list, across all known repos.
122 itemRkeysForList(listRkey) {
123 const byDid = this.recordsByNsid.value[ITEM_NSID] ?? {};
124 const rkeys = new Set();
125 for (const recs of Object.values(byDid)) {
126 for (const rec of recs) {
127 if (rec.value?.listId === listRkey) rkeys.add(rec.uri.split("/").pop());
128 }
129 }
130 return rkeys;
131 }
132
133 getEntries() {
134 const tab = TABS.find((t) => t.id === this.activeTab.value);
135 if (!tab) return [];
136 const byDid = this.recordsByNsid.value[tab.nsid] ?? {};
137 const selRepo = this.selectedRepo.value;
138 const listRkey = parseAtUri(this.props.listUri)?.rkey;
139 const itemRkeys = tab.id === "tombstone" && listRkey ? this.itemRkeysForList(listRkey) : null;
140 const out = [];
141 for (const [did, recs] of Object.entries(byDid)) {
142 if (selRepo && did !== selRepo) continue;
143 for (const rec of recs) {
144 if (tab.id === "item" && listRkey && rec.value?.listId !== listRkey) continue;
145 if (itemRkeys && !itemRkeys.has(rec.value?.item)) continue;
146 out.push({ did, uri: rec.uri, value: rec.value });
147 }
148 }
149 out.sort((a, b) => (a.uri < b.uri ? 1 : -1));
150 return out;
151 }
152
153 switchTab(id) {
154 this.activeTab.value = id;
155 this.selected.value = null;
156 }
157
158 renderDetail(entry) {
159 return html`
160 <div class="devtools-detail">
161 <div class="devtools-detail-meta" style="word-break:break-all">${entry.uri}</div>
162 <pre class="devtools-doc-json">${formatRecord(entry.value)}</pre>
163 </div>
164 `;
165 }
166
167 render() {
168 const tab = this.activeTab.value;
169 const dids = this.dids.value;
170 const handles = this.handles.value;
171 const selRepo = this.selectedRepo.value;
172 const entries = this.getEntries();
173 const sel = this.selected.value;
174 const selEntry = sel ? entries.find((e) => e.uri === sel) : null;
175
176 return html`
177 <div class="devtools-panel" style=${this.props.style}>
178 <div class="devtools-header">
179 <div class="devtools-tabs">
180 ${TABS.map(
181 (t) => html`
182 <button
183 key=${t.id}
184 class=${tab === t.id ? "active" : ""}
185 onClick=${() => this.switchTab(t.id)}
186 >
187 ${t.label}
188 </button>
189 `
190 )}
191 </div>
192 <button class="icon-button devtools-action" style="margin-left:auto" title="Close" onClick=${this.props.onClose}>
193 <svg
194 width="12"
195 height="12"
196 viewBox="0 0 16 16"
197 fill="none"
198 stroke="currentColor"
199 stroke-width="2"
200 stroke-linecap="round"
201 >
202 <line x1="4" y1="4" x2="12" y2="12" />
203 <line x1="12" y1="4" x2="4" y2="12" />
204 </svg>
205 </button>
206 </div>
207 <div class="devtools-body">
208 <div class="devtools-tab-content">
209 ${html`
210 <div class="devtools-subtabs">
211 <button
212 class=${!selRepo ? "active" : ""}
213 onClick=${() => {
214 this.selectedRepo.value = null;
215 this.selected.value = null;
216 }}
217 >
218 All
219 </button>
220 ${dids.map(
221 (did) => html`
222 <button
223 key=${did}
224 class=${selRepo === did ? "active" : ""}
225 onClick=${() => {
226 this.selectedRepo.value = did;
227 this.selected.value = null;
228 }}
229 >
230 @${handles[did] || did.slice(8, 20) + "…"}
231 </button>
232 `
233 )}
234 </div>
235 `}
236 <div class=${`devtools-body-inner${selEntry ? " has-detail" : ""}`}>
237 <div class="devtools-timeline">
238 ${entries.length === 0
239 ? html`<p class="devtools-empty">No records.</p>`
240 : entries.map((entry, i) => {
241 const active = sel === entry.uri;
242 const rkey = entry.uri.split("/").pop();
243 const author = "@" + (handles[entry.did] || entry.did.slice(8, 20) + "…");
244 return html`
245 <div
246 class=${`devtools-entry${active ? " active" : ""}`}
247 key=${entry.uri}
248 onClick=${() => (this.selected.value = active ? null : entry.uri)}
249 >
250 <div class="devtools-entry-meta">
251 <span class="devtools-entry-index">${i + 1}</span>
252 <span class="devtools-entry-author">${author}</span>
253 <span class="devtools-entry-time">${rkey}</span>
254 </div>
255 <div class="devtools-entry-uri">${entry.uri}</div>
256 <pre class="devtools-entry-json">${formatRecord(entry.value)}</pre>
257 </div>
258 `;
259 })}
260 </div>
261 ${selEntry ? this.renderDetail(selEntry) : null}
262 </div>
263 </div>
264 </div>
265 </div>
266 `;
267 }
268}