Add VoxVera Nostr static client

This commit is contained in:
thePR0M3TH3AN committed 2026-06-04 20:15:50 -04:00
1 parent 285d730369
commit d415eb26a3
14 files changed
+12651 -11

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VoxVera Nostr Client</title>
<link rel="stylesheet" href="nostr-client.css">
</head>
<body>
<header class="topbar">
<div>
<h1>VoxVera Nostr Client</h1>
<p>Static editor and viewer for decentralized flyer source events.</p>
</div>
<nav class="mode-tabs" aria-label="Client mode">
<button class="tab active" type="button" data-mode="editor">Editor</button>
<button class="tab" type="button" data-mode="viewer">Viewer</button>
</nav>
</header>
<div class="viewer-floating" aria-label="Viewer controls">
<button type="button" id="open-viewer-controls">Load Event</button>
<button type="button" id="viewer-print">Print</button>
<button type="button" id="viewer-editor">Editor</button>
</div>
<main>
<section id="editor-panel" class="panel active" aria-labelledby="editor-heading">
<div class="workspace">
<form id="editor-form" class="editor-form">
<div class="section-title">
<h2 id="editor-heading">Editor</h2>
<span id="signer-state" class="state-pill">Checking signer</span>
</div>
<label>
Relays
<textarea id="editor-relays" rows="3" spellcheck="false"></textarea>
</label>
<div class="identity-row">
<span id="identity-state" class="state-pill">Checking identity</span>
<button type="button" id="generate-anon-identity" class="secondary-button">Generate anonymous npub</button>
</div>
<div class="field-grid">
<label>
Flyer Name
<input id="field-folder-name" name="folder_name" maxlength="64" value="voxvera">
</label>
<label>
Language
<select id="field-lang" name="lang">
<option value="en">en</option>
<option value="es">es</option>
<option value="de">de</option>
<option value="fr">fr</option>
<option value="ru">ru</option>
<option value="he">he</option>
<option value="ar">ar</option>
<option value="ja">ja</option>
<option value="hi">hi</option>
<option value="sw">sw</option>
<option value="pt">pt</option>
<option value="tr">tr</option>
<option value="zh">zh</option>
<option value="fa">fa</option>
</select>
</label>
</div>
<label>
Page title
<input id="field-name" name="name" maxlength="120" value="Vox Vera Printable Flyers">
</label>
<label>
Title
<input id="field-title" name="title" maxlength="80" value="TOP SECRET">
</label>
<label>
Subtitle
<input id="field-subtitle" name="subtitle" maxlength="120" value="DO ~~NOT~~ DISTRIBUTE">
</label>
<label>
Headline
<input id="field-headline" name="headline" maxlength="160" value="OPERATION VOX VERA">
</label>
<label>
Content
<textarea id="field-content" name="content" rows="9" maxlength="10000"></textarea>
</label>
<label>
URL message
<input id="field-url-message" name="url_message" maxlength="240" value="Follow this link to learn more.">
</label>
<label>
Poster URL
<input id="field-url" name="url" maxlength="2048" value="https://voxvera.org/" readonly>
</label>
<label>
Footer message
<input id="field-footer-message" name="footer_message" maxlength="240" value="0110010 0101011 0110010 0111101 0110100">
</label>
<div class="actions">
<button type="button" id="preview-editor">Preview</button>
<button type="button" id="export-event">Export event JSON</button>
<button type="submit" id="publish-event">Sign and publish</button>
</div>
</form>
<aside class="result-panel">
<h2>Published Event</h2>
<dl class="result-list">
<dt>Event ID</dt>
<dd class="copy-value"><code id="event-id-output">Not published</code><button type="button" class="copy-button" data-copy-target="event-id-output">Copy</button></dd>
<dt>Author npub</dt>
<dd class="copy-value"><code id="author-npub-output">Not generated</code><button type="button" class="copy-button" data-copy-target="author-npub-output">Copy</button></dd>
<dt>Nostr address</dt>
<dd class="copy-value"><code id="naddr-output">Not generated</code><button type="button" class="copy-button" data-copy-target="naddr-output">Copy</button></dd>
<dt>Poster URL</dt>
<dd class="copy-value"><code id="poster-url-output">Not generated</code><button type="button" class="copy-button" data-copy-target="poster-url-output">Copy</button></dd>
<dt>Status</dt>
<dd id="publish-status">Idle</dd>
</dl>
<textarea id="event-json-output" rows="14" spellcheck="false" readonly></textarea>
</aside>
</div>
</section>
<section id="viewer-panel" class="panel viewer-drawer" aria-labelledby="viewer-heading">
<div class="workspace">
<form id="viewer-form" class="editor-form">
<div class="section-title">
<h2 id="viewer-heading">Viewer</h2>
<button type="button" id="close-viewer-controls" class="secondary-button">Close</button>
</div>
<label>
Event ID
<input id="viewer-event-id" spellcheck="false" placeholder="naddr, event id, note1, nevent1, or nostr: URL">
</label>
<label>
Relays
<textarea id="viewer-relays" rows="3" spellcheck="false"></textarea>
</label>
<div class="actions">
<button type="submit">Fetch and render</button>
<button type="button" id="copy-viewer-config">Copy config JSON</button>
</div>
<p id="viewer-status" class="status-line">Idle</p>
</form>
<aside class="result-panel">
<h2>Normalized Config</h2>
<textarea id="viewer-config-output" rows="16" spellcheck="false" readonly></textarea>
</aside>
</div>
</section>
<section class="preview-band" aria-label="Flyer preview">
<div class="preview-toolbar">
<h2>Preview</h2>
<button type="button" id="print-preview">Print preview</button>
</div>
<div id="flyer-preview" class="flyer-preview"></div>
</section>
</main>
<script src="vendor/qrcode-generator/qrcode.js"></script>
<script src="vendor/nostr-tools/nostr.bundle.js"></script>
<script src="nostr-client.js"></script>
</body>
</html>
+702
View File
@@ -0,0 +1,702 @@
:root {
color-scheme: light;
--ink: #111;
--muted: #5b5b5b;
--line: #c9c9c9;
--soft: #f3f3f1;
--accent: #b80000;
--ok: #1d6b3a;
--warn: #8a4d00;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
background: #fff;
font-family: "Courier New", Courier, monospace;
line-height: 1.45;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
min-height: 40px;
padding: 8px 14px;
border: 1px solid #000;
background: #000;
color: #fff;
cursor: pointer;
}
button:hover {
background: #333;
}
.secondary-button {
background: #fff;
color: #000;
}
.secondary-button:hover {
background: #eee;
}
.topbar {
display: flex;
gap: 24px;
justify-content: space-between;
align-items: flex-end;
padding: 20px clamp(16px, 4vw, 42px);
border-bottom: 2px solid #000;
}
.topbar h1,
.topbar p,
.section-title h2,
.preview-toolbar h2 {
margin: 0;
}
.topbar h1 {
font-size: 24px;
}
.topbar p {
color: var(--muted);
}
.mode-tabs {
display: inline-flex;
border: 1px solid #000;
}
.tab {
border: 0;
border-right: 1px solid #000;
background: #fff;
color: #000;
}
.tab:last-child {
border-right: 0;
}
.tab.active {
background: #000;
color: #fff;
}
main {
padding: 18px clamp(16px, 4vw, 42px) 36px;
}
.panel {
display: none;
}
.panel.active {
display: block;
}
.viewer-floating {
display: none;
}
.workspace {
display: grid;
grid-template-columns: minmax(320px, 1.15fr) minmax(280px, 0.85fr);
gap: 24px;
align-items: start;
}
.editor-form,
.result-panel {
min-width: 0;
}
.section-title,
.preview-toolbar {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.state-pill {
display: inline-block;
padding: 4px 8px;
border: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
}
.identity-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin: 0 0 12px;
}
.identity-row .secondary-button {
min-height: 32px;
padding: 5px 10px;
font-size: 12px;
}
.editor-form label {
display: grid;
gap: 5px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 700;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #000;
border-radius: 0;
background: #fff;
color: #000;
padding: 8px;
}
textarea {
resize: vertical;
}
.field-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 12px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.result-list {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
gap: 8px 12px;
margin: 0 0 12px;
}
.result-list dt {
font-weight: 700;
}
.result-list dd {
margin: 0;
min-width: 0;
}
.copy-value {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.copy-button {
min-height: 28px;
padding: 4px 8px;
font-size: 11px;
}
.relay-results {
display: grid;
gap: 4px;
}
.relay-ok {
color: var(--ok);
font-weight: 700;
}
.relay-error {
color: var(--warn);
}
code {
display: block;
overflow-wrap: anywhere;
white-space: normal;
}
.status-line {
min-height: 22px;
color: var(--muted);
}
.preview-band {
margin-top: 26px;
border-top: 2px solid #000;
padding-top: 18px;
}
.flyer-preview {
overflow-x: auto;
padding-bottom: 8px;
}
.flyer-sheet {
display: flex;
width: 8.5in;
height: 11in;
border: 1px solid #000;
background: #fff;
color: #000;
overflow: hidden;
}
.flyer-sheet.no-tear-offs .tear-offs {
display: none;
}
.flyer-sheet.no-tear-offs .flyer-content {
width: 100%;
}
.tear-offs {
width: 3.75in;
padding: 8px;
border-right: 1px dashed #000;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 5px;
}
.tear-off {
display: flex;
gap: 12px;
height: 1in;
padding: 5px;
border: 1px dashed #000;
font-size: 10px;
overflow: hidden;
}
.qr-box {
flex: 0 0 0.72in;
width: 0.72in;
height: 0.72in;
border: 1px solid #000;
display: grid;
place-items: center;
font-size: 9px;
text-align: center;
}
.flyer-content {
width: 4.75in;
padding: 0.28in;
display: flex;
flex-direction: column;
justify-content: flex-start;
text-align: center;
}
.flyer-content h1 {
margin: 0 0 0.18in;
font-size: 34px;
line-height: 1.05;
}
.flyer-content .subtitle {
margin-bottom: 0.16in;
color: var(--accent);
font-weight: 700;
font-size: 21px;
}
.message {
margin-top: 0.12in;
text-align: left;
font-size: 15px;
white-space: pre-wrap;
}
.main-qr {
align-self: center;
margin-top: 0.18in;
width: 1.25in;
height: 1.25in;
}
.footer {
margin-top: auto;
font-size: 11px;
overflow-wrap: anywhere;
}
a {
color: var(--accent);
}
.container {
display: flex;
flex-direction: row;
width: 8.5in;
height: 11in;
border: 1px solid #000;
background: #fff;
color: #000;
box-sizing: border-box;
overflow: hidden;
page-break-inside: avoid;
}
.container.no-tear-offs .left-tear-offs {
display: none;
}
.container.no-tear-offs .content {
width: 100%;
}
.left-tear-offs {
width: 3.75in;
padding: 8px;
border-right: 1px dashed #000;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 5px;
box-sizing: border-box;
}
.tear-off-text {
flex: 1;
min-width: 0;
padding-top: 1px;
overflow-wrap: anywhere;
word-break: normal;
hyphens: none;
}
.qr-code {
width: 80px;
height: 80px;
flex-shrink: 0;
}
.qr-code svg {
display: block;
width: 100%;
height: 100%;
}
.qr-error {
display: grid;
width: 100%;
height: 100%;
place-items: center;
border: 1px solid #000;
font-size: 9px;
text-align: center;
}
.qr-placeholder {
width: 80px;
height: 80px;
flex-shrink: 0;
background:
linear-gradient(90deg, #000 8px, transparent 8px 16px, #000 16px 24px, transparent 24px 32px),
linear-gradient(#000 8px, transparent 8px 16px, #000 16px 24px, transparent 24px 32px);
background-color: #fff;
background-size: 32px 32px;
border: 8px solid #000;
}
.content {
width: calc(8.5in - 3.75in);
padding: 15px 20px;
text-align: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
page-break-inside: avoid;
overflow-wrap: break-word;
}
.content h1 {
margin: 0;
padding: 0;
font-size: 24px;
line-height: 1.2;
letter-spacing: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.distribute {
font-size: 18px;
line-height: 1.2;
letter-spacing: 6px;
margin: 2px 0;
padding: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.container .message {
flex: 1;
margin-top: 4px;
font-size: 14px;
line-height: 1.4;
text-align: left;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
white-space: pre-wrap;
box-sizing: border-box;
position: relative;
}
.qr-code-body {
width: 100%;
height: auto;
display: flex;
justify-content: flex-start;
align-items: center;
gap: 10px;
margin-top: 10px;
padding: 0;
box-sizing: border-box;
}
.qr-code-body .main-qr {
width: 80px;
height: 80px;
}
.qr-code-url {
font-size: 12px;
text-align: left;
overflow-wrap: anywhere;
word-break: normal;
hyphens: none;
flex: 1 1 auto;
min-width: 0;
padding-left: 4px;
}
hr {
border: none;
border-top: 1px solid #000;
width: 100%;
margin: 4px 0;
}
.container .footer {
font-size: 10px;
overflow-wrap: break-word;
text-align: center;
align-self: center;
width: 100%;
padding: 10px 0;
box-sizing: border-box;
border-top: 1px solid #000;
}
.container .footer p {
margin: 0;
padding: 0;
}
.container .footer .binary {
margin-bottom: 5px;
margin-top: 20px;
}
.container .footer .credit {
font-size: 12px;
margin-top: 5px;
}
body.mode-viewer {
background: #fff;
}
body.mode-viewer .topbar,
body.mode-viewer #editor-panel,
body.mode-viewer .preview-toolbar {
display: none;
}
body.mode-viewer main {
padding: 0;
}
body.mode-viewer .preview-band {
margin: 0;
padding: 0;
border: 0;
}
body.mode-viewer .flyer-preview {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px 0;
overflow: auto;
}
body.mode-viewer .viewer-floating {
position: fixed;
top: 12px;
right: 12px;
z-index: 20;
display: flex;
gap: 8px;
}
body.mode-viewer .viewer-floating button {
min-height: 34px;
padding: 6px 10px;
font-size: 12px;
}
body.mode-viewer #viewer-panel {
display: none;
}
body.mode-viewer #viewer-panel.drawer-open {
position: fixed;
top: 56px;
right: 12px;
bottom: 12px;
z-index: 19;
display: block;
width: min(680px, calc(100vw - 24px));
overflow: auto;
padding: 16px;
border: 2px solid #000;
background: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
body.mode-viewer #viewer-panel.drawer-open .workspace {
display: block;
}
body.mode-viewer #viewer-panel.drawer-open .result-panel {
margin-top: 18px;
}
@media (max-width: 900px) {
.topbar,
.workspace,
.field-grid {
display: block;
}
.mode-tabs {
margin-top: 14px;
}
.result-panel {
margin-top: 22px;
}
.flyer-sheet,
.container {
transform: scale(0.58);
transform-origin: top left;
margin-bottom: -4.5in;
}
body.mode-viewer .flyer-preview {
justify-content: flex-start;
min-height: 0;
}
}
@media print {
@page {
size: Letter portrait;
margin: 0;
}
html,
body {
width: 8.5in;
height: 11in;
margin: 0;
padding: 0;
overflow: hidden;
}
body > header,
body > .viewer-floating,
.viewer-floating,
#open-viewer-controls,
#viewer-print,
#viewer-editor,
body > main > .panel,
.preview-toolbar,
.viewer-drawer {
display: none !important;
}
main {
display: block;
width: 8.5in;
height: 11in;
padding: 0;
margin: 0;
}
.preview-band {
display: block;
width: 8.5in;
height: 11in;
border: 0;
margin: 0;
padding: 0;
}
.flyer-preview {
display: block;
width: 8.5in;
height: 11in;
overflow: visible;
padding: 0;
margin: 0;
}
.flyer-sheet,
.container {
position: absolute;
inset: 0 auto auto 0;
width: 8.5in;
height: 11in;
transform: none;
margin: 0;
border: 0;
}
}
+748
View File
@@ -0,0 +1,748 @@
(function () {
"use strict";
const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net"
];
const ANON_SECRET_STORAGE_KEY = "voxvera_nostr_anon_secret_hex";
const EVENT_KIND = 30078;
const FIELD_LIMITS = {
folder_name: 64,
name: 120,
title: 80,
subtitle: 120,
headline: 160,
content: 10000,
url_message: 240,
url: 2048,
footer_message: 240
};
const CONFIG_DEFAULTS = {
name: "Vox Vera Printable Flyers",
folder_name: "voxvera",
lang: "en",
title: "TOP SECRET",
subtitle: "DO ~~NOT~~ DISTRIBUTE",
headline: "OPERATION VOX VERA",
content: `Break free from censorship with VoxVera. An anonymous guerrilla marketing and message-spreading tool. Whether online or in the physical world, it can empower you to spread your ideas boldly, shielded by complete anonymity (if you host over Tor). Download the code, design a flyer site, host it online and amplify your message.
Use memetic power to share your ideas in your school, workplace, online communities, or even globally. VoxVera ensures your message resonates far and wide, with tear-off sections featuring unique URLs and QR codes for easy reprinting.
Privacy can be maintained. Flyers can be shared via the Tor network, protecting hoster and users from censorship.
Join us in a revolution that values truth and transparency. Together, we can build a network of informed citizens who are unafraid to speak out.`,
url_message: "Follow this link to learn more.",
url: "https://voxvera.org/",
tear_off_link: "",
footer_message: "0110010 0101011 0110010 0111101 0110100",
attachment_path: "",
attachment_filename: ""
};
const el = (id) => document.getElementById(id);
function setDefaultText() {
const relayText = DEFAULT_RELAYS.join("\n");
el("editor-relays").value = relayText;
el("viewer-relays").value = relayText;
el("field-content").value = CONFIG_DEFAULTS.content;
renderPreview(CONFIG_DEFAULTS);
refreshSignerState();
refreshIdentityState();
}
function refreshSignerState() {
const state = el("signer-state");
if (window.nostr && typeof window.nostr.signEvent === "function") {
state.textContent = "Anonymous signing is default; NIP-07 detected";
state.style.color = "#1d6b3a";
return;
}
state.textContent = "Anonymous signing available; no NIP-07 login required";
state.style.color = "#1d6b3a";
}
function nostrTools() {
if (!window.NostrTools) {
throw new Error("Nostr tools bundle did not load.");
}
return window.NostrTools;
}
function bytesToHex(bytes) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function hexToBytes(hex) {
if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Invalid anonymous secret key.");
const bytes = new Uint8Array(32);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = parseInt(hex.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
function getStoredAnonSecret() {
try {
const stored = window.localStorage.getItem(ANON_SECRET_STORAGE_KEY);
return stored && /^[a-f0-9]{64}$/i.test(stored) ? stored : "";
} catch (_) {
return "";
}
}
function storeAnonSecret(secretHex) {
try {
window.localStorage.setItem(ANON_SECRET_STORAGE_KEY, secretHex);
} catch (_) {}
}
function getOrCreateAnonIdentity(forceNew = false) {
const tools = nostrTools();
let secretHex = forceNew ? "" : getStoredAnonSecret();
if (!secretHex) {
secretHex = bytesToHex(tools.generateSecretKey());
storeAnonSecret(secretHex);
}
const secretKey = hexToBytes(secretHex);
const pubkey = tools.getPublicKey(secretKey);
const npub = tools.nip19.npubEncode(pubkey);
const nsec = tools.nip19.nsecEncode(secretKey);
return { secretKey, secretHex, pubkey, npub, nsec };
}
function refreshIdentityState() {
const state = el("identity-state");
const output = el("author-npub-output");
if (!state || !output) return;
try {
const secretHex = getStoredAnonSecret();
if (!secretHex) {
state.textContent = "No anonymous npub yet";
state.style.color = "#8a4d00";
output.textContent = "Not generated";
return;
}
const identity = getOrCreateAnonIdentity(false);
state.textContent = "Anonymous npub ready";
state.style.color = "#1d6b3a";
output.textContent = identity.npub;
updatePosterAddressOutputs();
} catch (error) {
state.textContent = error.message;
state.style.color = "#8a4d00";
}
}
function parseRelays(value) {
return value
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean)
.filter((item, index, list) => item.startsWith("wss://") && list.indexOf(item) === index);
}
function slugify(value) {
const slug = String(value || "voxvera")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, FIELD_LIMITS.folder_name);
return slug || "voxvera";
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function redactionToHtml(value) {
return escapeHtml(value).replace(/~~(.*?)~~/g, '<span style="text-decoration: line-through;">$1</span>');
}
function buildPayloadFromForm() {
const payload = {
type: "voxvera_flyer",
version: 1,
folder_name: slugify(el("field-folder-name").value),
lang: el("field-lang").value || "en",
name: el("field-name").value.trim(),
title: el("field-title").value.trim(),
subtitle: el("field-subtitle").value.trim(),
headline: el("field-headline").value.trim(),
content: el("field-content").value.trim(),
url_message: el("field-url-message").value.trim(),
url: el("field-url").value.trim(),
footer_message: el("field-footer-message").value.trim(),
attachment_path: "",
attachment_filename: "",
qr_target: "flyer_url"
};
validatePayload(payload);
return payload;
}
function validatePayload(payload) {
if (payload.type !== "voxvera_flyer") throw new Error("Payload type must be voxvera_flyer.");
if (payload.version !== 1) throw new Error("Payload version must be 1.");
Object.entries(FIELD_LIMITS).forEach(([key, limit]) => {
if (payload[key] && payload[key].length > limit) {
throw new Error(`${key} exceeds ${limit} characters.`);
}
});
Object.entries(payload).forEach(([key, value]) => {
if (typeof value === "string" && key !== "url" && /<\s*\/?\s*[a-z][^>]*>|on[a-z]+\s*=/i.test(value)) {
throw new Error(`${key} contains raw HTML.`);
}
});
if (payload.url) {
const parsed = new URL(payload.url);
if (!["http:", "https:", "nostr:"].includes(parsed.protocol)) {
throw new Error(`Unsupported URL scheme: ${parsed.protocol}`);
}
}
}
function normalizePayload(payload) {
validatePayload(payload);
return {
...CONFIG_DEFAULTS,
name: payload.name || CONFIG_DEFAULTS.name,
folder_name: slugify(payload.folder_name),
lang: payload.lang || "en",
title: payload.title || CONFIG_DEFAULTS.title,
subtitle: payload.subtitle || CONFIG_DEFAULTS.subtitle,
headline: payload.headline || CONFIG_DEFAULTS.headline,
content: payload.content || CONFIG_DEFAULTS.content,
url_message: payload.url_message || CONFIG_DEFAULTS.url_message,
url: payload.url || CONFIG_DEFAULTS.url,
footer_message: payload.footer_message || CONFIG_DEFAULTS.footer_message,
attachment_path: "",
attachment_filename: ""
};
}
function buildUnsignedEvent(payload) {
const folderName = slugify(payload.folder_name);
return {
kind: EVENT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", `voxvera:${folderName}`],
["t", "voxvera"],
["t", "flyer"],
["title", payload.headline || payload.title || folderName],
["language", payload.lang || "en"]
],
content: JSON.stringify(payload)
};
}
function flyerIdentifier(payload) {
return `voxvera:${slugify(payload.folder_name)}`;
}
function buildNaddr(identity, payload, relays) {
return nostrTools().nip19.naddrEncode({
identifier: flyerIdentifier(payload),
pubkey: identity.pubkey,
kind: EVENT_KIND,
relays
});
}
function clientBaseUrl() {
return `${window.location.origin}${window.location.pathname}`;
}
function posterUrlForNaddr(naddr) {
return `${clientBaseUrl()}?addr=${encodeURIComponent(naddr)}`;
}
function withPosterUrl(payload, identity, relays) {
const naddr = buildNaddr(identity, payload, relays);
return {
payload: {
...payload,
url: posterUrlForNaddr(naddr),
qr_target: "flyer_url"
},
naddr,
posterUrl: posterUrlForNaddr(naddr)
};
}
function updatePosterAddressOutputs() {
const naddrOutput = el("naddr-output");
const posterUrlOutput = el("poster-url-output");
if (!naddrOutput || !posterUrlOutput) return null;
try {
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const payload = buildPayloadFromForm();
const poster = withPosterUrl(payload, identity, relays);
naddrOutput.textContent = poster.naddr;
posterUrlOutput.textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
return poster;
} catch (_) {
return null;
}
}
async function signAndPublish(payload, relays) {
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
const signed = nostrTools().finalizeEvent(buildUnsignedEvent(poster.payload), identity.secretKey);
el("author-npub-output").textContent = identity.npub;
el("naddr-output").textContent = poster.naddr;
el("poster-url-output").textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
refreshIdentityState();
if (!signed.id || !signed.sig) {
throw new Error("Signer returned an event without id/sig.");
}
const results = await publishEvent(signed, relays);
return { event: signed, results, naddr: poster.naddr, posterUrl: poster.posterUrl, payload: poster.payload };
}
function publishEvent(event, relays) {
return Promise.all(relays.map((relay) => publishToRelay(event, relay)));
}
function publishToRelay(event, relay) {
return new Promise((resolve) => {
const ws = new WebSocket(relay);
const timeout = setTimeout(() => {
try { ws.close(); } catch (_) {}
resolve({ relay, ok: false, message: "timeout" });
}, 9000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", event]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "OK" && data[1] === event.id) {
clearTimeout(timeout);
ws.close();
resolve({ relay, ok: Boolean(data[2]), message: data[3] || "" });
}
} catch (_) {}
};
ws.onerror = () => {
clearTimeout(timeout);
resolve({ relay, ok: false, message: "connection error" });
};
});
}
function fetchEvent(eventId, relays) {
return new Promise((resolve, reject) => {
const sub = `voxvera-${Math.random().toString(16).slice(2)}`;
let found = null;
let remaining = relays.length;
const sockets = relays.map((relay) => {
const ws = new WebSocket(relay);
ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, { ids: [eventId], kinds: [EVENT_KIND], limit: 1 }]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "EVENT" && data[2] && data[2].id === eventId && !found) {
found = data[2];
sockets.forEach((socket) => {
try { socket.send(JSON.stringify(["CLOSE", sub])); socket.close(); } catch (_) {}
});
resolve(found);
}
} catch (_) {}
};
ws.onerror = () => {
remaining -= 1;
if (remaining <= 0 && !found) reject(new Error("No relay returned the event."));
};
return ws;
});
setTimeout(() => {
if (!found) {
sockets.forEach((socket) => {
try { socket.close(); } catch (_) {}
});
reject(new Error("Timed out fetching event."));
}
}, 12000);
});
}
function fetchReplaceable(address, relays) {
return new Promise((resolve, reject) => {
const sub = `voxvera-addr-${Math.random().toString(16).slice(2)}`;
let best = null;
let remaining = relays.length;
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
sockets.forEach((socket) => {
try { socket.send(JSON.stringify(["CLOSE", sub])); socket.close(); } catch (_) {}
});
if (best) {
resolve(best);
} else {
reject(new Error("No relay returned the addressed flyer."));
}
};
const sockets = relays.map((relay) => {
const ws = new WebSocket(relay);
ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, {
authors: [address.pubkey],
kinds: [address.kind],
"#d": [address.identifier],
limit: 1
}]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "EVENT" && data[2]) {
const event = data[2];
const hasMatchingD = Array.isArray(event.tags) && event.tags.some((tag) => tag[0] === "d" && tag[1] === address.identifier);
if (event.pubkey === address.pubkey && event.kind === address.kind && hasMatchingD && (!best || event.created_at > best.created_at)) {
best = event;
}
}
if (data[0] === "EOSE") {
remaining -= 1;
if (remaining <= 0) finish();
}
} catch (_) {}
};
ws.onerror = () => {
remaining -= 1;
if (remaining <= 0) finish();
};
return ws;
});
setTimeout(finish, 5000);
});
}
function parseEventReference(value) {
const raw = String(value || "").trim();
if (!raw) return null;
try {
const decoded = decodeURIComponent(raw);
const rawId = decoded.match(/\b[a-f0-9]{64}\b/i);
if (rawId) return { type: "event", id: rawId[0].toLowerCase(), relays: [] };
const nipMatch = decoded.match(/(?:nostr:)?((?:note1|nevent1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]+)/i);
if (!nipMatch) return null;
const tools = nostrTools();
const result = tools.nip19.decode(nipMatch[1].toLowerCase());
if (result.type === "note") {
return { type: "event", id: result.data, relays: [] };
}
if (result.type === "nevent") {
return { type: "event", id: result.data.id, relays: result.data.relays || [] };
}
if (result.type === "naddr") {
return {
type: "address",
address: result.data,
relays: result.data.relays || []
};
}
} catch (_) {
return null;
}
return null;
}
function getUrlEventReference() {
const search = new URLSearchParams(window.location.search);
const params = ["addr", "naddr", "event", "id", "note", "nevent"];
for (const param of params) {
const value = search.get(param);
const parsed = parseEventReference(value);
if (parsed) return parsed;
}
const hash = window.location.hash.replace(/^#/, "");
const hashParts = hash.split(/[?&/]/).filter(Boolean);
for (const part of hashParts) {
const parsed = parseEventReference(part);
if (parsed) return parsed;
}
return parseEventReference(window.location.href);
}
function mergeRelayList(existingValue, extraRelays) {
return parseRelays(`${existingValue}\n${(extraRelays || []).join("\n")}`).join("\n");
}
function payloadFromEvent(event) {
if (!event || event.kind !== EVENT_KIND) throw new Error("Event kind is not a VoxVera source kind.");
const hasTag = (name, value) => Array.isArray(event.tags) && event.tags.some((tag) => tag[0] === name && tag[1] === value);
if (!hasTag("t", "voxvera") || !hasTag("t", "flyer")) throw new Error("Event is missing VoxVera flyer tags.");
const payload = JSON.parse(event.content);
validatePayload(payload);
return payload;
}
function makeQrSvg(value) {
if (!value) return "";
if (typeof qrcode !== "function") {
return '<span class="qr-error">QR unavailable</span>';
}
try {
const qr = qrcode(0, "M");
qr.addData(value);
qr.make();
return qr.createSvgTag({
cellSize: 2,
margin: 2,
scalable: true
});
} catch (_) {
return '<span class="qr-error">QR too long</span>';
}
}
function renderPreview(config) {
const tearOff = config.tear_off_link || config.url || "";
const contentQr = config.url || tearOff;
const tearOffQrSvg = makeQrSvg(tearOff);
const contentQrSvg = makeQrSvg(contentQr);
const sheetClass = tearOff ? "container" : "container no-tear-offs";
const tearOffHtml = Array.from({ length: 10 }).map(() => `
<div class="tear-off">
<div class="tear-off-text">
Open this poster:<br>
<a href="${escapeHtml(tearOff)}">${escapeHtml(tearOff)}</a><br>
Share and reprint.
</div>
<div class="qr-code" aria-label="QR code for ${escapeHtml(tearOff)}">${tearOffQrSvg}</div>
</div>
`).join("");
el("flyer-preview").innerHTML = `
<div class="${sheetClass}">
<div class="left-tear-offs">${tearOffHtml}</div>
<div class="content">
<h1>${redactionToHtml(config.title)}</h1>
<div class="distribute">${redactionToHtml(config.subtitle)}</div>
<h1>${redactionToHtml(config.headline)}</h1>
<hr>
<div class="message">${redactionToHtml(config.content)}</div>
<div class="qr-code-body">
<div class="qr-code-url">
<span class="url-message">${redactionToHtml(config.url_message)}</span><br><br>
<a href="${escapeHtml(config.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(config.url)}</a>
</div>
<div class="qr-code main-qr" aria-label="QR code for ${escapeHtml(contentQr)}">${contentQrSvg}</div>
</div>
<hr>
<div class="footer">
<p class="credit">Built with <a href="https://github.com/PR0M3TH3AN/VoxVera">voxvera</a></p>
<p class="binary">${redactionToHtml(config.footer_message)}</p>
</div>
</div>
</div>
`;
}
function formToPreview() {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
el("author-npub-output").textContent = identity.npub;
el("naddr-output").textContent = poster.naddr;
el("poster-url-output").textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
refreshIdentityState();
renderPreview(normalizePayload(poster.payload));
}
function setMode(mode) {
document.querySelectorAll(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.mode === mode));
el("editor-panel").classList.toggle("active", mode === "editor");
el("viewer-panel").classList.toggle("active", mode === "viewer");
document.body.classList.toggle("mode-editor", mode === "editor");
document.body.classList.toggle("mode-viewer", mode === "viewer");
if (mode === "viewer") {
closeViewerDrawer();
}
if (!window.location.hash || window.location.hash === "#editor" || window.location.hash === "#viewer") {
history.replaceState(null, "", `#${mode}`);
}
}
function openViewerDrawer() {
el("viewer-panel").classList.add("drawer-open");
}
function closeViewerDrawer() {
el("viewer-panel").classList.remove("drawer-open");
}
function downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function renderRelayResults(results) {
return `<div class="relay-results">${results.map((item) => `
<div>
<span>${escapeHtml(item.relay)}:</span>
<span class="${item.ok ? "relay-ok" : "relay-error"}">${item.ok ? "OK" : escapeHtml(item.message || "failed")}</span>
</div>
`).join("")}</div>`;
}
async function copyTextFromElement(targetId, button) {
const value = el(targetId).textContent.trim();
if (!value || value === "Not published" || value === "Not generated") return;
await navigator.clipboard.writeText(value);
const oldText = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = oldText;
}, 1200);
}
async function fetchAndRenderFromInput(eventInput, relaysValue, statusElement = el("viewer-status")) {
const parsed = parseEventReference(eventInput);
if (!parsed) throw new Error("Enter an naddr, event id, note1, or nevent1.");
const relays = parseRelays(`${relaysValue}\n${(parsed.relays || []).join("\n")}`);
if (!relays.length) throw new Error("Enter at least one wss:// relay.");
el("viewer-event-id").value = eventInput;
el("viewer-relays").value = relays.join("\n");
statusElement.textContent = "Fetching...";
const nostrEvent = parsed.type === "address"
? await fetchReplaceable(parsed.address, relays)
: await fetchEvent(parsed.id, relays);
const payload = payloadFromEvent(nostrEvent);
const config = normalizePayload(payload);
el("viewer-config-output").value = JSON.stringify(config, null, 2);
renderPreview(config);
statusElement.textContent = `Fetched ${parsed.type === "address" ? parsed.address.identifier : parsed.id}`;
closeViewerDrawer();
}
document.addEventListener("DOMContentLoaded", () => {
setDefaultText();
const urlEvent = getUrlEventReference();
setMode(window.location.hash === "#editor" && !urlEvent ? "editor" : "viewer");
if (urlEvent) {
const urlInput = window.location.href;
el("viewer-event-id").value = urlInput;
el("viewer-relays").value = mergeRelayList(el("viewer-relays").value, urlEvent.relays);
fetchAndRenderFromInput(urlInput, el("viewer-relays").value).catch((error) => {
el("viewer-status").textContent = error.message;
openViewerDrawer();
});
}
document.querySelectorAll(".tab").forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.mode));
});
el("open-viewer-controls").addEventListener("click", openViewerDrawer);
el("close-viewer-controls").addEventListener("click", closeViewerDrawer);
el("viewer-editor").addEventListener("click", () => setMode("editor"));
el("viewer-print").addEventListener("click", () => window.print());
document.querySelectorAll(".copy-button").forEach((button) => {
button.addEventListener("click", () => {
copyTextFromElement(button.dataset.copyTarget, button).catch((error) => {
el("publish-status").textContent = error.message;
});
});
});
el("generate-anon-identity").addEventListener("click", () => {
try {
const identity = getOrCreateAnonIdentity(true);
el("author-npub-output").textContent = identity.npub;
refreshIdentityState();
el("publish-status").textContent = `Anonymous npub generated: ${identity.npub}`;
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("preview-editor").addEventListener("click", () => {
try {
formToPreview();
el("publish-status").textContent = "Preview updated.";
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("export-event").addEventListener("click", () => {
try {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
const event = buildUnsignedEvent(poster.payload);
el("author-npub-output").textContent = identity.npub;
el("naddr-output").textContent = poster.naddr;
el("poster-url-output").textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
el("event-json-output").value = JSON.stringify(event, null, 2);
downloadJson(`${poster.payload.folder_name}-voxvera-event-unsigned.json`, event);
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("editor-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
if (!relays.length) throw new Error("Enter at least one wss:// relay.");
el("publish-status").textContent = "Signing and publishing...";
const result = await signAndPublish(payload, relays);
el("event-id-output").textContent = result.event.id;
el("event-json-output").value = JSON.stringify(result.event, null, 2);
el("viewer-event-id").value = result.naddr;
el("publish-status").innerHTML = renderRelayResults(result.results);
renderPreview(normalizePayload(result.payload));
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("viewer-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await fetchAndRenderFromInput(el("viewer-event-id").value, el("viewer-relays").value);
} catch (error) {
el("viewer-status").textContent = error.message;
}
});
el("copy-viewer-config").addEventListener("click", async () => {
const value = el("viewer-config-output").value;
if (!value) return;
await navigator.clipboard.writeText(value);
el("viewer-status").textContent = "Config copied.";
});
el("print-preview").addEventListener("click", () => window.print());
});
})();
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>
+13
View File
@@ -0,0 +1,13 @@
nostr-tools 2.23.5
==================
Vendored browser bundle used by the static Nostr client for:
- NIP-19 parsing and encoding (`note1`, `nevent1`, `npub`, `nsec`)
- anonymous local key generation
- NIP-01 event finalization and signing
Source package: `nostr-tools`
License: Unlicense
The package license is included in `LICENSE`.
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
qrcode-generator 2.0.4
=======================
Vendored browser QR generator used by the static Nostr client.
Source package: `qrcode-generator`
Author: Kazuhiko Arase
License: MIT
The vendored `qrcode.js` file includes its original copyright and MIT
license header.
File diff suppressed because it is too large. Load diff