a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols

composer: add event mode UI behind flag

+580 -15
+405 -11
composer.js
··· 7 7 import { markdown } from './markdown.js' 8 8 import { imgUpload } from './upload.js' 9 9 10 + const ENABLE_EVENT_COMPOSER = false 11 + 10 12 async function pushLocalNotification({ hash, author, text }) { 11 13 try { 12 14 await fetch('/push-now', { ··· 80 82 } 81 83 82 84 const pubkey = await apds.pubkey() 85 + let composerMode = 'message' 86 + 87 + const eventDate = h('input', {type: 'date'}) 88 + const makeSelect = (placeholder, values) => { 89 + const select = document.createElement('select') 90 + const empty = document.createElement('option') 91 + empty.value = '' 92 + empty.textContent = placeholder 93 + select.appendChild(empty) 94 + for (const value of values) { 95 + const option = document.createElement('option') 96 + option.value = value 97 + option.textContent = value 98 + select.appendChild(option) 99 + } 100 + return select 101 + } 102 + const timeOptions = [] 103 + for (let minutes = 0; minutes < 24 * 60; minutes += 30) { 104 + const hour24 = Math.floor(minutes / 60) 105 + const minute = minutes % 60 106 + const ampm = hour24 < 12 ? 'AM' : 'PM' 107 + const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12 108 + const label = `${hour12}:${String(minute).padStart(2, '0')}${ampm.toLowerCase()}` 109 + timeOptions.push(label) 110 + } 111 + const eventStartTime = makeSelect('Start time', timeOptions) 112 + const eventEndTime = makeSelect('End time', timeOptions) 113 + const fallbackTimezones = [ 114 + 'UTC', 115 + 'America/New_York', 116 + 'America/Chicago', 117 + 'America/Denver', 118 + 'America/Los_Angeles', 119 + 'Europe/London', 120 + 'Europe/Paris', 121 + 'Asia/Tokyo' 122 + ] 123 + const timezoneOptions = (typeof Intl !== 'undefined' && Intl.supportedValuesOf) 124 + ? Intl.supportedValuesOf('timeZone') 125 + : fallbackTimezones 126 + const eventTimezone = makeSelect('Timezone (optional)', timezoneOptions) 127 + const localTz = (typeof Intl !== 'undefined') 128 + ? Intl.DateTimeFormat().resolvedOptions().timeZone 129 + : '' 130 + if (localTz) { eventTimezone.value = localTz } 131 + const eventLocation = h('input', {placeholder: 'Location'}) 132 + const eventLocationStatus = h('div', {classList: 'event-location-status'}) 133 + const locationResults = h('div', {classList: 'event-location-results'}) 134 + const locationWrapper = h('div', {classList: 'event-location-wrapper'}, [ 135 + eventLocation, 136 + locationResults 137 + ]) 138 + let locationTimer 139 + let locationController 140 + let locationItems = [] 141 + let locationHighlight = -1 142 + const setLocationStatus = (msg, isError = false) => { 143 + eventLocationStatus.textContent = msg 144 + eventLocationStatus.classList.toggle('error', isError) 145 + } 146 + const clearLocationResults = () => { 147 + locationResults.innerHTML = '' 148 + locationItems = [] 149 + locationHighlight = -1 150 + locationResults.style.display = 'none' 151 + } 152 + const chooseLocation = (displayName, shortLabel) => { 153 + eventLocation.value = shortLabel || displayName 154 + eventLocation.dataset.full = displayName 155 + clearLocationResults() 156 + setLocationStatus('Location selected.') 157 + } 158 + const updateHighlight = () => { 159 + const options = locationResults.querySelectorAll('.event-location-option') 160 + options.forEach((option, index) => { 161 + option.classList.toggle('active', index === locationHighlight) 162 + }) 163 + } 164 + const formatLocationLabel = (match) => { 165 + const address = match.address || {} 166 + const name = match.name || (match.namedetails && match.namedetails.name) 167 + const road = address.road 168 + const house = address.house_number 169 + const city = address.city || address.town || address.village || address.hamlet 170 + const region = address.state || address.region 171 + const country = address.country 172 + const street = house && road ? `${house} ${road}` : road 173 + const labelParts = [name, street, city, region].filter(Boolean) 174 + if (labelParts.length) { return labelParts.join(', ') } 175 + const fallbackParts = [match.display_name, country].filter(Boolean) 176 + return fallbackParts.join(', ') || 'Unknown' 177 + } 178 + const renderLocationResults = (data) => { 179 + clearLocationResults() 180 + locationItems = data 181 + locationResults.style.display = 'flex' 182 + for (let i = 0; i < data.length; i += 1) { 183 + const match = data[i] 184 + const label = formatLocationLabel(match) 185 + const option = h('div', { 186 + classList: 'event-location-option', 187 + onmousedown: (e) => { 188 + e.preventDefault() 189 + chooseLocation(match.display_name, label) 190 + } 191 + }, [label]) 192 + locationResults.appendChild(option) 193 + } 194 + } 195 + const updateLocationList = async () => { 196 + const query = eventLocation.value.trim() 197 + if (query.length < 3) { 198 + clearLocationResults() 199 + setLocationStatus('') 200 + return 201 + } 202 + if (locationController) { locationController.abort() } 203 + locationController = new AbortController() 204 + setLocationStatus('Searching...') 205 + try { 206 + const url = `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&namedetails=1&limit=5&q=${encodeURIComponent(query)}` 207 + const res = await fetch(url, { headers: { 'accept': 'application/json' }, signal: locationController.signal }) 208 + if (!res.ok) { throw new Error('Lookup failed') } 209 + const data = await res.json() 210 + if (!Array.isArray(data) || data.length === 0) { 211 + clearLocationResults() 212 + setLocationStatus('No results found.', true) 213 + return 214 + } 215 + renderLocationResults(data) 216 + setLocationStatus('') 217 + } catch (err) { 218 + if (err && err.name === 'AbortError') { return } 219 + setLocationStatus('Could not fetch suggestions.', true) 220 + } 221 + } 222 + eventLocation.addEventListener('input', () => { 223 + clearTimeout(locationTimer) 224 + eventLocation.dataset.full = '' 225 + locationTimer = setTimeout(updateLocationList, 300) 226 + }) 227 + eventLocation.addEventListener('keydown', (e) => { 228 + if (!locationItems.length) { return } 229 + if (e.key === 'ArrowDown') { 230 + e.preventDefault() 231 + locationHighlight = Math.min(locationHighlight + 1, locationItems.length - 1) 232 + updateHighlight() 233 + } else if (e.key === 'ArrowUp') { 234 + e.preventDefault() 235 + locationHighlight = Math.max(locationHighlight - 1, 0) 236 + updateHighlight() 237 + } else if (e.key === 'Enter') { 238 + if (locationHighlight >= 0) { 239 + e.preventDefault() 240 + chooseLocation(locationItems[locationHighlight].display_name) 241 + } 242 + } else if (e.key === 'Escape') { 243 + clearLocationResults() 244 + } 245 + }) 246 + eventLocation.addEventListener('blur', () => { 247 + setTimeout(() => { clearLocationResults() }, 150) 248 + }) 249 + const eventHelp = h('div', {style: 'display: none; color: #b00020; font-size: 12px; margin: 4px 0;'}) 250 + const eventFields = h('div', {style: 'display: none;'}, [ 251 + h('div', [eventDate]), 252 + h('div', {classList: 'event-time-row'}, [eventStartTime, eventEndTime, eventTimezone]), 253 + h('div', [locationWrapper]), 254 + h('div', [eventLocationStatus]), 255 + h('div', [eventHelp]) 256 + ]) 257 + 258 + const parseTime = (label) => { 259 + const match = label.match(/^(\d{1,2}):(\d{2})(am|pm)$/i) 260 + if (!match) { return '' } 261 + let hour24 = Number.parseInt(match[1], 10) 262 + const minute = match[2] 263 + const ampm = match[3].toLowerCase() 264 + if (ampm === 'am') { 265 + if (hour24 === 12) { hour24 = 0 } 266 + } else if (ampm === 'pm') { 267 + if (hour24 < 12) { hour24 += 12 } 268 + } 269 + return `${String(hour24).padStart(2, '0')}:${minute}` 270 + } 271 + 272 + const buildEventYaml = () => { 273 + const meta = buildComposeMeta() 274 + const lines = ['---'] 275 + if (meta.start) { lines.push(`start: ${meta.start}`) } 276 + if (meta.end) { lines.push(`end: ${meta.end}`) } 277 + if (meta.loc) { lines.push(`loc: ${meta.loc}`) } 278 + if (meta.tz) { lines.push(`tz: ${meta.tz}`) } 279 + lines.push('---') 280 + lines.push(textarea.value) 281 + return lines.join('\n') 282 + } 283 + 284 + const renderEventPreview = async (showRaw) => { 285 + const dateValue = eventDate.value || 'Date not set' 286 + const startLabel = eventStartTime.value || 'Start not set' 287 + const endLabel = eventEndTime.value || 'End not set' 288 + const loc = eventLocation.value.trim() || 'Location not set' 289 + const tz = eventTimezone.value 290 + content.innerHTML = '' 291 + if (showRaw) { 292 + content.textContent = buildEventYaml() 293 + return 294 + } 295 + const bodyHtml = await markdown(textarea.value) 296 + const timeLine = tz 297 + ? `${dateValue} • ${startLabel}–${endLabel} • ${tz}` 298 + : `${dateValue} • ${startLabel}–${endLabel}` 299 + const summary = h('div', {classList: 'event-preview'}, [ 300 + h('div', {classList: 'event-preview-meta'}, [timeLine]), 301 + h('div', {classList: 'event-preview-meta'}, [loc]) 302 + ]) 303 + const body = h('div') 304 + body.innerHTML = bodyHtml 305 + content.appendChild(summary) 306 + content.appendChild(body) 307 + } 308 + 309 + const renderMessagePreview = async (showRaw) => { 310 + if (showRaw) { 311 + content.textContent = textarea.value 312 + return 313 + } 314 + content.innerHTML = await markdown(textarea.value) 315 + } 316 + 317 + let messageToggle 318 + let eventToggle 319 + let modeToggle 320 + const updateToggleState = () => { 321 + if (!ENABLE_EVENT_COMPOSER) { return } 322 + if (!messageToggle || !eventToggle) { return } 323 + const isEvent = composerMode === 'event' 324 + messageToggle.classList.toggle('active', !isEvent) 325 + eventToggle.classList.toggle('active', isEvent) 326 + messageToggle.setAttribute('aria-pressed', String(!isEvent)) 327 + eventToggle.setAttribute('aria-pressed', String(isEvent)) 328 + if (modeToggle) { 329 + modeToggle.classList.toggle('event-active', isEvent) 330 + } 331 + } 332 + 333 + const setComposerMode = (mode) => { 334 + if (!ENABLE_EVENT_COMPOSER) { 335 + composerMode = 'message' 336 + return 337 + } 338 + composerMode = mode 339 + if (mode === 'event') { 340 + eventFields.style = 'display: block;' 341 + textarea.placeholder = 'Write event details' 342 + } else { 343 + eventFields.style = 'display: none;' 344 + textarea.placeholder = 'Write a message' 345 + } 346 + updateToggleState() 347 + } 348 + 349 + const buildComposeMeta = () => { 350 + if (!ENABLE_EVENT_COMPOSER || composerMode !== 'event') { return { ...replyObj } } 351 + const meta = { ...replyObj } 352 + const loc = (eventLocation.dataset.full || eventLocation.value).trim() 353 + const dateValue = eventDate.value 354 + const startLabel = eventStartTime.value 355 + const endLabel = eventEndTime.value 356 + const tz = eventTimezone.value 357 + const startValue = startLabel ? parseTime(startLabel) : '' 358 + const endValue = endLabel ? parseTime(endLabel) : '' 359 + const start = (dateValue && startValue) 360 + ? Math.floor(new Date(`${dateValue}T${startValue}`).getTime() / 1000) 361 + : null 362 + const end = (dateValue && endValue) 363 + ? Math.floor(new Date(`${dateValue}T${endValue}`).getTime() / 1000) 364 + : null 365 + if (Number.isFinite(start)) { meta.start = start } 366 + if (Number.isFinite(end)) { meta.end = end } 367 + if (loc) { meta.loc = loc } 368 + if (tz) { meta.tz = tz } 369 + return meta 370 + } 83 371 84 372 const publishButton = h('button', {style: 'float: right;', onclick: async (e) => { 85 - e.target.disabled = true 86 - e.target.textContent = 'Publishing...' 87 - const published = await apds.compose(textarea.value, replyObj) 373 + const button = e.target 374 + button.disabled = true 375 + button.textContent = 'Publishing...' 376 + if (ENABLE_EVENT_COMPOSER && composerMode === 'event') { 377 + const dateValue = eventDate.value 378 + const startLabel = eventStartTime.value 379 + const endLabel = eventEndTime.value 380 + const startValue = startLabel ? parseTime(startLabel) : '' 381 + const endValue = endLabel ? parseTime(endLabel) : '' 382 + const loc = (eventLocation.dataset.full || eventLocation.value).trim() 383 + const startMs = (dateValue && startValue) 384 + ? new Date(`${dateValue}T${startValue}`).getTime() 385 + : NaN 386 + const endMs = (dateValue && endValue) 387 + ? new Date(`${dateValue}T${endValue}`).getTime() 388 + : NaN 389 + const setHelp = (msg, focusEl) => { 390 + eventHelp.textContent = msg 391 + eventHelp.style = 'display: block; color: #b00020; font-size: 12px; margin: 4px 0;' 392 + if (focusEl) { focusEl.focus() } 393 + } 394 + eventHelp.style = 'display: none;' 395 + if (!loc) { 396 + setHelp('Location is required.', eventLocation) 397 + button.disabled = false 398 + button.textContent = 'Publish' 399 + return 400 + } 401 + if (!dateValue) { 402 + setHelp('Event date is required.', eventDate) 403 + button.disabled = false 404 + button.textContent = 'Publish' 405 + return 406 + } 407 + if (!startValue) { 408 + setHelp('Start time is required.', eventStartTime) 409 + button.disabled = false 410 + button.textContent = 'Publish' 411 + return 412 + } 413 + if (!endValue) { 414 + setHelp('End time is required.', eventEndTime) 415 + button.disabled = false 416 + button.textContent = 'Publish' 417 + return 418 + } 419 + if (endMs < startMs) { 420 + setHelp('End time must be after the start time.', eventEndTime) 421 + button.disabled = false 422 + button.textContent = 'Publish' 423 + return 424 + } 425 + } 426 + const published = await apds.compose(textarea.value, buildComposeMeta()) 88 427 textarea.value = '' 89 428 const signed = await apds.get(published) 90 429 const opened = await apds.open(signed) ··· 142 481 } 143 482 }}, ['Publish']) 144 483 484 + if (ENABLE_EVENT_COMPOSER) { 485 + messageToggle = h('button', {type: 'button', onclick: () => setComposerMode('message')}, ['Message']) 486 + eventToggle = h('button', {type: 'button', onclick: () => setComposerMode('event')}, ['Event']) 487 + const toggleIndicator = h('span', {classList: 'composer-toggle-indicator'}) 488 + modeToggle = h('div', {classList: 'composer-toggle'}, [ 489 + toggleIndicator, 490 + messageToggle, 491 + eventToggle 492 + ]) 493 + updateToggleState() 494 + } 495 + 496 + const rawDiv = h('div', {classList: 'message-raw'}) 497 + let rawshow = true 498 + let rawContent 499 + let rawText = '' 500 + const updateRawText = () => { 501 + rawText = (ENABLE_EVENT_COMPOSER && composerMode === 'event') ? buildEventYaml() : textarea.value 502 + if (rawContent) { rawContent.textContent = rawText } 503 + } 504 + const rawToggle = h('a', {classList: 'material-symbols-outlined', onclick: () => { 505 + updateRawText() 506 + if (rawshow) { 507 + if (!rawContent) { 508 + rawContent = h('pre', {classList: 'hljs'}, [rawText]) 509 + } 510 + rawDiv.appendChild(rawContent) 511 + rawshow = false 512 + } else { 513 + rawContent.parentNode.removeChild(rawContent) 514 + rawshow = true 515 + } 516 + }}, ['Code']) 517 + 518 + const renderPreview = async () => { 519 + updateRawText() 520 + if (ENABLE_EVENT_COMPOSER && composerMode === 'event') { 521 + await renderEventPreview(false) 522 + } else { 523 + await renderMessagePreview(false) 524 + } 525 + } 526 + 145 527 const previewButton = h('button', {style: 'float: right;', onclick: async () => { 146 528 textareaDiv.style = 'display: none;' 147 529 previewDiv.style = 'display: block;' 148 - content.innerHTML = await markdown(textarea.value) 530 + await renderPreview() 149 531 }}, ['Preview']) 150 532 151 - const textareaDiv = h('div', {classList: 'composer'}, [ 152 - textarea, 153 - previewButton 154 - ]) 533 + const textareaDiv = h('div', {classList: 'composer'}, ENABLE_EVENT_COMPOSER 534 + ? [modeToggle, eventFields, textarea, previewButton] 535 + : [textarea, previewButton] 536 + ) 155 537 156 538 const content = h('div') 157 539 158 - const previewDiv = h('div', {style: 'display: none;'}, [ 159 - content, 540 + const previewControls = h('div', {classList: 'preview-controls'}, [ 160 541 publishButton, 161 542 h('button', {style: 'float: right;', onclick: () => { 162 543 textareaDiv.style = 'display: block;' ··· 164 545 }}, ['Cancel']) 165 546 ]) 166 547 548 + const previewDiv = h('div', {style: 'display: none;'}, [ 549 + content, 550 + rawDiv, 551 + previewControls 552 + ]) 553 + 167 554 const meta = h('span', {classList: 'message-meta'}, [ 168 555 h('span', {classList: 'pubkey'}, [pubkey.substring(0, 6)]), 556 + ' ', 557 + rawToggle, 169 558 ' ', 170 559 cancel, 171 560 ]) ··· 177 566 await imgUpload(textarea) 178 567 ]) 179 568 569 + const composerHeader = h('div', {classList: 'composer-header'}, ENABLE_EVENT_COMPOSER 570 + ? [await nameSpan(), modeToggle] 571 + : [await nameSpan()] 572 + ) 573 + 180 574 const composerDiv = h('div', [ 181 575 meta, 182 576 h('div', {classList: 'message-main'}, [ 183 577 h('span', [await avatarSpan()]), 184 578 h('div', {classList: 'message-stack'}, [ 185 - await nameSpan(), 579 + composerHeader, 186 580 bodyWrap 187 581 ]) 188 582 ])
+175 -4
style.css
··· 116 116 cursor: pointer; 117 117 } 118 118 119 - textarea, input { 119 + .composer-toggle { 120 + display: inline-flex; 121 + gap: 2px; 122 + margin-bottom: 6px; 123 + background: #f0f0f0; 124 + border: 1px solid #e4e4e4; 125 + border-radius: 999px; 126 + padding: 2px; 127 + position: relative; 128 + overflow: hidden; 129 + } 130 + 131 + .composer-toggle button { 132 + border-radius: 999px; 133 + padding: 4px 10px; 134 + border: none; 135 + background: transparent; 136 + position: relative; 137 + z-index: 1; 138 + } 139 + 140 + .composer-toggle button.active { 141 + font-weight: 600; 142 + } 143 + 144 + .composer-toggle-indicator { 145 + position: absolute; 146 + top: 2px; 147 + bottom: 2px; 148 + left: 2px; 149 + width: calc(50% - 2px); 150 + border-radius: 999px; 151 + background: #fff; 152 + border: 1px solid #e4e4e4; 153 + transition: transform 180ms ease; 154 + pointer-events: none; 155 + } 156 + 157 + .composer-toggle.event-active .composer-toggle-indicator { 158 + transform: translateX(100%); 159 + } 160 + 161 + textarea, input, select { 120 162 font-size: 1em; 121 163 font-family: "Source Sans 3", sans-serif; 122 164 border: 1px solid #f8f8f8; ··· 129 171 130 172 .composer { margin-left: 0; margin-top: 0;} 131 173 132 - textarea:hover, textarea:focus, input:hover, input:focus, { 174 + textarea:hover, textarea:focus, input:hover, input:focus, select:hover, select:focus, { 133 175 background: transparent; 134 176 } 135 177 136 - textarea:focus, input:focus { 178 + textarea:focus, input:focus, select:focus { 137 179 outline: none !important; 138 180 } 139 181 ··· 144 186 height: 150px; 145 187 } 146 188 189 + .composer-header { 190 + display: flex; 191 + align-items: center; 192 + gap: 8px; 193 + flex-wrap: wrap; 194 + } 195 + 196 + .event-preview { 197 + border: 1px solid #e4e4e4; 198 + border-radius: 6px; 199 + padding: 8px 10px; 200 + margin-bottom: 8px; 201 + background: #fafafa; 202 + } 203 + 204 + .event-preview-title { 205 + font-weight: 600; 206 + margin-bottom: 2px; 207 + } 208 + 209 + .event-preview-meta { 210 + color: #777; 211 + font-size: 0.95em; 212 + } 213 + 214 + .event-location-wrapper { 215 + position: relative; 216 + } 217 + 218 + .event-location-wrapper input { 219 + width: 100%; 220 + } 221 + 222 + .event-time-row { 223 + display: flex; 224 + gap: 6px; 225 + flex-wrap: wrap; 226 + } 227 + 228 + .event-time-row select { 229 + min-width: 120px; 230 + } 231 + 232 + .event-location-results { 233 + position: absolute; 234 + z-index: 5; 235 + top: calc(100% + 4px); 236 + left: 0; 237 + right: 0; 238 + display: none; 239 + flex-direction: column; 240 + gap: 2px; 241 + padding: 4px; 242 + border: 1px solid #e4e4e4; 243 + background: #fff; 244 + border-radius: 6px; 245 + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); 246 + } 247 + 248 + .event-location-option { 249 + text-align: left; 250 + padding: 6px 8px; 251 + border-radius: 4px; 252 + cursor: pointer; 253 + } 254 + 255 + .event-location-option:hover, 256 + .event-location-option.active { 257 + background: #f2f2f2; 258 + } 259 + 260 + .event-location-status { 261 + font-size: 0.9em; 262 + color: #2a6a2a; 263 + } 264 + 265 + .event-location-status.error { 266 + color: #b00020; 267 + } 268 + 269 + .preview-controls { 270 + display: flex; 271 + align-items: center; 272 + gap: 8px; 273 + margin-top: 8px; 274 + } 275 + 147 276 a { 148 277 color: #045fd0; 149 278 text-decoration: none; ··· 305 434 .message:hover { border: 1px solid #333;} 306 435 .new-posts-button { background: #3a2f12; border: 1px solid #6b5320; color: #f5d27a; } 307 436 308 - textarea, input, iframe { background: #222; color: #f5f5f5; border: 1px solid #222;} 437 + textarea, input, select, iframe { background: #222; color: #f5f5f5; border: 1px solid #222;} 309 438 310 439 button { color: #ccc; background: #333; border: 1px solid #444;} 311 440 button:hover { background: #222;} 312 441 hr { border: 1px solid #333;} 313 442 pre, code { background: #333; color: #f5f5f5;} 314 443 a {color: #50afe4;} 444 + 445 + .composer-toggle { 446 + background: #2a2a2a; 447 + border-color: #333; 448 + } 449 + 450 + .composer-toggle-indicator { 451 + background: #3a3a3a; 452 + border-color: #4a4a4a; 453 + } 454 + 455 + .event-preview { 456 + background: #1f1f1f; 457 + border-color: #2a2a2a; 458 + } 459 + 460 + .event-preview-meta { 461 + color: #a5a5a5; 462 + } 463 + 464 + .event-location-status { 465 + color: #7bc27b; 466 + } 467 + 468 + .event-location-status.error { 469 + color: #f28b82; 470 + } 471 + 472 + .event-location-results { 473 + background: #1f1f1f; 474 + border-color: #333; 475 + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); 476 + } 477 + 478 + .event-location-option { 479 + color: #f5f5f5; 480 + } 481 + 482 + .event-location-option:hover, 483 + .event-location-option.active { 484 + background: #2b2b2b; 485 + } 315 486 316 487 } 317 488