a demonstration replicated social networking web app built with anproto
wiredove.net/
social
ed25519
protocols
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}