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 { avatarSpan, nameSpan } from './profile.js'
5import { ntfy } from './ntfy.js'
6import { send } from './send.js'
7import { markdown } from './markdown.js'
8import { imgUpload } from './upload.js'
9import { beginPublishVerification, finishPublishVerification } from './publish_status.js'
10
11const ENABLE_EVENT_COMPOSER = false
12const parseOpenedTimestamp = (opened) => {
13 if (typeof opened !== 'string' || opened.length < 13) { return 0 }
14 const ts = Number.parseInt(opened.substring(0, 13), 10)
15 return Number.isFinite(ts) ? ts : 0
16}
17
18async function pushLocalNotification({ hash, author, text }) {
19 try {
20 await fetch('/push-now', {
21 method: 'POST',
22 headers: { 'content-type': 'application/json' },
23 body: JSON.stringify({
24 hash,
25 author,
26 text,
27 url: `${window.location.origin}/#${hash}`,
28 }),
29 })
30 } catch {
31 // Notifications server might be unavailable; ignore.
32 }
33}
34
35export const composer = async (sig, options = {}) => {
36 const obj = {}
37 const isEdit = !!options.editHash && !sig
38 if (sig) {
39 const hash = await apds.hash(sig)
40 obj.replyHash = hash
41 obj.replyAuthor = sig.substring(0, 44)
42 const opened = await apds.open(sig)
43 const msg = await apds.parseYaml(await apds.get(opened.substring(13)))
44 if (msg.name) { obj.replyName = msg.name }
45 if (msg.body) {obj.replyBody = msg.body}
46 }
47
48 const contextDiv = h('div')
49
50 if (obj.replyHash) {
51 const replySymbol = h('span', {classList: 'material-symbols-outlined'}, ['Subdirectory_Arrow_left'])
52 const author = h('a', {href: '#' + obj.replyAuthor}, [obj.replyAuthor.substring(0, 10)])
53 const replyContent = h('a', {href: '#' + obj.replyHash}, [obj.replyHash.substring(0, 10)])
54 contextDiv.appendChild(author)
55 if (obj.replyName) { author.textContent = obj.replyName}
56 if (obj.replyBody) { replyContent.textContent = obj.replyBody.substring(0, 10) + '...'}
57 contextDiv.appendChild(replySymbol)
58 contextDiv.appendChild(replyContent)
59 }
60
61 if (isEdit) {
62 const editSymbol = h('span', {classList: 'material-symbols-outlined'}, ['Edit'])
63 const editTarget = h('a', {href: '#' + options.editHash}, [options.editHash.substring(0, 10)])
64 contextDiv.appendChild(editSymbol)
65 contextDiv.appendChild(editTarget)
66 }
67
68 const textarea = h('textarea', {placeholder: 'Write a message'})
69 if (typeof options.initialBody === 'string' && !isEdit) { textarea.value = options.initialBody }
70 if (isEdit && typeof options.editBody === 'string') { textarea.value = options.editBody }
71
72 const cancel = h('a', {classList: 'material-symbols-outlined', onclick: () => {
73 if (sig) {
74 div.remove()
75 } else {
76 overlay.remove()
77 }
78 }
79 }, ['Cancel'])
80
81 const replyObj = {}
82
83 if (sig) {
84 replyObj.reply = await apds.hash(sig)
85 replyObj.replyto = sig.substring(0, 44)
86 }
87 if (isEdit) {
88 replyObj.edit = options.editHash
89 }
90
91 let pubkey = await apds.pubkey()
92 if (!pubkey && options.autoGenKeypair) {
93 try {
94 const keypair = await apds.generate()
95 await apds.put('keypair', keypair)
96 pubkey = await apds.pubkey()
97 } catch (err) {
98 // Fall back to anonymous composer if keygen fails.
99 }
100 }
101 let composerMode = 'message'
102
103 const eventDate = h('input', {type: 'date'})
104 const makeSelect = (placeholder, values) => {
105 const select = document.createElement('select')
106 const empty = document.createElement('option')
107 empty.value = ''
108 empty.textContent = placeholder
109 select.appendChild(empty)
110 for (const value of values) {
111 const option = document.createElement('option')
112 option.value = value
113 option.textContent = value
114 select.appendChild(option)
115 }
116 return select
117 }
118 const timeOptions = []
119 for (let minutes = 0; minutes < 24 * 60; minutes += 30) {
120 const hour24 = Math.floor(minutes / 60)
121 const minute = minutes % 60
122 const ampm = hour24 < 12 ? 'AM' : 'PM'
123 const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12
124 const label = `${hour12}:${String(minute).padStart(2, '0')}${ampm.toLowerCase()}`
125 timeOptions.push(label)
126 }
127 const eventStartTime = makeSelect('Start time', timeOptions)
128 const eventEndTime = makeSelect('End time', timeOptions)
129 const fallbackTimezones = [
130 'UTC',
131 'America/New_York',
132 'America/Chicago',
133 'America/Denver',
134 'America/Los_Angeles',
135 'Europe/London',
136 'Europe/Paris',
137 'Asia/Tokyo'
138 ]
139 const timezoneOptions = (typeof Intl !== 'undefined' && Intl.supportedValuesOf)
140 ? Intl.supportedValuesOf('timeZone')
141 : fallbackTimezones
142 const eventTimezone = makeSelect('Timezone (optional)', timezoneOptions)
143 const localTz = (typeof Intl !== 'undefined')
144 ? Intl.DateTimeFormat().resolvedOptions().timeZone
145 : ''
146 if (localTz) { eventTimezone.value = localTz }
147 const eventLocation = h('input', {placeholder: 'Location'})
148 const eventLocationStatus = h('div', {classList: 'event-location-status'})
149 const locationResults = h('div', {classList: 'event-location-results'})
150 const locationWrapper = h('div', {classList: 'event-location-wrapper'}, [
151 eventLocation,
152 locationResults
153 ])
154 let locationTimer
155 let locationController
156 let locationItems = []
157 let locationHighlight = -1
158 const setLocationStatus = (msg, isError = false) => {
159 eventLocationStatus.textContent = msg
160 eventLocationStatus.classList.toggle('error', isError)
161 }
162 const clearLocationResults = () => {
163 locationResults.innerHTML = ''
164 locationItems = []
165 locationHighlight = -1
166 locationResults.style.display = 'none'
167 }
168 const chooseLocation = (displayName, shortLabel) => {
169 eventLocation.value = shortLabel || displayName
170 eventLocation.dataset.full = displayName
171 clearLocationResults()
172 setLocationStatus('Location selected.')
173 }
174 const updateHighlight = () => {
175 const options = locationResults.querySelectorAll('.event-location-option')
176 options.forEach((option, index) => {
177 option.classList.toggle('active', index === locationHighlight)
178 })
179 }
180 const formatLocationLabel = (match) => {
181 const address = match.address || {}
182 const name = match.name || (match.namedetails && match.namedetails.name)
183 const road = address.road
184 const house = address.house_number
185 const city = address.city || address.town || address.village || address.hamlet
186 const region = address.state || address.region
187 const country = address.country
188 const street = house && road ? `${house} ${road}` : road
189 const labelParts = [name, street, city, region].filter(Boolean)
190 if (labelParts.length) { return labelParts.join(', ') }
191 const fallbackParts = [match.display_name, country].filter(Boolean)
192 return fallbackParts.join(', ') || 'Unknown'
193 }
194 const renderLocationResults = (data) => {
195 clearLocationResults()
196 locationItems = data
197 locationResults.style.display = 'flex'
198 for (let i = 0; i < data.length; i += 1) {
199 const match = data[i]
200 const label = formatLocationLabel(match)
201 const option = h('div', {
202 classList: 'event-location-option',
203 onmousedown: (e) => {
204 e.preventDefault()
205 chooseLocation(match.display_name, label)
206 }
207 }, [label])
208 locationResults.appendChild(option)
209 }
210 }
211 const updateLocationList = async () => {
212 const query = eventLocation.value.trim()
213 if (query.length < 3) {
214 clearLocationResults()
215 setLocationStatus('')
216 return
217 }
218 if (locationController) { locationController.abort() }
219 locationController = new AbortController()
220 setLocationStatus('Searching...')
221 try {
222 const url = `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&namedetails=1&limit=5&q=${encodeURIComponent(query)}`
223 const res = await fetch(url, { headers: { 'accept': 'application/json' }, signal: locationController.signal })
224 if (!res.ok) { throw new Error('Lookup failed') }
225 const data = await res.json()
226 if (!Array.isArray(data) || data.length === 0) {
227 clearLocationResults()
228 setLocationStatus('No results found.', true)
229 return
230 }
231 renderLocationResults(data)
232 setLocationStatus('')
233 } catch (err) {
234 if (err && err.name === 'AbortError') { return }
235 setLocationStatus('Could not fetch suggestions.', true)
236 }
237 }
238 eventLocation.addEventListener('input', () => {
239 clearTimeout(locationTimer)
240 eventLocation.dataset.full = ''
241 locationTimer = setTimeout(updateLocationList, 300)
242 })
243 eventLocation.addEventListener('keydown', (e) => {
244 if (!locationItems.length) { return }
245 if (e.key === 'ArrowDown') {
246 e.preventDefault()
247 locationHighlight = Math.min(locationHighlight + 1, locationItems.length - 1)
248 updateHighlight()
249 } else if (e.key === 'ArrowUp') {
250 e.preventDefault()
251 locationHighlight = Math.max(locationHighlight - 1, 0)
252 updateHighlight()
253 } else if (e.key === 'Enter') {
254 if (locationHighlight >= 0) {
255 e.preventDefault()
256 chooseLocation(locationItems[locationHighlight].display_name)
257 }
258 } else if (e.key === 'Escape') {
259 clearLocationResults()
260 }
261 })
262 eventLocation.addEventListener('blur', () => {
263 setTimeout(() => { clearLocationResults() }, 150)
264 })
265 const eventHelp = h('div', {style: 'display: none; color: #b00020; font-size: 12px; margin: 4px 0;'})
266 const eventFields = h('div', {style: 'display: none;'}, [
267 h('div', [eventDate]),
268 h('div', {classList: 'event-time-row'}, [eventStartTime, eventEndTime, eventTimezone]),
269 h('div', [locationWrapper]),
270 h('div', [eventLocationStatus]),
271 h('div', [eventHelp])
272 ])
273
274 const parseTime = (label) => {
275 const match = label.match(/^(\d{1,2}):(\d{2})(am|pm)$/i)
276 if (!match) { return '' }
277 let hour24 = Number.parseInt(match[1], 10)
278 const minute = match[2]
279 const ampm = match[3].toLowerCase()
280 if (ampm === 'am') {
281 if (hour24 === 12) { hour24 = 0 }
282 } else if (ampm === 'pm') {
283 if (hour24 < 12) { hour24 += 12 }
284 }
285 return `${String(hour24).padStart(2, '0')}:${minute}`
286 }
287
288 const buildEventYaml = () => {
289 const meta = buildComposeMeta()
290 const lines = ['---']
291 if (meta.start) { lines.push(`start: ${meta.start}`) }
292 if (meta.end) { lines.push(`end: ${meta.end}`) }
293 if (meta.loc) { lines.push(`loc: ${meta.loc}`) }
294 if (meta.tz) { lines.push(`tz: ${meta.tz}`) }
295 lines.push('---')
296 lines.push(textarea.value)
297 return lines.join('\n')
298 }
299
300 const renderEventPreview = async (showRaw) => {
301 const dateValue = eventDate.value || 'Date not set'
302 const startLabel = eventStartTime.value || 'Start not set'
303 const endLabel = eventEndTime.value || 'End not set'
304 const loc = eventLocation.value.trim() || 'Location not set'
305 const tz = eventTimezone.value
306 content.innerHTML = ''
307 if (showRaw) {
308 content.textContent = buildEventYaml()
309 return
310 }
311 const bodyHtml = await markdown(textarea.value)
312 const timeLine = tz
313 ? `${dateValue} • ${startLabel}–${endLabel} • ${tz}`
314 : `${dateValue} • ${startLabel}–${endLabel}`
315 const summary = h('div', {classList: 'event-preview'}, [
316 h('div', {classList: 'event-preview-meta'}, [timeLine]),
317 h('div', {classList: 'event-preview-meta'}, [loc])
318 ])
319 const body = h('div')
320 body.innerHTML = bodyHtml
321 content.appendChild(summary)
322 content.appendChild(body)
323 }
324
325 const renderMessagePreview = async (showRaw) => {
326 if (showRaw) {
327 content.textContent = textarea.value
328 return
329 }
330 content.innerHTML = await markdown(textarea.value)
331 }
332
333 let messageToggle
334 let eventToggle
335 let modeToggle
336 const updateToggleState = () => {
337 if (!ENABLE_EVENT_COMPOSER) { return }
338 if (!messageToggle || !eventToggle) { return }
339 const isEvent = composerMode === 'event'
340 messageToggle.classList.toggle('active', !isEvent)
341 eventToggle.classList.toggle('active', isEvent)
342 messageToggle.setAttribute('aria-pressed', String(!isEvent))
343 eventToggle.setAttribute('aria-pressed', String(isEvent))
344 if (modeToggle) {
345 modeToggle.classList.toggle('event-active', isEvent)
346 }
347 }
348
349 const setComposerMode = (mode) => {
350 if (!ENABLE_EVENT_COMPOSER) {
351 composerMode = 'message'
352 return
353 }
354 composerMode = mode
355 if (mode === 'event') {
356 eventFields.style = 'display: block;'
357 textarea.placeholder = 'Write event details'
358 } else {
359 eventFields.style = 'display: none;'
360 textarea.placeholder = 'Write a message'
361 }
362 updateToggleState()
363 }
364
365 const buildComposeMeta = () => {
366 if (!ENABLE_EVENT_COMPOSER || composerMode !== 'event') { return { ...replyObj } }
367 const meta = { ...replyObj }
368 const loc = (eventLocation.dataset.full || eventLocation.value).trim()
369 const dateValue = eventDate.value
370 const startLabel = eventStartTime.value
371 const endLabel = eventEndTime.value
372 const tz = eventTimezone.value
373 const startValue = startLabel ? parseTime(startLabel) : ''
374 const endValue = endLabel ? parseTime(endLabel) : ''
375 const start = (dateValue && startValue)
376 ? Math.floor(new Date(`${dateValue}T${startValue}`).getTime() / 1000)
377 : null
378 const end = (dateValue && endValue)
379 ? Math.floor(new Date(`${dateValue}T${endValue}`).getTime() / 1000)
380 : null
381 if (Number.isFinite(start)) { meta.start = start }
382 if (Number.isFinite(end)) { meta.end = end }
383 if (loc) { meta.loc = loc }
384 if (tz) { meta.tz = tz }
385 return meta
386 }
387
388 const publishButton = h('button', {style: 'float: right;', onclick: async (e) => {
389 const button = e.target
390 button.disabled = true
391 button.textContent = 'Publishing...'
392 if (ENABLE_EVENT_COMPOSER && composerMode === 'event') {
393 const dateValue = eventDate.value
394 const startLabel = eventStartTime.value
395 const endLabel = eventEndTime.value
396 const startValue = startLabel ? parseTime(startLabel) : ''
397 const endValue = endLabel ? parseTime(endLabel) : ''
398 const loc = (eventLocation.dataset.full || eventLocation.value).trim()
399 const startMs = (dateValue && startValue)
400 ? new Date(`${dateValue}T${startValue}`).getTime()
401 : NaN
402 const endMs = (dateValue && endValue)
403 ? new Date(`${dateValue}T${endValue}`).getTime()
404 : NaN
405 const setHelp = (msg, focusEl) => {
406 eventHelp.textContent = msg
407 eventHelp.style = 'display: block; color: #b00020; font-size: 12px; margin: 4px 0;'
408 if (focusEl) { focusEl.focus() }
409 }
410 eventHelp.style = 'display: none;'
411 if (!loc) {
412 setHelp('Location is required.', eventLocation)
413 button.disabled = false
414 button.textContent = 'Publish'
415 return
416 }
417 if (!dateValue) {
418 setHelp('Event date is required.', eventDate)
419 button.disabled = false
420 button.textContent = 'Publish'
421 return
422 }
423 if (!startValue) {
424 setHelp('Start time is required.', eventStartTime)
425 button.disabled = false
426 button.textContent = 'Publish'
427 return
428 }
429 if (!endValue) {
430 setHelp('End time is required.', eventEndTime)
431 button.disabled = false
432 button.textContent = 'Publish'
433 return
434 }
435 if (endMs < startMs) {
436 setHelp('End time must be after the start time.', eventEndTime)
437 button.disabled = false
438 button.textContent = 'Publish'
439 return
440 }
441 }
442 const published = await apds.compose(textarea.value, buildComposeMeta())
443 textarea.value = ''
444 const signed = await apds.get(published)
445 const opened = await apds.open(signed)
446
447 const blob = await apds.get(opened.substring(13))
448 await ntfy(signed)
449 await ntfy(blob)
450 await send(signed)
451 await send(blob)
452 const hash = await apds.hash(signed)
453 pushLocalNotification({ hash, author: signed.substring(0, 44), text: blob })
454
455 const confirmTargets = [signed]
456 const images = blob.match(/!\[.*?\]\((.*?)\)/g)
457 if (images) {
458 for (const image of images) {
459 const src = image.match(/!\[.*?\]\((.*?)\)/)[1]
460 const imgBlob = await apds.get(src)
461 if (imgBlob) {
462 await send(imgBlob)
463 }
464 }
465 }
466 const verifyId = beginPublishVerification({ hash })
467 void (async () => {
468 try {
469 const { confirmMessagesPersisted } = await import('./websocket.js')
470 const openedTs = parseOpenedTimestamp(opened)
471 const since = openedTs ? Math.max(0, openedTs - 10000) : undefined
472 const persisted = await confirmMessagesPersisted(confirmTargets, { since })
473 finishPublishVerification(verifyId, persisted)
474 if (!persisted.ok) {
475 console.warn('publish confirmation failed', persisted)
476 }
477 } catch (err) {
478 console.warn('publish confirmation errored', err)
479 finishPublishVerification(verifyId, { ok: false, reason: 'unconfirmed', missing: confirmTargets })
480 }
481 })()
482
483 if (isEdit) {
484 render.invalidateEdits(options.editHash)
485 await render.refreshEdits(options.editHash, { forceLatest: true })
486 overlay.remove()
487 return
488 }
489
490 if (sig) {
491 div.id = hash
492 if (opened) { div.dataset.opened = opened }
493 await render.blob(signed, { hash, opened })
494 } else {
495 overlay.remove()
496 const scroller = document.getElementById('scroller')
497 const opened = await apds.open(signed)
498 const ts = opened ? opened.substring(0, 13) : Date.now().toString()
499 if (window.__feedEnqueue) {
500 const src = window.location.hash.substring(1)
501 // UX: if you just posted, show it immediately.
502 // adder.js normally hides new posts behind a "Show N new posts" banner when you're scrolled.
503 // That's good for other people's posts, but bad for *your own* just-published post.
504 const queued = await window.__feedEnqueue(src, { hash, ts: Number.parseInt(ts, 10), blob: signed, opened, local: true })
505 if (!queued) {
506 const placeholder = render.insertByTimestamp(scroller, hash, ts)
507 if (placeholder) {
508 if (opened) { placeholder.dataset.opened = opened }
509 await render.blob(signed, { hash, opened })
510 }
511 }
512 } else {
513 const placeholder = render.insertByTimestamp(scroller, hash, ts)
514 if (placeholder) {
515 if (opened) { placeholder.dataset.opened = opened }
516 await render.blob(signed, { hash, opened })
517 }
518 }
519 }
520 }}, ['Publish'])
521
522 if (ENABLE_EVENT_COMPOSER) {
523 messageToggle = h('button', {type: 'button', onclick: () => setComposerMode('message')}, ['Message'])
524 eventToggle = h('button', {type: 'button', onclick: () => setComposerMode('event')}, ['Event'])
525 const toggleIndicator = h('span', {classList: 'composer-toggle-indicator'})
526 modeToggle = h('div', {classList: 'composer-toggle'}, [
527 toggleIndicator,
528 messageToggle,
529 eventToggle
530 ])
531 updateToggleState()
532 }
533
534 const rawDiv = h('div', {classList: 'message-raw'})
535 let rawshow = true
536 let rawContent
537 let rawText = ''
538 const updateRawText = () => {
539 rawText = (ENABLE_EVENT_COMPOSER && composerMode === 'event') ? buildEventYaml() : textarea.value
540 if (rawContent) { rawContent.textContent = rawText }
541 }
542 const rawToggle = h('a', {classList: 'material-symbols-outlined', onclick: () => {
543 updateRawText()
544 if (rawshow) {
545 if (!rawContent) {
546 rawContent = h('pre', {classList: 'hljs'}, [rawText])
547 }
548 rawDiv.appendChild(rawContent)
549 rawshow = false
550 } else {
551 rawContent.parentNode.removeChild(rawContent)
552 rawshow = true
553 }
554 }}, ['Code'])
555
556 const renderPreview = async () => {
557 updateRawText()
558 if (ENABLE_EVENT_COMPOSER && composerMode === 'event') {
559 await renderEventPreview(false)
560 } else {
561 await renderMessagePreview(false)
562 }
563 }
564
565 const uploadControls = await imgUpload(textarea)
566
567 const previewButton = h('button', {style: 'float: right;', onclick: async () => {
568 textareaDiv.style = 'display: none;'
569 previewDiv.style = 'display: block;'
570 uploadControls.style = 'display: none;'
571 await renderPreview()
572 }}, ['Preview'])
573
574 const textareaDiv = h('div', {classList: 'composer'}, ENABLE_EVENT_COMPOSER
575 ? [modeToggle, eventFields, textarea, previewButton]
576 : [textarea, previewButton]
577 )
578
579 const content = h('div')
580
581 const previewControls = h('div', {classList: 'preview-controls'}, [
582 publishButton,
583 h('button', {style: 'float: right;', onclick: () => {
584 textareaDiv.style = 'display: block;'
585 previewDiv.style = 'display: none;'
586 uploadControls.style = 'display: block;'
587 }}, ['Cancel'])
588 ])
589
590 const previewDiv = h('div', {style: 'display: none;'}, [
591 content,
592 rawDiv,
593 previewControls
594 ])
595
596 const meta = h('span', {classList: 'message-meta'}, [
597 h('span', {classList: 'pubkey'}, [pubkey ? pubkey.substring(0, 6) : 'anon']),
598 ' ',
599 rawToggle,
600 ' ',
601 cancel,
602 ])
603
604 const bodyWrap = h('div', {classList: 'message-body'}, [
605 contextDiv,
606 textareaDiv,
607 previewDiv,
608 uploadControls
609 ])
610
611 const composerHeader = h('div', {classList: 'composer-header'}, ENABLE_EVENT_COMPOSER
612 ? [await nameSpan(), modeToggle]
613 : [await nameSpan()]
614 )
615
616 const composerDiv = h('div', [
617 meta,
618 h('div', {classList: 'message-main'}, [
619 h('span', [await avatarSpan()]),
620 h('div', {classList: 'message-stack'}, [
621 composerHeader,
622 bodyWrap
623 ])
624 ])
625 ])
626
627 const div = h('div', {classList: 'message modal-content'}, [
628 composerDiv
629 ])
630
631 if (sig) {
632 div.className = 'message reply'
633 div.id = 'reply-composer-' + obj.replyHash
634 }
635
636 const overlay = h('div', {
637 classList: 'modal-overlay',
638 onclick: (e) => {
639 if (e.target === overlay) {
640 overlay.remove()
641 }
642 }
643 }, [div])
644
645 if (sig) { return div }
646
647 return overlay
648
649}