vinney...axkl

Zero-JS Hypermedia Browser

avatar
vinney...axkl
vcavallo@vinneycavallo.com
npub19ma2...axkl
Engineer at https://opennode.com --- Working on https://catallax.network - decentralized labor/bounty protocol and: https://attestr.app/ - mutual agreements signed on nostr Do you like sharing paywalled content to nostr? Install this extension: https://chromewebstore.google.com/detail/readtorelay/gfncdikmbmefjjbahjhgkodnhepikecj - https://github.com/vcavallo/ReadToRelay Order print books with bitcoin! https://whitepaperbooks.com

Notes (13)

Check out what this screenshot note is replying to. I'm both excited and scared. Next steps: - host the malleable UI "client" (it's just a static html file) - make it easier to load a given note in the client - upgrade the "client" so that it can at least do these: 1. query + foreach - enables feeds/lists 2. embed - Enables composition 3. if/else - Conditional UIs 4. local state (??) - Interactive apps without publishing 5. computed - Derived values 6. pagination - Scale to large datasets X. ...eventually rewrite the client to be more of a "bootstrap/BIOS" so that the bot response actually _includes the "client code" html/js that it needs to run itself_. and the client replaces itself with that "OS". so essentially notes become "executable" apps. πŸ‘Ή (also the bot is offline so don't bother talking to it) nostr:nevent1qvzqqqqqqypzqth65u2mhdrd6klxkldg6acqyek3ze6tjyacz79dmdwzuc7esue3qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qghwaehxw309aex2mrp0yh8qunfd4skctnwv46z7qpq6ejq3qw4e895u50c364mnkwrcel7fw7djwrqs76qmevysftf8fqsf0mylr https://github.com/vcavallo/nostr-hypermedia/blob/hatebot/hateoas-js-readme.md
2025-12-03 15:48:23 from 1 relay(s) View Thread β†’
nostr:npub1h00x5r5gglsumv46tmqzrny5n6euauf9hqcy5ay0uywqgpuepmkq5x62ut once again i request that you build me a poll where users can vote for their favorite ice cream flavor
2025-12-03 15:34:37 from 1 relay(s) View Thread β†’
Getting somewhere... nostr:nevent1qvzqqqqqqypzqth65u2mhdrd6klxkldg6acqyek3ze6tjyacz79dmdwzuc7esue3qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qghwaehxw309aex2mrp0yh8qunfd4skctnwv46z7qpqnugmmqtr0zp6mxsznrngmhczzu7dwv9c9e47dfj23dmn2r088acqsh40us nostr:nevent1qvzqqqqqqypzqth65u2mhdrd6klxkldg6acqyek3ze6tjyacz79dmdwzuc7esue3qy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcqyqhsl76wgtkgmjp4mc20sra2yhzm25ccge4dxtlr2qduehvru9j865j2ryl
2025-12-03 15:33:27 from 1 relay(s) View Thread β†’
nostr:npub1h00x5r5gglsumv46tmqzrny5n6euauf9hqcy5ay0uywqgpuepmkq5x62ut make me poll where people can select their favorite ice cream flavor
2025-12-03 15:28:54 from 1 relay(s) View Thread β†’
hey nostr:npub1h00x5r5gglsumv46tmqzrny5n6euauf9hqcy5ay0uywqgpuepmkq5x62ut make me poll where people can select their favorite ice cream flavor
2025-12-03 15:17:35 from 1 relay(s) View Thread β†’
hello nostr:npub1h00x5r5gglsumv46tmqzrny5n6euauf9hqcy5ay0uywqgpuepmkq5x62ut
2025-12-03 15:12:38 from 1 relay(s) View Thread β†’
has anyone built a nostr ai bot that pays for its requests with lightning ( nostr:npub16g4umvwj2pduqc8kt2rv6heq2vhvtulyrsr2a20d4suldwnkl4hquekv4h or similar) and gets its wallet balance from zaps? so that the tokens are paid for by "the community" or at least the npubs making requests in the moment?
2025-12-03 15:11:41 from 1 relay(s) View Thread β†’
tomorrow i'm going to make a bot that you describe a ui/ux to and it responds by posting a note that just IS that experience, rendered in a hypermedia client. no code necessary nostr:nevent1qqsqcujy24rgkx5psnnc7rem0jajep85735fjw4wn8e9n4mkx3fdtmgpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygpwl2n3twa5dh2mu6ma4rthqqnx6yt8fwgnhqtc4hd4ctnrmxrnxypsgqqqqqqsahmr8l
2025-12-03 05:18:21 from 1 relay(s) View Thread β†’
below this quote is a nostr client. save it as html and open it in your browser. see https://github.com/vcavallo/nostr-hypermedia/blob/hateoas-js/hateoas-js-readme.md for more information nostr:nevent1qvzqqqqqqypzqth65u2mhdrd6klxkldg6acqyek3ze6tjyacz79dmdwzuc7esue3qythwumn8ghj7un9d3shjtnswf5k6ctv9ehx2ap0qy88wumn8ghj7mn0wvhxcmmv9uqzpqld9jnemvqzh9fe4mpsw7vmq6edmk7thshec5x4z75uud8ky352klatxz <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Malleable UI - Client-Side Nostr Hypermedia</title> <!-- Alpine.js for reactivity --> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #e0e0e0; background: #0d1117; min-height: 100vh; } .app { max-width: 900px; margin: 0 auto; padding: 20px; } header { background: linear-gradient(135deg, #238636 0%, #1f6feb 100%); color: white; padding: 24px; border-radius: 8px; margin-bottom: 20px; text-align: center; } header h1 { font-size: 24px; margin-bottom: 4px; } header .subtitle { opacity: 0.9; font-size: 14px; } .controls { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; } .controls label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; } .controls input { width: 100%; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e0e0e0; font-size: 14px; font-family: monospace; } .controls input:focus { outline: none; border-color: #238636; } .controls .actions { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; } .btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background 0.2s; } .btn-primary { background: #238636; color: white; } .btn-primary:hover { background: #2ea043; } .btn-primary:disabled { background: #21262d; color: #484f58; cursor: not-allowed; } .btn-secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; } .btn-secondary:hover { background: #30363d; } .status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #8b949e; margin-top: 12px; } .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #484f58; } .status-dot.connected { background: #238636; } .status-dot.loading { background: #f0883e; animation: pulse 1s infinite; } .status-dot.error { background: #f85149; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .error-box { background: #f8514922; border: 1px solid #f85149; color: #f85149; padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; font-size: 14px; } .event-meta { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; font-size: 13px; } .event-meta dt { color: #8b949e; font-weight: 500; } .event-meta dd { color: #c9d1d9; font-family: monospace; margin-bottom: 8px; word-break: break-all; } .raw-json { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 12px; color: #8b949e; white-space: pre-wrap; overflow-x: auto; max-height: 200px; overflow-y: auto; } /* Rendered UI styles */ .rendered-ui { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; } .ui-card { background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 20px; } .ui-heading { font-size: 22px; font-weight: 600; color: #e0e0e0; margin: 16px 0 8px 0; } .ui-heading:first-child { margin-top: 0; } .ui-text { color: #8b949e; margin: 8px 0; } .ui-image { max-width: 100%; border-radius: 8px; margin: 12px 0; } .ui-link { color: #58a6ff; text-decoration: none; } .ui-link:hover { text-decoration: underline; } .ui-container { margin: 12px 0; } .ui-container.options { display: flex; gap: 10px; flex-wrap: wrap; } .ui-button { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } .ui-button:hover { background: #2ea043; } .ui-button:disabled { background: #21262d; color: #484f58; cursor: wait; } .ui-input { background: #0d1117; border: 1px solid #30363d; color: #e0e0e0; padding: 10px 12px; border-radius: 6px; width: 100%; margin: 8px 0; } .ui-label { display: block; color: #8b949e; font-size: 13px; margin: 8px 0 4px 0; } .ui-hr { border: none; border-top: 1px solid #30363d; margin: 16px 0; } .ui-data { background: #0d1117; padding: 6px 10px; border-radius: 4px; font-family: monospace; font-size: 13px; color: #58a6ff; display: inline-block; margin: 4px 0; } .ui-data-label { color: #8b949e; margin-right: 6px; } .user-info { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; font-size: 13px; } .user-info .pubkey { font-family: monospace; color: #58a6ff; } .demo-specs { margin-top: 20px; padding-top: 20px; border-top: 1px solid #30363d; } .demo-specs h3 { font-size: 14px; color: #8b949e; margin-bottom: 10px; } .demo-specs .examples { display: flex; gap: 8px; flex-wrap: wrap; } </style> </head> <body> <div class="app" x-data="malleableApp()"> <header> <h1>Malleable UI</h1> <div class="subtitle">Client-side Nostr hypermedia - no server required</div> </header> <!-- User connection status --> <div class="user-info" x-show="userPubkey"> <span>Connected as: <span class="pubkey" x-text="userPubkey ? userPubkey.slice(0, 8) + '...' + userPubkey.slice(-4) : ''"></span></span> <button class="btn btn-secondary" @click="disconnect()">Disconnect</button> </div> <div class="user-info" x-show="!userPubkey"> <span>Not connected - actions require NIP-07 extension</span> <button class="btn btn-primary" @click="connect()">Connect</button> </div> <!-- Controls --> <div class="controls"> <label for="event-id">Event ID, nevent, naddr, or note (or paste a UI spec JSON)</label> <input type="text" id="event-id" x-model="eventInput" @keydown.enter="loadEvent()" placeholder="paste event id, nevent1..., or JSON UI spec" > <div class="actions"> <button class="btn btn-primary" @click="loadEvent()" :disabled="loading"> <span x-show="!loading">Load Event</span> <span x-show="loading">Loading...</span> </button> <button class="btn btn-secondary" @click="loadDemo()">Load Demo</button> <button class="btn btn-secondary" @click="clear()">Clear</button> </div> <div class="status"> <span class="status-dot" :class="{'connected': relayStatus === 'connected', 'loading': relayStatus === 'connecting', 'error': relayStatus === 'error'}"></span> <span x-text="statusMessage"></span> </div> <div class="demo-specs"> <h3>Try these demos:</h3> <div class="examples"> <button class="btn btn-secondary" @click="loadDemo('poll')">Poll</button> <button class="btn btn-secondary" @click="loadDemo('profile')">Profile Card</button> <button class="btn btn-secondary" @click="loadDemo('form')">Form</button> </div> </div> </div> <!-- Error display --> <div class="error-box" x-show="error" x-text="error"></div> <!-- Event metadata --> <div class="event-meta" x-show="event && !isDirectSpec"> <dl> <dt>Event ID</dt> <dd x-text="event?.id"></dd> <dt>Author</dt> <dd x-text="event?.pubkey"></dd> <dt>Kind</dt> <dd x-text="event?.kind"></dd> <dt>Created</dt> <dd x-text="event ? new Date(event.created_at * 1000).toLocaleString() : ''"></dd> </dl> </div> <!-- Rendered UI --> <div class="rendered-ui" x-show="uiSpec"> <div x-html="renderedHtml"></div> </div> <!-- Raw content (if not a UI spec) --> <div x-show="event && !uiSpec && !isDirectSpec"> <h3 style="color: #8b949e; font-size: 14px; margin-bottom: 10px;">Raw Content (not a UI spec)</h3> <div class="raw-json" x-text="event?.content"></div> </div> <!-- Raw spec display --> <details x-show="uiSpec" style="margin-top: 20px;"> <summary style="color: #8b949e; cursor: pointer; font-size: 13px;">View raw UI spec</summary> <div class="raw-json" style="margin-top: 10px;" x-text="JSON.stringify(uiSpec, null, 2)"></div> </details> </div> <script> // =========================================== // Vanilla JS: Relay connection & note fetching // =========================================== const DEFAULT_RELAYS = [ 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://relay.primal.net' ]; class RelayPool { constructor(relays = DEFAULT_RELAYS) { this.relays = relays; this.sockets = new Map(); this.subscriptions = new Map(); this.subCounter = 0; } async connect() { const promises = this.relays.map(url => this.connectRelay(url)); await Promise.allSettled(promises); return this.sockets.size > 0; } connectRelay(url) { return new Promise((resolve, reject) => { if (this.sockets.has(url)) { resolve(this.sockets.get(url)); return; } const ws = new WebSocket(url); const timeout = setTimeout(() => { ws.close(); reject(new Error(`Timeout connecting to ${url}`)); }, 5000); ws.onopen = () => { clearTimeout(timeout); this.sockets.set(url, ws); resolve(ws); }; ws.onerror = (err) => { clearTimeout(timeout); reject(err); }; ws.onclose = () => { this.sockets.delete(url); }; ws.onmessage = (msg) => { try { const data = JSON.parse(msg.data); this.handleMessage(url, data); } catch (e) { console.error('Failed to parse message:', e); } }; }); } handleMessage(relay, data) { const [type, subId, ...rest] = data; if (type === 'EVENT') { const event = rest[0]; const sub = this.subscriptions.get(subId); if (sub && sub.onEvent) { sub.onEvent(event, relay); } } else if (type === 'EOSE') { const sub = this.subscriptions.get(subId); if (sub) { sub.eoseCount = (sub.eoseCount || 0) + 1; if (sub.eoseCount >= this.sockets.size && sub.onEose) { sub.onEose(); } } } } subscribe(filter, { onEvent, onEose }) { const subId = `sub_${++this.subCounter}`; this.subscriptions.set(subId, { filter, onEvent, onEose, eoseCount: 0 }); const req = JSON.stringify(['REQ', subId, filter]); this.sockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { ws.send(req); } }); return subId; } unsubscribe(subId) { const close = JSON.stringify(['CLOSE', subId]); this.sockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { ws.send(close); } }); this.subscriptions.delete(subId); } async fetchEvent(filter, timeout = 5000) { return new Promise((resolve) => { let event = null; let timer; const subId = this.subscribe(filter, { onEvent: (e) => { if (!event || e.created_at > event.created_at) { event = e; } }, onEose: () => { clearTimeout(timer); this.unsubscribe(subId); resolve(event); } }); timer = setTimeout(() => { this.unsubscribe(subId); resolve(event); }, timeout); }); } async publish(event) { const msg = JSON.stringify(['EVENT', event]); const results = []; this.sockets.forEach((ws, url) => { if (ws.readyState === WebSocket.OPEN) { ws.send(msg); results.push(url); } }); return results; } close() { this.sockets.forEach(ws => ws.close()); this.sockets.clear(); this.subscriptions.clear(); } } // =========================================== // Vanilla JS: UI Spec Interpreter // =========================================== function parseUISpec(content) { if (typeof content !== 'string') return null; content = content.trim(); if (!content.startsWith('{')) return null; try { // Try wrapped format {"ui": {...}} const parsed = JSON.parse(content); if (parsed.ui && parsed.ui.elements) { return parsed.ui; } // Try direct format {elements: [...]} if (parsed.elements && Array.isArray(parsed.elements)) { return parsed; } } catch (e) { console.log('Not valid JSON:', e.message); } return null; } function renderUISpec(spec, ctx, actionHandler) { let html = ''; if (spec.style) { html += `<style>${escapeHtml(spec.style)}</style>`; } const layoutClass = spec.layout ? `ui-${spec.layout}` : 'ui-card'; html += `<div class="${layoutClass}">`; for (const elem of spec.elements || []) { html += renderElement(elem, ctx, spec.actions || [], actionHandler); } html += '</div>'; return html; } function renderElement(elem, ctx, actions, actionHandler) { const value = elem.bind ? resolveBind(elem.bind, ctx) : (elem.value || ''); const id = elem.id ? ` id="${escapeHtml(elem.id)}"` : ''; switch (elem.type) { case 'heading': case 'h1': case 'h2': case 'h3': return `<h2 class="ui-heading"${id}>${escapeHtml(value)}</h2>`; case 'text': case 'p': return `<p class="ui-text"${id}>${escapeHtml(value)}</p>`; case 'image': case 'img': const src = elem.src || value; return `<img class="ui-image"${id} src="${escapeHtml(src)}" alt="">`; case 'link': case 'a': const href = elem.href || value; const label = elem.label || value; return `<a class="ui-link"${id} href="${escapeHtml(href)}">${escapeHtml(label)}</a>`; case 'button': const btnLabel = elem.label || value; if (elem.action) { const action = actions.find(a => a.id === elem.action); if (action) { const actionId = `action_${Math.random().toString(36).slice(2, 8)}`; // Store action for later execution window._malleableActions = window._malleableActions || {}; window._malleableActions[actionId] = { action, ctx }; return `<button class="ui-button"${id} onclick="window.executeAction('${actionId}')">${escapeHtml(btnLabel)}</button>`; } } if (elem.href) { return `<a class="ui-button"${id} href="${escapeHtml(elem.href)}">${escapeHtml(btnLabel)}</a>`; } return `<button class="ui-button"${id}>${escapeHtml(btnLabel)}</button>`; case 'input': const inputLabel = elem.label; const name = elem.name || elem.id || ''; let inputHtml = ''; if (inputLabel) { inputHtml += `<label class="ui-label" for="${escapeHtml(name)}">${escapeHtml(inputLabel)}</label>`; } inputHtml += `<input class="ui-input"${id} name="${escapeHtml(name)}" type="text" value="${escapeHtml(value)}">`; return inputHtml; case 'container': case 'div': const containerClass = elem.style && !elem.style.includes(':') ? `ui-container ${elem.style}` : 'ui-container'; let containerHtml = `<div class="${containerClass}"${id}>`; for (const child of elem.children || []) { containerHtml += renderElement(child, ctx, actions, actionHandler); } containerHtml += '</div>'; return containerHtml; case 'hr': return '<hr class="ui-hr">'; case 'data': const dataLabel = elem.label; if (dataLabel) { return `<span class="ui-data"${id}><span class="ui-data-label">${escapeHtml(dataLabel)}</span>${escapeHtml(value)}</span>`; } return `<span class="ui-data"${id}>${escapeHtml(value)}</span>`; default: return ''; } } function resolveBind(bind, ctx) { if (!ctx) return ''; const path = bind.replace(/^\$\.?/, '').toLowerCase(); switch (path) { case 'id': return ctx.id || ''; case 'pubkey': return ctx.pubkey || ''; case 'npub': return ctx.npub || ctx.pubkey || ''; case 'content': return ctx.content || ''; case 'time': case 'createdat': case 'created_at': return ctx.created_at ? new Date(ctx.created_at * 1000).toLocaleString() : ''; case 'kind': return String(ctx.kind || ''); default: return ''; } } function resolveTemplate(tmpl, ctx) { return tmpl.replace(/\{\{\s*\$\.?(\w+)\s*\}\}/g, (match, path) => { return resolveBind(path, ctx); }); } function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } // =========================================== // Bech32 decoding for nevent/note/naddr // =========================================== const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; function bech32Decode(str) { str = str.toLowerCase(); const pos = str.lastIndexOf('1'); if (pos < 1 || pos + 7 > str.length) throw new Error('Invalid bech32'); const hrp = str.slice(0, pos); const data = []; for (let i = pos + 1; i < str.length; i++) { const idx = BECH32_CHARSET.indexOf(str[i]); if (idx === -1) throw new Error('Invalid character'); data.push(idx); } // Remove checksum (last 6 chars) const payload = data.slice(0, -6); // Convert 5-bit to 8-bit let acc = 0, bits = 0; const bytes = []; for (const val of payload) { acc = (acc << 5) | val; bits += 5; while (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 0xff); } } return { hrp, bytes: new Uint8Array(bytes) }; } function parseNostrId(input) { input = input.trim(); // Already hex? if (/^[0-9a-f]{64}$/i.test(input)) { return { type: 'hex', id: input.toLowerCase() }; } // note1... (just the event id) if (input.startsWith('note1')) { const { bytes } = bech32Decode(input); const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); return { type: 'note', id: hex }; } // nevent1... (TLV encoded) if (input.startsWith('nevent1')) { const { bytes } = bech32Decode(input); return parseTLV(bytes, 'nevent'); } // naddr1... (TLV encoded) if (input.startsWith('naddr1')) { const { bytes } = bech32Decode(input); return parseTLV(bytes, 'naddr'); } return { type: 'unknown', raw: input }; } function parseTLV(bytes, type) { const result = { type }; let i = 0; while (i < bytes.length) { const t = bytes[i++]; const l = bytes[i++]; const v = bytes.slice(i, i + l); i += l; if (t === 0) { // special (event id for nevent, identifier for naddr) result.id = Array.from(v).map(b => b.toString(16).padStart(2, '0')).join(''); } else if (t === 1) { // relay result.relay = new TextDecoder().decode(v); } else if (t === 2) { // author result.author = Array.from(v).map(b => b.toString(16).padStart(2, '0')).join(''); } else if (t === 3) { // kind result.kind = v.reduce((acc, b) => acc * 256 + b, 0); } } return result; } // =========================================== // Demo UI Specs // =========================================== const DEMO_SPECS = { poll: { layout: 'card', title: 'Community Poll', elements: [ { type: 'heading', value: "What's the best approach to malleable UI?" }, { type: 'text', value: 'This entire UI is defined in JSON. The browser interprets it and renders HTML - no server required!' }, { type: 'hr' }, { type: 'text', value: 'Vote for your preferred approach:' }, { type: 'container', style: 'options', children: [ { type: 'button', label: 'Server-rendered', action: 'vote-server' }, { type: 'button', label: 'Client JS', action: 'vote-client' }, { type: 'button', label: 'Hybrid', action: 'vote-hybrid' } ]}, { type: 'hr' }, { type: 'heading', value: 'Event Data Bindings' }, { type: 'text', value: 'UI specs can bind to event data:' }, { type: 'data', bind: '$.id', label: 'Event ID: ' }, { type: 'data', bind: '$.pubkey', label: 'Author: ' }, { type: 'data', bind: '$.time', label: 'Created: ' } ], actions: [ { id: 'vote-server', publish: { kind: 7, content: 'server-rendered', tags: [['e', '{{$.id}}']] }}, { id: 'vote-client', publish: { kind: 7, content: 'client-js', tags: [['e', '{{$.id}}']] }}, { id: 'vote-hybrid', publish: { kind: 7, content: 'hybrid', tags: [['e', '{{$.id}}']] }} ] }, profile: { layout: 'card', title: 'Profile Card', elements: [ { type: 'heading', value: 'User Profile' }, { type: 'data', bind: '$.pubkey', label: 'Pubkey: ' }, { type: 'data', bind: '$.time', label: 'Last seen: ' }, { type: 'hr' }, { type: 'container', style: 'options', children: [ { type: 'button', label: 'Follow', action: 'follow' }, { type: 'button', label: 'Message', action: 'message' } ]} ], actions: [ { id: 'follow', publish: { kind: 3, content: '', tags: [['p', '{{$.pubkey}}']] }}, { id: 'message', link: '/html/messages/{{$.pubkey}}' } ] }, form: { layout: 'card', title: 'Feedback Form', elements: [ { type: 'heading', value: 'Send Feedback' }, { type: 'text', value: 'Your feedback helps improve this project.' }, { type: 'input', name: 'feedback', label: 'Your message:', id: 'feedback-input' }, { type: 'hr' }, { type: 'button', label: 'Submit Feedback', action: 'submit' } ], actions: [ { id: 'submit', publish: { kind: 1, content: 'Feedback: {{input:feedback}}', tags: [['t', 'feedback']] }} ] } }; // =========================================== // Alpine.js App // =========================================== function malleableApp() { return { // State eventInput: '', event: null, uiSpec: null, renderedHtml: '', error: null, loading: false, relayStatus: 'disconnected', statusMessage: 'Not connected', userPubkey: null, isDirectSpec: false, // Relay pool pool: null, async init() { this.pool = new RelayPool(); // Check for NIP-07 if (window.nostr) { try { this.userPubkey = await window.nostr.getPublicKey(); } catch (e) { console.log('NIP-07 available but not connected'); } } // Connect to relays this.relayStatus = 'connecting'; this.statusMessage = 'Connecting to relays...'; try { await this.pool.connect(); this.relayStatus = 'connected'; this.statusMessage = `Connected to ${this.pool.sockets.size} relays`; } catch (e) { this.relayStatus = 'error'; this.statusMessage = 'Failed to connect to relays'; } // Set up action executor window.executeAction = async (actionId) => { const { action, ctx } = window._malleableActions[actionId] || {}; if (!action) return; await this.executeAction(action, ctx); }; }, async connect() { if (!window.nostr) { this.error = 'No NIP-07 extension found. Install Alby, nos2x, or similar.'; return; } try { this.userPubkey = await window.nostr.getPublicKey(); } catch (e) { this.error = 'Failed to connect: ' + e.message; } }, disconnect() { this.userPubkey = null; }, async loadEvent() { this.error = null; this.event = null; this.uiSpec = null; this.renderedHtml = ''; this.isDirectSpec = false; const input = this.eventInput.trim(); if (!input) { this.error = 'Please enter an event ID or UI spec'; return; } // Check if it's direct JSON if (input.startsWith('{')) { const spec = parseUISpec(input); if (spec) { this.isDirectSpec = true; this.uiSpec = spec; const ctx = { id: 'direct-spec', pubkey: this.userPubkey || 'not-connected', created_at: Math.floor(Date.now() / 1000), kind: 0, content: input }; this.renderedHtml = renderUISpec(spec, ctx); return; } else { this.error = 'Invalid UI spec JSON'; return; } } // Parse as Nostr identifier this.loading = true; try { const parsed = parseNostrId(input); let filter = {}; if (parsed.type === 'hex' || parsed.type === 'note' || parsed.type === 'nevent') { filter = { ids: [parsed.id], limit: 1 }; } else if (parsed.type === 'naddr') { filter = { kinds: [parsed.kind], authors: [parsed.author], '#d': [parsed.id], limit: 1 }; } else { this.error = 'Unrecognized identifier format'; this.loading = false; return; } const event = await this.pool.fetchEvent(filter); if (!event) { this.error = 'Event not found'; this.loading = false; return; } this.event = event; this.uiSpec = parseUISpec(event.content); if (this.uiSpec) { const ctx = { id: event.id, pubkey: event.pubkey, created_at: event.created_at, kind: event.kind, content: event.content }; this.renderedHtml = renderUISpec(this.uiSpec, ctx); } } catch (e) { this.error = 'Failed to load event: ' + e.message; } this.loading = false; }, loadDemo(name = 'poll') { this.error = null; this.event = null; this.isDirectSpec = true; const spec = DEMO_SPECS[name] || DEMO_SPECS.poll; this.uiSpec = spec; const ctx = { id: 'demo-' + name, pubkey: this.userPubkey || 'demo-pubkey', created_at: Math.floor(Date.now() / 1000), kind: 1, content: JSON.stringify(spec) }; this.renderedHtml = renderUISpec(spec, ctx); this.eventInput = JSON.stringify(spec, null, 2); }, clear() { this.eventInput = ''; this.event = null; this.uiSpec = null; this.renderedHtml = ''; this.error = null; this.isDirectSpec = false; }, async executeAction(action, ctx) { if (action.link) { const url = resolveTemplate(action.link, ctx); window.location.href = url; return; } if (action.publish) { if (!window.nostr) { this.error = 'NIP-07 extension required to publish events'; return; } if (!this.userPubkey) { this.error = 'Please connect your NIP-07 extension first'; return; } try { // Resolve template in content let content = resolveTemplate(action.publish.content, ctx); // Check for input bindings like {{input:fieldname}} content = content.replace(/\{\{input:(\w+)\}\}/g, (match, fieldName) => { const input = document.querySelector(`[name="${fieldName}"]`); return input ? input.value : ''; }); // Resolve template in tags const tags = (action.publish.tags || []).map(tag => tag.map(v => resolveTemplate(v, ctx)) ); const event = { kind: action.publish.kind, content: content, tags: tags, created_at: Math.floor(Date.now() / 1000) }; // Sign with NIP-07 const signedEvent = await window.nostr.signEvent(event); // Publish to relays const relays = await this.pool.publish(signedEvent); this.error = null; alert(`Published to ${relays.length} relays!\n\nEvent ID: ${signedEvent.id}`); } catch (e) { this.error = 'Failed to publish: ' + e.message; } } } }; } </script> </body> </html>
2025-12-03 04:43:55 from 1 relay(s) View Thread β†’
Tested on live relays (in this thread) and merged! What a legend! https://github.com/vcavallo/nostr-hypermedia/pull/1 nostr:nevent1qvzqqqqqqypzpk4yr0kmdpv3xcalgsrldp7tj7yuc4p76qjtka7z95kgfky02s2nqy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcqypsvmwu72nthyjp9lxgzvl9f5q2xw35skcsgvyvdp90k9sulcqfzzkt33ca
2025-12-02 23:25:35 from 1 relay(s) View Thread β†’
The procedure through which anarcho-[insert socialist flavor] dismisses anarchocapitalism as "not true anarchism" typically looks like this: Instead of using the literal, commonly accepted definition of the word "anarchy" ("Without rulers") they **selectively and cynically redefine it to mean "Without _unjustified hierarchies_"**. Disregarding for a moment how "unjustified" is a completely subjective moral interpretation they now proceed to define voluntary trade (ie. my time for your resources) as "unjustified". [most socialists/communists wouldn't balk at barter, but bring money or free markets into it and suddenly they see it as some form of exploitation] Thus, since capitalism is based on voluntary trade, the subjective moral interpretation of the redefined word "anarchy" now "proves" free market capitalism is incompatible with anarchism. The fact that so much mental gymnastics is required to reach their conclusion makes me suspect a great deal of psychological projection is going on in the socialist camp... I’m guessing at some point every type of communist must be confronted with the difficult reality that every attempt at communism in history has been exactly identical to totalitarian dictatorship. Contrary to the claims of word-bending language artists, anarchocapitalism is likely the only true form of voluntarist anarchy, uniquely separated from other flavors of anarchy by having **no dependency on a central power to enforce it**. Anarchocapitalism needs no master plan - it’s simply a consequence of not initiating aggression against other people. Rather than being a "received/enforced doctrine", it's just what happens when all human interactions are voluntary. When all human interactions are voluntary you have a society without rulers. That’s the actual definition of anarchy. #anarchocapitalism #ancap #socialism #capitalism
2025-12-02 22:07:52 from 1 relay(s) View Thread β†’