a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 294 lines 9.4 kB view raw
1import { apds } from 'apds' 2import { render } from './render.js' 3import { h } from 'h' 4import { composer } from './composer.js' 5import { buildShareMessage, parseSharePayload } from './share.js' 6import { settings, importKey } from './settings.js' 7import { adder } from './adder.js' 8import { importBlob } from './import.js' 9import { send } from './send.js' 10import { noteInterest } from './sync.js' 11import { isBlockedAuthor } from './moderation.js' 12import { buildProfileHeader } from './profile_header.js' 13import { perfStart, perfEnd } from './perf.js' 14import { FeedStore } from './feed_store.js' 15import { FeedOrchestrator } from './feed_orchestrator.js' 16 17let activeRouteRun = 0 18let activeRouteController = null 19let activeOrchestrator = null 20let activePanelKey = null 21const routePanels = new Map() 22const routeScrollTop = new Map() 23 24const getScroller = () => { 25 let scroller = document.getElementById('scroller') 26 if (scroller) { return scroller } 27 scroller = h('div', { id: 'scroller' }) 28 document.body.appendChild(scroller) 29 return scroller 30} 31 32const getPanelKey = (src) => (src === '' ? '__home__' : src) 33const panelKeyToSrc = (key) => (key === '__home__' ? '' : key) 34 35const getRoutePanel = (scroller, key) => { 36 let panel = routePanels.get(key) 37 if (!panel) { 38 panel = h('div', { classList: 'route-panel' }) 39 panel.dataset.routeKey = key 40 routePanels.set(key, panel) 41 scroller.appendChild(panel) 42 } else if (panel.parentNode !== scroller) { 43 scroller.appendChild(panel) 44 } 45 return panel 46} 47 48const activatePanel = (key, panel) => { 49 if (activePanelKey && activePanelKey !== key) { 50 const previous = routePanels.get(activePanelKey) 51 if (previous) { 52 routeScrollTop.set(activePanelKey, window.scrollY || 0) 53 previous.remove() 54 } 55 window.__feedController?.deactivateFeed?.(panelKeyToSrc(activePanelKey)) 56 } 57 activePanelKey = key 58 window.__feedController?.activateFeed?.(panelKeyToSrc(key)) 59 const savedTop = routeScrollTop.get(key) 60 if (typeof savedTop === 'number') { 61 setTimeout(() => { 62 window.scrollTo(0, savedTop) 63 }, 0) 64 } 65} 66 67const waitForFirstRenderedAuthor = (container, timeoutMs = 6000) => new Promise((resolve) => { 68 if (!container) { resolve(null); return } 69 const existing = container.querySelector('[data-author]') 70 if (existing && existing.dataset.author) { 71 resolve(existing.dataset.author) 72 return 73 } 74 let resolved = false 75 const observer = new MutationObserver(() => { 76 if (resolved) { return } 77 const found = container.querySelector('[data-author]') 78 if (found && found.dataset.author) { 79 resolved = true 80 observer.disconnect() 81 resolve(found.dataset.author) 82 } 83 }) 84 observer.observe(container, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-author'] }) 85 setTimeout(() => { 86 if (resolved) { return } 87 resolved = true 88 observer.disconnect() 89 resolve(null) 90 }, timeoutMs) 91}) 92 93const scheduleReplyIndexBuild = () => { 94 void render.buildReplyIndex().then(() => { 95 render.refreshVisibleReplies?.() 96 }).catch((err) => { 97 console.warn('reply index build failed', err) 98 }) 99} 100 101const beginRouteRun = () => { 102 activeRouteRun += 1 103 if (activeOrchestrator) { 104 activeOrchestrator.stop() 105 activeOrchestrator = null 106 } 107 if (activeRouteController) { 108 activeRouteController.abort() 109 } 110 activeRouteController = new AbortController() 111 return { 112 runId: activeRouteRun, 113 signal: activeRouteController.signal 114 } 115} 116 117const makeRouteContext = (src, scroller) => { 118 const { runId, signal } = beginRouteRun() 119 const isActive = () => runId === activeRouteRun && window.location.hash.substring(1) === src 120 const store = new FeedStore(src, { isActive }) 121 const orchestrator = new FeedOrchestrator({ src, signal, isActive, store }) 122 activeOrchestrator = orchestrator 123 return { runId, signal, isActive, store, orchestrator, scroller, src } 124} 125 126export const route = async () => { 127 const token = perfStart('route', window.location.hash.substring(1) || 'home') 128 const src = window.location.hash.substring(1) 129 const scroller = getScroller() 130 const panelKey = getPanelKey(src) 131 const panel = getRoutePanel(scroller, panelKey) 132 activatePanel(panelKey, panel) 133 134 try { 135 if (src.startsWith('share=')) { 136 const payload = parseSharePayload(src) 137 if (payload) { 138 const message = buildShareMessage(payload) 139 setTimeout(async () => { 140 try { 141 const compose = await composer(null, { initialBody: message, autoGenKeypair: true }) 142 document.body.appendChild(compose) 143 } catch (err) { 144 console.log(err) 145 } 146 }, 0) 147 history.replaceState(null, '', '#') 148 if (typeof window.onhashchange === 'function') { 149 window.onhashchange() 150 } 151 return 152 } 153 } 154 155 if (src === '' || src.startsWith('share=')) { 156 if (panel.dataset.ready === 'true') { return } 157 panel.replaceChildren() 158 panel.dataset.paginated = 'true' 159 scheduleReplyIndexBuild() 160 const ctx = makeRouteContext(src, panel) 161 const { log } = await ctx.orchestrator.startHome() 162 if (!ctx.isActive()) { return } 163 adder(log || [], src, panel) 164 panel.dataset.ready = 'true' 165 return 166 } 167 168 if (src === 'settings') { 169 if (panel.dataset.ready === 'true') { return } 170 panel.replaceChildren() 171 if (await apds.pubkey()) { 172 panel.appendChild(await settings()) 173 } else { 174 panel.appendChild(await importKey()) 175 } 176 panel.dataset.ready = 'true' 177 return 178 } 179 180 if (src === 'import') { 181 if (panel.dataset.ready === 'true') { return } 182 panel.replaceChildren() 183 panel.appendChild(await importBlob()) 184 panel.dataset.ready = 'true' 185 return 186 } 187 188 if (src.length < 44 && !src.startsWith('?')) { 189 if (panel.dataset.ready === 'true') { return } 190 panel.replaceChildren() 191 panel.dataset.paginated = 'true' 192 scheduleReplyIndexBuild() 193 const ctx = makeRouteContext(src, panel) 194 const { query, primaryKey } = await ctx.orchestrator.startAlias(src) 195 if (!ctx.isActive()) { return } 196 adder(query || [], src, panel) 197 if (query.length) { 198 const header = await buildProfileHeader({ label: src, messages: query, canEdit: false, pubkey: primaryKey }) 199 if (header) { panel.appendChild(header) } 200 } else { 201 const header = await buildProfileHeader({ label: src, messages: [], canEdit: false, pubkey: primaryKey }) 202 if (header) { panel.appendChild(header) } 203 } 204 panel.dataset.ready = 'true' 205 return 206 } 207 208 if (src.length === 44) { 209 if (panel.dataset.ready === 'true') { return } 210 panel.replaceChildren() 211 if (await isBlockedAuthor(src)) { return } 212 const selfKey = await apds.pubkey() 213 await noteInterest(src) 214 panel.dataset.paginated = 'true' 215 scheduleReplyIndexBuild() 216 const ctx = makeRouteContext(src, panel) 217 const { log } = await ctx.orchestrator.startAuthor(src) 218 if (!ctx.isActive()) { return } 219 adder(log || [], src, panel) 220 const canEdit = !!(selfKey && selfKey === src) 221 void waitForFirstRenderedAuthor(panel).then(async (author) => { 222 if (!ctx.isActive()) { return } 223 if (!author || author !== src) { return } 224 const header = await buildProfileHeader({ label: src.substring(0, 10), messages: log || [], canEdit, pubkey: src }) 225 if (header) { panel.prepend(header) } 226 }) 227 if (!log || !log[0]) { 228 await send(src) 229 } 230 panel.dataset.ready = 'true' 231 return 232 } 233 234 if (src.startsWith('?')) { 235 if (panel.dataset.ready === 'true') { return } 236 panel.replaceChildren() 237 panel.dataset.paginated = 'true' 238 scheduleReplyIndexBuild() 239 const ctx = makeRouteContext(src, panel) 240 const { log } = await ctx.orchestrator.startSearch(src) 241 if (!ctx.isActive()) { return } 242 adder(log || [], src, panel) 243 panel.dataset.ready = 'true' 244 return 245 } 246 247 if (src.length > 44) { 248 if (panel.dataset.ready === 'true') { return } 249 panel.replaceChildren() 250 const hash = await apds.hash(src) 251 const opened = await apds.open(src) 252 const author = src.substring(0, 44) 253 if (await isBlockedAuthor(author)) { return } 254 await noteInterest(author) 255 if (opened) { 256 await apds.add(src) 257 } 258 const check = await document.getElementById(hash) 259 if (!check) { 260 let ts = 0 261 if (opened) { 262 ts = Number.parseInt(opened.substring(0, 13), 10) 263 if (Number.isNaN(ts)) { ts = 0 } 264 } 265 if (!ts) { ts = Date.now() } 266 const div = render.insertByTimestamp(panel, hash, ts) 267 if (!div) { return } 268 if (opened) { div.dataset.opened = opened } 269 await render.blob(src, { hash, opened }) 270 } 271 panel.dataset.ready = 'true' 272 } 273 } finally { 274 perfEnd(token) 275 } 276} 277 278window.onhashchange = async () => { 279 if (activeOrchestrator) { 280 activeOrchestrator.stop() 281 activeOrchestrator = null 282 } 283 if (activeRouteController) { 284 activeRouteController.abort() 285 activeRouteController = null 286 } 287 if (window.location.hash === '#?') { 288 const search = document.getElementById('search') 289 search.value = '' 290 search.classList = 'material-symbols-outlined' 291 window.location.hash = '' 292 } 293 await route() 294}