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
8.0 kB 241 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 14export class DevToolsPanel extends Component { 15 activeTab = signal("list"); 16 selectedRepo = signal(null); 17 recordsByNsid = signal({}); 18 selected = signal(null); 19 dids = signal([]); 20 handles = signal({}); 21 22 #unsubscribe = null; 23 24 async loadData() { 25 const did = this.props.did; 26 if (!did) return; 27 28 let dids = [did]; 29 30 if (this.props.listUri) { 31 try { 32 const parsed = parseAtUri(this.props.listUri); 33 if (parsed) { 34 const rec = await getRecord(parsed.did, LIST_NSID, parsed.rkey); 35 if (rec?.value) { 36 const editors = rec.value.editors ?? []; 37 dids = [...new Set([...dids, parsed.did, ...editors])]; 38 } 39 } 40 } catch {} 41 } 42 43 this.dids.value = dids; 44 45 const [records] = await Promise.all([ 46 Promise.all( 47 TABS.flatMap(({ nsid }) => 48 dids.map(async (d) => ({ nsid, did: d, recs: await listRecords(d, nsid).catch(() => []) })) 49 ) 50 ), 51 Promise.all( 52 dids 53 .filter((d) => !this.handles.value[d]) 54 .map(async (d) => { 55 const h = await resolveHandle(d).catch(() => d); 56 this.handles.value = { ...this.handles.value, [d]: h }; 57 }) 58 ), 59 ]); 60 61 const next = {}; 62 for (const { nsid, did: d, recs } of records) { 63 if (!next[nsid]) next[nsid] = {}; 64 next[nsid][d] = recs; 65 } 66 this.recordsByNsid.value = next; 67 68 this.#unsubscribe?.(); 69 this.#unsubscribe = subscribe({ 70 dids, 71 collections: TABS.map((t) => t.nsid), 72 onCommit: this.#onCommit, 73 }); 74 } 75 76 #onCommit = ({ did, collection, rkey, action, record }) => { 77 const uri = `at://${did}/${collection}/${rkey}`; 78 const outer = { ...this.recordsByNsid.value }; 79 if (!outer[collection]) outer[collection] = {}; 80 const inner = [...(outer[collection][did] ?? [])]; 81 82 if (action === "delete") { 83 outer[collection] = { ...outer[collection], [did]: inner.filter((r) => r.uri !== uri) }; 84 } else if (record) { 85 const idx = inner.findIndex((r) => r.uri === uri); 86 const updated = idx >= 0 ? inner.with(idx, { uri, value: record }) : [...inner, { uri, value: record }]; 87 outer[collection] = { ...outer[collection], [did]: updated }; 88 } 89 90 this.recordsByNsid.value = outer; 91 }; 92 93 componentDidMount() { 94 this.loadData(); 95 } 96 97 componentDidUpdate(prev) { 98 if (prev.did !== this.props.did || prev.listUri !== this.props.listUri) { 99 this.#unsubscribe?.(); 100 this.selected.value = null; 101 this.selectedRepo.value = null; 102 this.loadData(); 103 } 104 } 105 106 componentWillUnmount() { 107 this.#unsubscribe?.(); 108 } 109 110 getEntries() { 111 const tab = TABS.find((t) => t.id === this.activeTab.value); 112 if (!tab) return []; 113 const byDid = this.recordsByNsid.value[tab.nsid] ?? {}; 114 const selRepo = this.selectedRepo.value; 115 const out = []; 116 for (const [did, recs] of Object.entries(byDid)) { 117 if (selRepo && did !== selRepo) continue; 118 for (const rec of recs) out.push({ did, uri: rec.uri, value: rec.value }); 119 } 120 out.sort((a, b) => (a.uri < b.uri ? 1 : -1)); 121 return out; 122 } 123 124 switchTab(id) { 125 this.activeTab.value = id; 126 this.selected.value = null; 127 this.selectedRepo.value = null; 128 } 129 130 renderDetail(entry) { 131 return html` 132 <div class="devtools-detail"> 133 <div class="devtools-detail-meta" style="word-break:break-all">${entry.uri}</div> 134 <pre class="devtools-doc-json">${JSON.stringify(entry.value, null, 2)}</pre> 135 </div> 136 `; 137 } 138 139 render() { 140 const tab = this.activeTab.value; 141 const dids = this.dids.value; 142 const handles = this.handles.value; 143 const selRepo = this.selectedRepo.value; 144 const entries = this.getEntries(); 145 const sel = this.selected.value; 146 147 return html` 148 <div class="devtools-panel" style=${this.props.style}> 149 <div class="devtools-header"> 150 <div class="devtools-tabs"> 151 ${TABS.map( 152 (t) => html` 153 <button 154 key=${t.id} 155 class=${tab === t.id ? "active" : ""} 156 onClick=${() => this.switchTab(t.id)} 157 > 158 ${t.label} 159 </button> 160 ` 161 )} 162 </div> 163 <button class="icon-button devtools-action" style="margin-left:auto" title="Close" onClick=${this.props.onClose}> 164 <svg 165 width="12" 166 height="12" 167 viewBox="0 0 16 16" 168 fill="none" 169 stroke="currentColor" 170 stroke-width="2" 171 stroke-linecap="round" 172 > 173 <line x1="4" y1="4" x2="12" y2="12" /> 174 <line x1="12" y1="4" x2="4" y2="12" /> 175 </svg> 176 </button> 177 </div> 178 <div class="devtools-body"> 179 <div class="devtools-tab-content"> 180 ${dids.length > 1 181 ? html` 182 <div class="devtools-subtabs"> 183 <button 184 class=${!selRepo ? "active" : ""} 185 onClick=${() => { 186 this.selectedRepo.value = null; 187 this.selected.value = null; 188 }} 189 > 190 All 191 </button> 192 ${dids.map( 193 (did) => html` 194 <button 195 key=${did} 196 class=${selRepo === did ? "active" : ""} 197 onClick=${() => { 198 this.selectedRepo.value = did; 199 this.selected.value = null; 200 }} 201 > 202 @${handles[did] || did.slice(8, 20) + "…"} 203 </button> 204 ` 205 )} 206 </div> 207 ` 208 : null} 209 <div class=${`devtools-body-inner${sel ? " has-detail" : ""}`}> 210 <div class="devtools-timeline"> 211 ${entries.length === 0 212 ? html`<p class="devtools-empty">No records.</p>` 213 : entries.map((entry, i) => { 214 const active = sel?.uri === entry.uri; 215 const rkey = entry.uri.split("/").pop(); 216 const author = "@" + (handles[entry.did] || entry.did.slice(8, 20) + "…"); 217 return html` 218 <div 219 class=${`devtools-entry${active ? " active" : ""}`} 220 key=${entry.uri} 221 onClick=${() => (this.selected.value = active ? null : entry)} 222 > 223 <div class="devtools-entry-meta"> 224 <span class="devtools-entry-index">${i + 1}</span> 225 <span class="devtools-entry-author">${author}</span> 226 <span class="devtools-entry-time">${rkey}</span> 227 </div> 228 <div class="devtools-entry-uri">${entry.uri}</div> 229 <pre class="devtools-entry-json">${JSON.stringify(entry.value, null, 2)}</pre> 230 </div> 231 `; 232 })} 233 </div> 234 ${sel ? this.renderDetail(sel) : null} 235 </div> 236 </div> 237 </div> 238 </div> 239 `; 240 } 241}