a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 649 lines 22 kB view raw
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}