a proof of concept realtime collaborative todo list
0

Configure Feed

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

atodo / devtools.js
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}