a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 619 lines 18 kB view raw
1import { render } from './render.js' 2import { apds } from 'apds' 3import { adaptiveConcurrency } from './adaptive_concurrency.js' 4import { perfMeasure, perfStart, perfEnd } from './perf.js' 5import { queueSend } from './network_queue.js' 6import { getFeedRow } from './feed_row_cache.js' 7import { normalizeTimestamp } from './utils.js' 8 9const RENDER_CONCURRENCY = adaptiveConcurrency({ base: 6, min: 2, max: 10, type: 'render' }) 10const FRAME_RENDER_START_BUDGET = adaptiveConcurrency({ base: 3, min: 1, max: 6, type: 'render' }) 11const FRAME_QUEUE_BUDGET_MS = 6 12const VIEW_PREFETCH_MARGIN = '1800px 0px' 13const DERENDER_DELAY_MS = 2000 14const ENABLE_DERENDER = false 15const PREFETCH_AHEAD_PX = 2000 16const PREFETCH_BEHIND_PX = 700 17let renderQueue = [] 18let renderActive = 0 19let frameStartsRemaining = FRAME_RENDER_START_BUDGET 20let frameBudgetScheduled = false 21const derenderTimers = new Map() 22let viewObserver = null 23let lastScrollTop = 0 24let scrollDir = 1 25 26const perfNow = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) 27const logPerf = (label, start) => { 28 if (!window.__perfRender) { return } 29 const duration = perfNow() - start 30 console.log(`[render] ${label} ${duration.toFixed(1)}ms`) 31} 32const scheduleDrain = (fn) => { 33 if (typeof requestAnimationFrame === 'function') { 34 requestAnimationFrame(fn) 35 } else { 36 setTimeout(fn, 0) 37 } 38} 39 40const scheduleFrameBudgetReset = () => { 41 if (frameBudgetScheduled) { return } 42 frameBudgetScheduled = true 43 scheduleDrain(() => { 44 frameStartsRemaining = FRAME_RENDER_START_BUDGET 45 frameBudgetScheduled = false 46 if (renderQueue.length) { 47 void drainRenderQueue() 48 } 49 }) 50} 51 52const scheduleRender = (entry, sig) => new Promise(resolve => { 53 renderQueue.push({ entry, sig, resolve }) 54 void drainRenderQueue() 55}) 56 57const drainRenderQueue = async () => { 58 const drainToken = perfStart('adder.renderQueue.drain') 59 const frameStart = perfNow() 60 while (renderActive < RENDER_CONCURRENCY && renderQueue.length) { 61 if (frameStartsRemaining <= 0) { 62 scheduleFrameBudgetReset() 63 break 64 } 65 if (perfNow() - frameStart > FRAME_QUEUE_BUDGET_MS) { 66 scheduleFrameBudgetReset() 67 break 68 } 69 const { entry, sig, resolve } = renderQueue.shift() 70 frameStartsRemaining -= 1 71 renderActive += 1 72 const start = perfNow() 73 Promise.resolve() 74 .then(async () => { 75 if (!sig) { return } 76 await render.blob(sig, { hash: entry?.hash, opened: entry?.opened }) 77 const wrapper = entry?.hash ? document.getElementById(entry.hash) : null 78 if (wrapper) { 79 wrapper.dataset.rendered = 'true' 80 wrapper.dataset.rendering = 'false' 81 } 82 logPerf(entry.hash || 'blob', start) 83 }) 84 .finally(() => { 85 resolve?.() 86 renderActive -= 1 87 if (renderQueue.length) { 88 if (frameStartsRemaining <= 0) { 89 scheduleFrameBudgetReset() 90 } else { 91 scheduleDrain(() => { 92 void drainRenderQueue() 93 }) 94 } 95 } 96 }) 97 } 98 perfEnd(drainToken) 99} 100 101const ensureObserver = () => { 102 if (viewObserver) { return viewObserver } 103 viewObserver = new IntersectionObserver((entries) => { 104 entries.forEach(entry => { 105 const wrapper = entry.target 106 if (!wrapper) { return } 107 const hash = wrapper.id 108 if (!hash) { return } 109 const pending = derenderTimers.get(wrapper) 110 if (entry.isIntersecting) { 111 if (pending) { 112 clearTimeout(pending) 113 derenderTimers.delete(wrapper) 114 } 115 if (wrapper.dataset.rendered === 'true' || wrapper.dataset.rendering === 'true') { return } 116 wrapper.dataset.rendering = 'true' 117 apds.get(hash).then(sig => { 118 if (!sig) { 119 wrapper.dataset.rendering = 'false' 120 queueSend(hash, { priority: 'high' }) 121 return 122 } 123 void scheduleRender({ hash }, sig) 124 }) 125 return 126 } 127 if (!ENABLE_DERENDER) { return } 128 if (pending || wrapper.dataset.rendered !== 'true') { return } 129 const timer = setTimeout(() => { 130 derenderTimers.delete(wrapper) 131 if (!document.body.contains(wrapper)) { return } 132 if (wrapper.contains(document.activeElement)) { return } 133 const rendered = wrapper.querySelector('.message, .edit-message') 134 if (!rendered) { return } 135 const placeholder = document.createElement('div') 136 placeholder.className = 'message-shell premessage' 137 rendered.replaceWith(placeholder) 138 wrapper.dataset.rendered = 'false' 139 wrapper.dataset.rendering = 'false' 140 const row = getFeedRow(hash) 141 if (row) { 142 render.applyRowPreview?.(wrapper, row) 143 } 144 }, DERENDER_DELAY_MS) 145 derenderTimers.set(wrapper, timer) 146 }) 147 }, { root: null, rootMargin: VIEW_PREFETCH_MARGIN, threshold: 0 }) 148 return viewObserver 149} 150 151const observeWrapper = (wrapper) => { 152 if (!wrapper) { return } 153 const observer = ensureObserver() 154 observer.observe(wrapper) 155} 156 157const isNearViewport = (element) => { 158 if (!element) { return false } 159 const rect = element.getBoundingClientRect() 160 const height = window.innerHeight || document.documentElement.clientHeight || 0 161 const ahead = scrollDir >= 0 ? PREFETCH_AHEAD_PX : PREFETCH_BEHIND_PX 162 const behind = scrollDir >= 0 ? PREFETCH_BEHIND_PX : PREFETCH_AHEAD_PX 163 return rect.top < height + ahead && rect.bottom > -behind 164} 165 166const getController = () => { 167 if (!window.__feedController) { 168 window.__feedController = { 169 feeds: new Map(), 170 getFeed(src) { 171 return this.feeds.get(src) || null 172 }, 173 deactivateFeed(src) { 174 const state = this.getFeed(src) 175 state?.deactivate?.() 176 }, 177 activateFeed(src) { 178 const state = this.getFeed(src) 179 state?.activate?.() 180 } 181 } 182 } 183 return window.__feedController 184} 185 186const makeRouteMatcher = (src) => { 187 if (src === '') { 188 return () => true 189 } 190 if (src.length === 44) { 191 return (entry) => entry?.author === src 192 } 193 if (src.length < 44 && !src.startsWith('?') && src !== 'settings' && src !== 'import') { 194 return (entry) => { 195 const aliasesRaw = localStorage.getItem(src) 196 if (!aliasesRaw) { return false } 197 try { 198 const aliases = JSON.parse(aliasesRaw) 199 if (!Array.isArray(aliases)) { return false } 200 return aliases.includes(entry?.author) 201 } catch { 202 return false 203 } 204 } 205 } 206 return () => false 207} 208 209 210const updateScrollDirection = () => { 211 const scrollEl = document.scrollingElement || document.documentElement || document.body 212 const top = scrollEl.scrollTop || window.scrollY || 0 213 scrollDir = top >= lastScrollTop ? 1 : -1 214 lastScrollTop = top 215} 216 217const addPosts = async (posts, div) => { 218 updateScrollDirection() 219 for (const post of posts) { 220 const ts = post.ts || (post.opened ? Number.parseInt(post.opened.substring(0, 13), 10) : 0) 221 let placeholder = render.hash(post.hash, post.row || null) 222 if (!placeholder) { 223 placeholder = document.getElementById(post.hash) 224 } 225 if (!placeholder) { continue } 226 if (post.row) { 227 render.applyRowPreview?.(placeholder, post.row) 228 } 229 if (ts) { placeholder.dataset.ts = ts.toString() } 230 if (placeholder.parentNode !== div) { 231 div.appendChild(placeholder) 232 } 233 observeWrapper(placeholder) 234 if (isNearViewport(placeholder)) { 235 placeholder.dataset.rendering = 'true' 236 apds.get(post.hash).then(sig => { 237 if (!sig) { 238 placeholder.dataset.rendering = 'false' 239 queueSend(post.hash, { priority: 'high' }) 240 return 241 } 242 void scheduleRender(post, sig) 243 }) 244 } 245 } 246} 247 248const getTimestamp = (post) => { 249 if (!post) { return 0 } 250 if (post.ts) { return Number.parseInt(post.ts, 10) } 251 if (post.opened) { return Number.parseInt(post.opened.substring(0, 13), 10) } 252 return 0 253} 254 255const sortDesc = (a, b) => b.ts - a.ts 256 257const buildEntries = (log) => { 258 if (!log) { return [] } 259 const entries = [] 260 const seen = new Set() 261 for (const post of log) { 262 if (!post || !post.hash) { continue } 263 if (seen.has(post.hash)) { continue } 264 seen.add(post.hash) 265 const ts = getTimestamp(post) 266 entries.push({ hash: post.hash, ts, opened: post.opened, row: post.row || null }) 267 } 268 entries.sort(sortDesc) 269 return entries 270} 271 272const insertEntry = (state, entry) => { 273 if (!entry || !entry.hash || !entry.ts) { return -1 } 274 if (state.seen.has(entry.hash)) { return -1 } 275 const list = state.entries 276 const prevLen = list.length 277 let lo = 0 278 let hi = list.length 279 while (lo < hi) { 280 const mid = Math.floor((lo + hi) / 2) 281 if (list[mid].ts >= entry.ts) { 282 lo = mid + 1 283 } else { 284 hi = mid 285 } 286 } 287 list.splice(lo, 0, entry) 288 state.seen.add(entry.hash) 289 if (lo <= state.cursor) { 290 if (state.cursor === prevLen && lo === prevLen) { return lo } 291 state.cursor += 1 292 } 293 return lo 294} 295 296const isAtTop = () => { 297 const scrollEl = document.scrollingElement || document.documentElement || document.body 298 const scrollTop = scrollEl.scrollTop || window.scrollY || 0 299 return scrollTop <= 10 300} 301 302const ensureBanner = (state) => { 303 if (state.banner && state.banner.parentNode === state.container) { return state.banner } 304 const banner = document.createElement('div') 305 banner.className = 'new-posts-banner' 306 banner.style.display = 'none' 307 const button = document.createElement('button') 308 button.type = 'button' 309 button.className = 'new-posts-button' 310 button.addEventListener('click', async () => { 311 if (state.statusMode) { return } 312 const scrollEl = document.scrollingElement || document.documentElement || document.body 313 if (scrollEl) { 314 scrollEl.scrollTo({ top: 0, behavior: 'smooth' }) 315 } else { 316 window.scrollTo({ top: 0, behavior: 'smooth' }) 317 } 318 await flushPending(state) 319 }) 320 banner.appendChild(button) 321 state.container.insertBefore(banner, state.container.firstChild) 322 state.banner = banner 323 state.bannerButton = button 324 return banner 325} 326 327const updateBanner = (state) => { 328 if (!state.banner || !state.bannerButton) { return } 329 if (state.statusMessage) { 330 state.bannerButton.textContent = state.statusMessage 331 state.bannerButton.disabled = true 332 state.banner.style.display = 'block' 333 return 334 } 335 const count = state.pending.length 336 if (!count) { 337 state.banner.style.display = 'none' 338 return 339 } 340 state.bannerButton.textContent = `Show ${count} new post${count === 1 ? '' : 's'}` 341 state.bannerButton.disabled = false 342 state.banner.style.display = 'block' 343} 344 345const renderEntry = async (state, entry) => { 346 updateScrollDirection() 347 const div = render.insertByTimestamp(state.container, entry.hash, entry.ts) 348 if (!div) { return } 349 if (entry.row) { 350 render.applyRowPreview?.(div, entry.row) 351 } 352 if (entry.opened) { 353 div.dataset.opened = entry.opened 354 } 355 if (entry.blob) { 356 div.dataset.rendering = 'true' 357 void scheduleRender(entry, entry.blob) 358 } else { 359 const sig = await apds.get(entry.hash) 360 if (sig) { 361 div.dataset.rendering = 'true' 362 void scheduleRender(entry, sig) 363 } else { 364 div.dataset.rendering = 'false' 365 queueSend(entry.hash, { priority: 'high' }) 366 } 367 } 368 state.rendered.add(entry.hash) 369 observeWrapper(div.closest('.message-wrapper') || div) 370} 371 372const flushPending = async (state) => { 373 if (!state.pending.length) { return } 374 const pending = state.pending.slice().sort(sortDesc) 375 state.pending = [] 376 updateBanner(state) 377 for (const entry of pending) { 378 await renderEntry(state, entry) 379 state.latestVisibleTs = Math.max(state.latestVisibleTs || 0, entry.ts) 380 state.oldestVisibleTs = Math.min(state.oldestVisibleTs || entry.ts, entry.ts) 381 } 382} 383 384const enqueuePost = async (state, entry) => { 385 if (!entry || !entry.hash || !entry.ts) { return } 386 const insertedAt = insertEntry(state, entry) 387 if (insertedAt < 0) { return } 388 if (!state.active) { 389 state.pending.push(entry) 390 updateBanner(state) 391 return 392 } 393 // Initial page still loading — render everything immediately 394 if (!state.latestVisibleTs || state.rendered.size < state.pageSize) { 395 await renderEntry(state, entry) 396 state.latestVisibleTs = Math.max(state.latestVisibleTs || 0, entry.ts) 397 state.oldestVisibleTs = Math.min(state.oldestVisibleTs || entry.ts, entry.ts) 398 return 399 } 400 // Newer than current view and user has scrolled down (not a local post) — buffer behind banner 401 if (entry.ts > state.latestVisibleTs && !entry.local && !isAtTop()) { 402 state.pending.push(entry) 403 updateBanner(state) 404 return 405 } 406 // Everything else: inside/below current window, or newer but user is at top / local post 407 await renderEntry(state, entry) 408 state.latestVisibleTs = Math.max(state.latestVisibleTs, entry.ts) 409 state.oldestVisibleTs = Math.min(state.oldestVisibleTs, entry.ts) 410} 411 412window.__feedEnqueue = async (src, entry) => { 413 const controller = getController() 414 const state = controller.getFeed(src) 415 if (!state) { return false } 416 await enqueuePost(state, entry) 417 return true 418} 419 420window.__feedEnqueueMatching = async (entry) => { 421 if (!entry || !entry.hash || !entry.ts) { return false } 422 const controller = getController() 423 let matched = false 424 const states = Array.from(controller.feeds.values()) 425 for (const state of states) { 426 if (!state?.matches?.(entry)) { continue } 427 await enqueuePost(state, entry) 428 matched = true 429 } 430 return matched 431} 432 433const getStatusState = () => { 434 const controller = getController() 435 const src = window.location.hash.substring(1) 436 let state = controller.getFeed(src) 437 if (state) { return state } 438 if (!window.__statusFeedState) { 439 const container = document.getElementById('scroller') 440 if (!container) { return null } 441 const fallback = { 442 src: '__status__', 443 container, 444 entries: [], 445 cursor: 0, 446 seen: new Set(), 447 rendered: new Set(), 448 pending: [], 449 pageSize: 0, 450 latestVisibleTs: 0, 451 oldestVisibleTs: 0, 452 banner: null, 453 bannerButton: null, 454 statusMessage: '', 455 statusMode: false, 456 statusTimer: null 457 } 458 ensureBanner(fallback) 459 window.__statusFeedState = fallback 460 } 461 return window.__statusFeedState 462} 463 464window.__feedStatus = (message, options = {}) => { 465 const state = getStatusState() 466 if (!state) { return false } 467 const { timeout = 2500, sticky = false } = options 468 if (state.statusTimer) { 469 clearTimeout(state.statusTimer) 470 state.statusTimer = null 471 } 472 state.statusMessage = message || '' 473 state.statusMode = Boolean(message) 474 updateBanner(state) 475 if (state.statusMode && !sticky) { 476 state.statusTimer = setTimeout(() => { 477 state.statusMessage = '' 478 state.statusMode = false 479 state.statusTimer = null 480 updateBanner(state) 481 }, timeout) 482 } 483 return true 484} 485 486export const adder = (log, src, div) => { 487 if (!div) { return } 488 updateScrollDirection() 489 const pageSize = 25 490 const entries = buildEntries(log || []) 491 let loading = false 492 let armed = false 493 let armListenerAttached = false 494 495 let posts = [] 496 const state = { 497 src, 498 container: div, 499 matches: makeRouteMatcher(src), 500 active: true, 501 entries, 502 cursor: 0, 503 seen: new Set(entries.map(entry => entry.hash)), 504 rendered: new Set(), 505 pending: [], 506 pageSize, 507 latestVisibleTs: 0, 508 oldestVisibleTs: 0, 509 banner: null, 510 bannerButton: null, 511 statusMessage: '', 512 statusMode: false, 513 statusTimer: null, 514 sentinel: null, 515 observer: null, 516 activate: null, 517 deactivate: null 518 } 519 getController().feeds.set(src, state) 520 ensureBanner(state) 521 522 const takeSlice = () => { 523 posts = [] 524 if (state.cursor >= entries.length) { return posts } 525 let idx = state.cursor 526 while (idx < entries.length && posts.length < pageSize) { 527 const entry = entries[idx] 528 if (!state.rendered.has(entry.hash)) { 529 posts.push(entry) 530 } 531 idx += 1 532 } 533 state.cursor = idx 534 return posts 535 } 536 537 const ensureSentinel = () => { 538 let sentinel = state.sentinel 539 if (!sentinel) { 540 sentinel = document.createElement('div') 541 sentinel.className = 'scroll-sentinel' 542 sentinel.style.height = '1px' 543 state.sentinel = sentinel 544 } 545 if (sentinel.parentNode && sentinel.parentNode !== div) { 546 sentinel.parentNode.removeChild(sentinel) 547 } 548 div.appendChild(sentinel) 549 return sentinel 550 } 551 552 const loadNext = async () => { 553 if (loading) { return } 554 if (!state.active) { return false } 555 if (window.location.hash.substring(1) !== src) { return } 556 loading = true 557 try { 558 const next = takeSlice() 559 if (!next.length) { return false } 560 await perfMeasure('adder.loadNext', async () => addPosts(next, div), src || 'home') 561 for (const entry of next) { 562 state.rendered.add(entry.hash) 563 } 564 if (!state.latestVisibleTs && next[0]) { 565 state.latestVisibleTs = normalizeTimestamp(next[0].ts) 566 } 567 if (next[next.length - 1]) { 568 state.oldestVisibleTs = normalizeTimestamp(next[next.length - 1].ts) 569 } 570 ensureSentinel() 571 return true 572 } finally { 573 loading = false 574 } 575 } 576 577 void loadNext() 578 579 const detachArmScroll = () => { 580 if (!armListenerAttached) { return } 581 window.removeEventListener('scroll', armScroll) 582 armListenerAttached = false 583 } 584 585 const armScroll = () => { 586 armed = true 587 detachArmScroll() 588 } 589 const attachArmScroll = () => { 590 if (armed || armListenerAttached) { return } 591 window.addEventListener('scroll', armScroll, { passive: true, once: true }) 592 armListenerAttached = true 593 } 594 595 attachArmScroll() 596 const sentinel = ensureSentinel() 597 const observer = new IntersectionObserver(async (entries) => { 598 const entry = entries[0] 599 if (!entry || !entry.isIntersecting) { return } 600 if (!armed) { return } 601 await loadNext() 602 }, { root: null, rootMargin: '1200px 0px', threshold: 0 }) 603 604 observer.observe(sentinel) 605 state.observer = observer 606 state.deactivate = () => { 607 state.active = false 608 detachArmScroll() 609 state.observer?.disconnect() 610 } 611 state.activate = () => { 612 state.active = true 613 if (state.sentinel) { 614 state.observer?.observe(state.sentinel) 615 } 616 attachArmScroll() 617 updateBanner(state) 618 } 619}