Hello world
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
You mean crazy shit like this?
```
{"type": "eval", "expr": "new Date().getMinutes()", "as": "minute", "display": false},
{"type": "data", "bind": "$.minute", "label": "Current minute: "},
{"type": "hr"},
{
"type": "if",
"condition": "$.minute % 2 == 1",
"then": [
{"type": "text", "value": "Minute is ODD - querying notes..."},
{"type": "query", "filter": {"kinds": [1], "limit": 3}, "as": "notes", "children": [
{"type": "foreach", "items": "$.notes", "as": "note", "template": {
"type": "container", "children": [
{"type": "text", "bind": "$.note.content"}
]
}}
]}
],
"else": [
{"type": "text", "value": "Minute is EVEN - showing different content"},
{"type": "text", "value": "Refresh on an odd minute to see notes!"}
]
}
```
View quoted note →
i'm going to have to record a video of this in progress..
- write request
- wait a bit
- bot responds with a note whose content is the ui
- ui is immediately rendered and usable from your connected npub
View quoted note →
View quoted note →Create a feedback form that lets people explain how they feel about this HATE-bot (Hypertext as the Engine) approach
now the bot is in the client :)
a client that re-builds itself live.
View quoted note →
View quoted note →Create a poll asking what people's favorite pizza topping is with options for pepperoni, mushrooms, pineapple, and plain cheese
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)
View quoted note →

GitHub
nostr-hypermedia/hateoas-js-readme.md at hatebot · vcavallo/nostr-hypermedia
HTTP-only Nostr client aggregator with REST and hypermedia support. - vcavallo/nostr-hypermedia
@npub1h00x...62ut
once again i request that you build me a poll where users can vote for their favorite ice cream flavor
Getting somewhere...
View quoted note →
View quoted note →
@npub1h00x...62ut
make me poll where people can select their favorite ice cream flavor
hey @npub1h00x...62ut
make me poll where people can select their favorite ice cream flavor
hello @npub1h00x...62ut
has anyone built a nostr ai bot that pays for its requests with lightning ( @PayPerQ 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?
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
View quoted note →
below this quote is a nostr client. save it as html and open it in your browser. see
for more information
View quoted note →
<!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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ===========================================
// 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>
GitHub
nostr-hypermedia/hateoas-js-readme.md at hateoas-js · vcavallo/nostr-hypermedia
HTTP-only Nostr client aggregator with REST and hypermedia support. - vcavallo/nostr-hypermedia
Feedback: hi