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