Bulletin board: login gate, web-of-trust filter, and key connect methods

Gate the board behind a connected Nostr identity and filter what it shows
to the viewer's web of trust, so it is no longer an unmoderated firehose.

- Login gate: the board shows a Connect prompt instead of the table until a
  Nostr identity is connected; the connected npub is surfaced with a
  Disconnect control and remembered (pubkey only) for silent reconnect.
- Connect methods: NIP-07 extension, pasting an nsec (decoded in-page to a
  pubkey, then discarded — the board reads, never signs), or creating a new
  key (reveals the nsec to save, reuses the device key if present).
- Web-of-trust filter (NIP-02): defaults to flyers from authors in the
  viewer's follow graph, with a "Show all" opt-out. A fresh key with no
  follows is seeded from a pinned curator account so new viewers still get a
  curated board; if no trust data loads at all, it shows everything.
- Localized across all 14 languages (gate, filter, and connect strings),
  RTL included.

Covered by the cross-engine Playwright suite (gate shown/hidden, connect via
each method, pubkey-only persistence, web-of-trust filtering + show-all, and
the curator-seeded fallback).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-10 10:16:24 -04:00
1 parent b92cb018c5
commit 1297a73332
4 files changed
+836 -36

No files matched your search

+209
View File
@@ -16,6 +16,80 @@ function trackErrors(page) {
return errors;
}
/** Install a fake NIP-07 provider (window.nostr) so the board's login gate can
* be satisfied headlessly — no real extension exists in CI. */
function stubNip07(page, pubkeyHex) {
const pk = pubkeyHex || "1".repeat(64);
return page.addInitScript((hex) => {
window.nostr = {
getPublicKey: async () => hex,
signEvent: async (e) => e
};
}, pk);
}
/** Stub all relay WebSockets so the board's REQs are answered from a fixed set
* of events (no real network). Events are matched against the REQ filter by
* kind, authors, and the `#t` tag, then streamed back followed by EOSE. */
function stubRelays(page, events) {
return page.addInitScript((evts) => {
function matches(ev, filter) {
if (filter.kinds && filter.kinds.indexOf(ev.kind) === -1) return false;
if (filter.authors && filter.authors.indexOf(ev.pubkey) === -1) return false;
if (filter["#t"]) {
const tvals = (ev.tags || []).filter((x) => x[0] === "t").map((x) => x[1]);
if (!filter["#t"].some((v) => tvals.indexOf(v) !== -1)) return false;
}
return true;
}
class FakeWS {
constructor() { this.readyState = 1; setTimeout(() => this.onopen && this.onopen(), 1); }
send(data) {
let m;
try { m = JSON.parse(data); } catch (_) { return; }
if (m[0] !== "REQ") return;
const sub = m[1];
const filter = m[2] || {};
const out = evts.filter((ev) => matches(ev, filter));
setTimeout(() => {
out.forEach((ev) => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", sub, ev]) }));
this.onmessage && this.onmessage({ data: JSON.stringify(["EOSE", sub]) });
}, 1);
}
close() {}
}
window.WebSocket = FakeWS;
}, events || []);
}
// Pubkeys used by the web-of-trust tests. ME matches stubNip07's default.
const ME = "1".repeat(64);
const TRUSTED = "a".repeat(64);
const UNTRUSTED = "b".repeat(64);
const CURATOR = "a4a6b5849bc917b3befd5c81865ee0b88773690609c207ba6588ef3e1e05b95b";
function flyerEvent(id, pubkey, title) {
return {
id,
kind: 30078,
pubkey,
created_at: 2000,
tags: [["d", "voxvera:" + id], ["t", "voxvera"], ["t", "flyer"]],
content: JSON.stringify({ type: "voxvera_flyer", title, lang: "en", url: "https://example.com/" + id })
};
}
function contactList(pubkey, follows) {
return {
id: "contacts-" + pubkey.slice(0, 6),
kind: 3,
pubkey,
created_at: 1000,
tags: follows.map((p) => ["p", p]),
content: ""
};
}
test.describe("VoxVera static client", () => {
test("loads the default flyer with no JS errors", async ({ page }) => {
const errors = trackErrors(page);
@@ -160,8 +234,13 @@ test.describe("VoxVera static client", () => {
});
test("bulletin board page renders localized, sortable columns", async ({ page }) => {
await stubNip07(page);
await stubRelays(page, []);
await page.goto("/board.html");
await expect(page.locator("#board-title")).toHaveText("Bulletin Board");
// The board is gated: connect a (stubbed) Nostr identity to reveal it.
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
const headers = page.locator("#board-head th");
await expect(headers).toHaveCount(5);
await expect(headers.nth(0)).toContainText("Title");
@@ -176,6 +255,136 @@ test.describe("VoxVera static client", () => {
await expect(headers.nth(0).locator(".sort-indicator")).toContainText("▲");
});
test("bulletin board is gated behind a Nostr connection", async ({ page }) => {
// No NIP-07 provider: the gate is shown, the table is hidden, and trying to
// connect reports that no extension was found.
await page.goto("/board.html");
await expect(page.locator("#board-gate")).toBeVisible();
await expect(page.locator("#board-content")).toBeHidden();
await expect(page.locator("#board-identity")).toBeHidden();
await page.locator("#board-connect").click();
await expect(page.locator("#board-gate-error")).toContainText(/No Nostr extension/i);
});
test("connecting a Nostr identity reveals the board and disconnect hides it", async ({ page }) => {
await stubNip07(page);
await stubRelays(page, []);
await page.goto("/board.html");
await expect(page.locator("#board-content")).toBeHidden();
await page.locator("#board-connect").click();
// Board content appears and the connected npub is surfaced.
await expect(page.locator("#board-content")).toBeVisible();
await expect(page.locator("#board-identity")).toBeVisible();
await expect(page.locator("#board-identity-npub")).toContainText(/^npub1/);
// Disconnect returns to the gate.
await page.locator("#board-disconnect").click();
await expect(page.locator("#board-gate")).toBeVisible();
await expect(page.locator("#board-content")).toBeHidden();
});
test("a remembered connection auto-restores on revisit", async ({ page }) => {
await stubNip07(page);
await stubRelays(page, []);
await page.goto("/board.html");
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
// The pubkey was remembered, so a fresh load connects without a click.
await page.reload();
await expect(page.locator("#board-content")).toBeVisible();
await expect(page.locator("#board-gate")).toBeHidden();
});
test("connecting by pasting an nsec derives the pubkey and reveals the board", async ({ page }) => {
// No NIP-07 here — the nsec path must work without an extension (mobile).
await stubRelays(page, []);
await page.goto("/board.html");
// Build a valid nsec in-page and capture the npub it should resolve to.
const { nsec, npub } = await page.evaluate(() => {
const t = window.NostrTools;
const sk = t.generateSecretKey();
return { nsec: t.nip19.nsecEncode(sk), npub: t.nip19.npubEncode(t.getPublicKey(sk)) };
});
await page.locator("#board-connect-nsec-toggle").click();
await page.locator("#board-nsec-input").fill(nsec);
await page.locator("#board-nsec-submit").click();
await expect(page.locator("#board-content")).toBeVisible();
await expect(page.locator("#board-identity-npub")).toContainText(npub.slice(0, 12));
// The secret is not left in the input, and only the pubkey is remembered.
await expect(page.locator("#board-nsec-input")).toHaveValue("");
expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toMatch(/^[0-9a-f]{64}$/);
expect(await page.evaluate(() => localStorage.getItem("voxvera_nostr_anon_secret_hex"))).toBeNull();
// A reload restores the session from the pubkey alone (no nsec re-entry).
await page.reload();
await expect(page.locator("#board-content")).toBeVisible();
});
test("an invalid nsec shows an error and does not connect", async ({ page }) => {
await page.goto("/board.html");
await page.locator("#board-connect-nsec-toggle").click();
await page.locator("#board-nsec-input").fill("nsec1notarealkey");
await page.locator("#board-nsec-submit").click();
await expect(page.locator("#board-gate-error")).toContainText(/valid nsec/i);
await expect(page.locator("#board-content")).toBeHidden();
});
test("creating a new key reveals the secret then connects", async ({ page }) => {
await stubRelays(page, []);
await page.goto("/board.html");
await page.locator("#board-generate").click();
// The new key is revealed (npub + nsec) so the user can save it.
await expect(page.locator("#board-generated")).toBeVisible();
await expect(page.locator("#board-gen-npub")).toHaveValue(/^npub1[0-9a-z]+/);
await expect(page.locator("#board-gen-nsec")).toHaveValue(/^nsec1[0-9a-z]+/);
// It is stored as the device key (shared with the editor's anon identity).
expect(await page.evaluate(() => localStorage.getItem("voxvera_nostr_anon_secret_hex"))).toMatch(/^[0-9a-f]{64}$/);
await page.locator("#board-gen-continue").click();
await expect(page.locator("#board-content")).toBeVisible();
await expect(page.locator("#board-identity-npub")).toContainText(/^npub1/);
});
test("web-of-trust filter shows only followed authors, with a show-all opt-out", async ({ page }) => {
// The connected viewer follows TRUSTED (but not UNTRUSTED).
await stubNip07(page, ME);
await stubRelays(page, [
contactList(ME, [TRUSTED]),
flyerEvent("trusted", TRUSTED, "Trusted Flyer"),
flyerEvent("untrusted", UNTRUSTED, "Untrusted Flyer")
]);
await page.goto("/board.html");
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
// Only the followed author's flyer is shown by default.
await expect(page.locator("#board-rows")).toContainText("Trusted Flyer");
await expect(page.locator("#board-rows")).not.toContainText("Untrusted Flyer");
await expect(page.locator("#board-filter-note")).toContainText(/people you follow/i);
await expect(page.locator("#board-show-all")).not.toBeChecked();
// Opting in to "show all" reveals the untrusted flyer too. Click the label
// (WebKit can report the bare checkbox as not actionable); this is also how
// a user toggles it.
await page.locator("#board-show-all-label").click();
await expect(page.locator("#board-show-all")).toBeChecked();
await expect(page.locator("#board-rows")).toContainText("Untrusted Flyer");
await expect(page.locator("#board-filter-note")).toContainText(/all flyers/i);
});
test("a viewer with no follow list is seeded from the curator's web of trust", async ({ page }) => {
// The connected viewer has NO contact list; the curator follows TRUSTED, so
// the board seeds its trust set from the curator and still hides UNTRUSTED.
await stubNip07(page, ME);
await stubRelays(page, [
contactList(CURATOR, [TRUSTED]),
flyerEvent("trusted", TRUSTED, "Trusted Flyer"),
flyerEvent("untrusted", UNTRUSTED, "Untrusted Flyer")
]);
await page.goto("/board.html");
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
await expect(page.locator("#board-rows")).toContainText("Trusted Flyer");
await expect(page.locator("#board-rows")).not.toContainText("Untrusted Flyer");
// The note explains this is a seeded/curated set, not the viewer's own graph.
await expect(page.locator("#board-filter-note")).toContainText(/don't follow anyone yet|curated/i);
});
test("bulletin board assets use root-absolute paths (cleanUrls/trailingSlash safe)", async ({ page }) => {
await page.goto("/board.html");
const refs = await page.evaluate(() =>
+53 -5
View File
@@ -17,15 +17,63 @@
</div>
<div class="topbar-controls">
<a id="board-back" class="board-back" href="/">Back to VoxVera</a>
<span id="board-identity" class="board-identity" hidden>
<span id="board-identity-npub" class="board-npub"></span>
<button id="board-disconnect" type="button" class="board-disconnect">Disconnect</button>
</span>
<select id="board-lang" aria-label="Language"></select>
</div>
</header>
<main class="board-main">
<p id="board-status">Loading…</p>
<table class="board-table" id="board-table">
<thead><tr id="board-head"></tr></thead>
<tbody id="board-rows"></tbody>
</table>
<!-- Login gate: the board is visible only to a connected Nostr identity.
The data itself is public on the relays, so this is not access
control — it supplies the viewer's identity so a later web-of-trust
filter can show them a trustworthy board (see docs/roadmap.md). -->
<section id="board-gate" class="board-gate">
<h2 id="board-gate-title">Connect to view the board</h2>
<p id="board-gate-body">Connect a Nostr identity to view the bulletin board.</p>
<div class="board-connect-methods">
<button id="board-connect" type="button" class="board-connect">Browser extension (NIP-07)</button>
<button id="board-connect-nsec-toggle" type="button" class="board-connect-alt">Use a private key (nsec)</button>
<button id="board-generate" type="button" class="board-connect-alt">Create a new key</button>
</div>
<!-- nsec import: the key is decoded in-page to derive the pubkey, then
discarded. Only the pubkey is persisted (board never signs). -->
<div id="board-nsec-row" class="board-nsec-row" hidden>
<input id="board-nsec-input" type="password" autocomplete="off" spellcheck="false" placeholder="nsec1…">
<button id="board-nsec-submit" type="button" class="board-connect">Connect</button>
<p id="board-nsec-note" class="board-key-note"></p>
</div>
<!-- Generated key reveal: shown once so the user can save the secret. -->
<div id="board-generated" class="board-generated" hidden>
<p id="board-gen-warning" class="board-key-warning"></p>
<label class="board-key-field">npub
<input id="board-gen-npub" readonly>
</label>
<label class="board-key-field">nsec
<span class="board-key-secret">
<input id="board-gen-nsec" type="password" readonly>
<button id="board-gen-copy" type="button" class="board-disconnect">Copy</button>
</span>
</label>
<button id="board-gen-continue" type="button" class="board-connect">Continue</button>
</div>
<p id="board-gate-error" class="board-gate-error" role="alert" hidden></p>
</section>
<div id="board-content" hidden>
<div class="board-filter">
<span id="board-filter-note" class="board-filter-note"></span>
<label class="board-show-all">
<input type="checkbox" id="board-show-all">
<span id="board-show-all-label">Show all flyers</span>
</label>
</div>
<p id="board-status">Loading…</p>
<table class="board-table" id="board-table">
<thead><tr id="board-head"></tr></thead>
<tbody id="board-rows"></tbody>
</table>
</div>
</main>
<script src="/locales.js"></script>
<script src="/vendor/nostr-tools/nostr.bundle.js"></script>
+409 -31
View File
@@ -12,24 +12,39 @@
];
const EVENT_KIND = 30078;
const UI_LANG_STORAGE_KEY = "voxvera_nostr_lang";
// Shared with the main client: the connected viewer's pubkey (hex). The board
// is gated on having a connected Nostr identity (NIP-07). The data on relays
// is public regardless, so this is not access control — it supplies the
// viewer's identity for a later web-of-trust filter (see docs/roadmap.md).
const NPUB_STORAGE_KEY = "voxvera_connected_pubkey";
// Shared with the main client: this device's anonymous secret key (hex). The
// "Create a new key" flow reuses it if present (so it doesn't orphan an
// identity already used in the editor) or generates and stores one.
const ANON_SECRET_STORAGE_KEY = "voxvera_nostr_anon_secret_hex";
// Bootstrap trust anchor (hex pubkey for
// npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx). When a
// connected viewer has no NIP-02 follow list of their own (a fresh key), the
// board seeds its web-of-trust filter from this account's follows, so a new
// viewer still gets a curated board instead of the unmoderated firehose.
const FALLBACK_CURATOR_PUBKEY = "a4a6b5849bc917b3befd5c81865ee0b88773690609c207ba6588ef3e1e05b95b";
const FALLBACK_LANG = "en";
const LOCALES = window.VoxVeraLocales || {};
const BOARD_UI = {
en: { title: "Bulletin Board", subtitle: "Most recent flyers published to the relays.", col_title: "Title", col_link: "Link", col_author: "Posted by", col_language: "Language", col_date: "Posted", link_text: "link", loading: "Loading flyers…", empty: "No flyers found yet.", error: "Could not reach the relays.", back: "← Back to VoxVera" },
es: { title: "Tablón de anuncios", subtitle: "Carteles más recientes publicados en los relés.", col_title: "Título", col_link: "Enlace", col_author: "Publicado por", col_language: "Idioma", col_date: "Publicado", link_text: "enlace", loading: "Cargando carteles…", empty: "Aún no se encontraron carteles.", error: "No se pudo conectar con los relés.", back: "← Volver a VoxVera" },
de: { title: "Schwarzes Brett", subtitle: "Neueste auf den Relays veröffentlichte Plakate.", col_title: "Titel", col_link: "Link", col_author: "Veröffentlicht von", col_language: "Sprache", col_date: "Veröffentlicht", link_text: "Link", loading: "Plakate werden geladen…", empty: "Noch keine Plakate gefunden.", error: "Relays konnten nicht erreicht werden.", back: "← Zurück zu VoxVera" },
fr: { title: "Tableau d'affichage", subtitle: "Affiches les plus récentes publiées sur les relais.", col_title: "Titre", col_link: "Lien", col_author: "Publié par", col_language: "Langue", col_date: "Publié", link_text: "lien", loading: "Chargement des affiches…", empty: "Aucune affiche trouvée pour l'instant.", error: "Impossible de joindre les relais.", back: "← Retour à VoxVera" },
ru: { title: "Доска объявлений", subtitle: "Последние листовки, опубликованные на реле.", col_title: "Заголовок", col_link: "Ссылка", col_author: "Опубликовал", col_language: "Язык", col_date: "Опубликовано", link_text: "ссылка", loading: "Загрузка листовок…", empty: "Листовки пока не найдены.", error: "Не удалось подключиться к реле.", back: "← Назад в VoxVera" },
he: { title: "לוח מודעות", subtitle: "הכרזות האחרונות שפורסמו לממסרים.", col_title: "כותרת", col_link: "קישור", col_author: "פורסם על ידי", col_language: "שפה", col_date: "פורסם", link_text: "קישור", loading: "טוען כרזות…", empty: "לא נמצאו כרזות עדיין.", error: "לא ניתן להתחבר לממסרים.", back: "← חזרה ל-VoxVera" },
ar: { title: "لوحة الإعلانات", subtitle: "أحدث الملصقات المنشورة على المرحلات.", col_title: "العنوان", col_link: "رابط", col_author: "نُشر بواسطة", col_language: "اللغة", col_date: "تاريخ النشر", link_text: "رابط", loading: "جارٍ تحميل الملصقات…", empty: "لم يتم العثور على ملصقات بعد.", error: "تعذّر الوصول إلى المرحلات.", back: "← العودة إلى VoxVera" },
fa: { title: "تابلوی اعلانات", subtitle: "جدیدترین پوسترهای منتشرشده روی رله‌ها.", col_title: "عنوان", col_link: "پیوند", col_author: "منتشرشده توسط", col_language: "زبان", col_date: "تاریخ انتشار", link_text: "پیوند", loading: "در حال بارگذاری پوسترها…", empty: "هنوز پوستری یافت نشد.", error: "اتصال به رله‌ها ممکن نشد.", back: "← بازگشت به VoxVera" },
hi: { title: "बुलेटिन बोर्ड", subtitle: "रिले पर प्रकाशित सबसे हाल के पोस्टर।", col_title: "शीर्षक", col_link: "लिंक", col_author: "द्वारा पोस्ट किया गया", col_language: "भाषा", col_date: "पोस्ट किया गया", link_text: "लिंक", loading: "पोस्टर लोड हो रहे हैं…", empty: "अभी तक कोई पोस्टर नहीं मिला।", error: "रिले तक नहीं पहुँच सके।", back: "← VoxVera पर वापस" },
ja: { title: "掲示板", subtitle: "リレーに公開された最新のちらし。", col_title: "タイトル", col_link: "リンク", col_author: "投稿者", col_language: "言語", col_date: "投稿日", link_text: "リンク", loading: "ちらしを読み込んでいます…", empty: "まだちらしが見つかりません。", error: "リレーに接続できませんでした。", back: "← VoxVera に戻る" },
pt: { title: "Quadro de avisos", subtitle: "Panfletos mais recentes publicados nos relés.", col_title: "Título", col_link: "Link", col_author: "Publicado por", col_language: "Idioma", col_date: "Publicado", link_text: "link", loading: "Carregando panfletos…", empty: "Nenhum panfleto encontrado ainda.", error: "Não foi possível acessar os relés.", back: "← Voltar ao VoxVera" },
sw: { title: "Ubao wa matangazo", subtitle: "Mabango ya hivi karibuni yaliyochapishwa kwenye relay.", col_title: "Kichwa", col_link: "Kiungo", col_author: "Imechapishwa na", col_language: "Lugha", col_date: "Imechapishwa", link_text: "kiungo", loading: "Inapakia mabango…", empty: "Hakuna mabango yaliyopatikana bado.", error: "Imeshindwa kufikia relay.", back: "← Rudi VoxVera" },
tr: { title: "İlan panosu", subtitle: "Rölelere yayımlanan en yeni el ilanları.", col_title: "Başlık", col_link: "Bağlantı", col_author: "Yayımlayan", col_language: "Dil", col_date: "Yayımlandı", link_text: "bağlantı", loading: "El ilanları yükleniyor…", empty: "Henüz el ilanı bulunamadı.", error: "Rölelere ulaşılamadı.", back: "← VoxVera'ya dön" },
zh: { title: "公告栏", subtitle: "发布到中继的最新传单。", col_title: "标题", col_link: "链接", col_author: "发布者", col_language: "语言", col_date: "发布时间", link_text: "链接", loading: "正在加载传单…", empty: "尚未找到传单。", error: "无法连接到中继。", back: "← 返回 VoxVera" }
en: { title: "Bulletin Board", subtitle: "Most recent flyers published to the relays.", col_title: "Title", col_link: "Link", col_author: "Posted by", col_language: "Language", col_date: "Posted", link_text: "link", loading: "Loading flyers…", empty: "No flyers found yet.", error: "Could not reach the relays.", back: "← Back to VoxVera", gate_title: "Connect to view the board", gate_body: "The bulletin board is visible to people who connect a Nostr identity. Choose how to connect below.", connect: "Connect", connecting: "Connecting…", no_extension: "No Nostr extension found. Install a NIP-07 browser extension to view the board.", connect_failed: "Connection cancelled or failed. Please try again.", disconnect: "Disconnect", filter_following: "Showing flyers from people you follow.", filter_seeded: "You don't follow anyone yet — showing a curated set from VoxVera's trust network.", filter_all: "Showing all flyers on the relays (unfiltered).", filter_unavailable: "Couldn't load a trust network — showing all flyers.", filter_empty: "No flyers from your trust network yet. Turn on “Show all” to see everything.", show_all_label: "Show all flyers", connect_extension: "Browser extension (NIP-07)", connect_nsec: "Use a private key (nsec)", connect_generate: "Create a new key", key_note: "Your key stays in this browser and is only used to read your follow list — it is never sent anywhere.", invalid_nsec: "That doesn't look like a valid nsec key.", gen_warning: "This is your new secret key. Anyone who has it controls this identity — save it somewhere safe, because VoxVera can't recover it.", copy: "Copy", copied: "Copied", continue_label: "Continue" },
es: { title: "Tablón de anuncios", subtitle: "Carteles más recientes publicados en los relés.", col_title: "Título", col_link: "Enlace", col_author: "Publicado por", col_language: "Idioma", col_date: "Publicado", link_text: "enlace", loading: "Cargando carteles…", empty: "Aún no se encontraron carteles.", error: "No se pudo conectar con los relés.", back: "← Volver a VoxVera", gate_title: "Conéctate para ver el tablón", gate_body: "El tablón de anuncios es visible para quienes conectan una identidad Nostr. Elige abajo cómo conectarte.", connect: "Conectar", connecting: "Conectando…", no_extension: "No se encontró ninguna extensión Nostr. Instala una extensión de navegador NIP-07 para ver el tablón.", connect_failed: "Conexión cancelada o fallida. Inténtalo de nuevo.", disconnect: "Desconectar", filter_following: "Mostrando carteles de las personas que sigues.", filter_seeded: "Aún no sigues a nadie: mostramos un conjunto curado de la red de confianza de VoxVera.", filter_all: "Mostrando todos los carteles de los relés (sin filtrar).", filter_unavailable: "No se pudo cargar una red de confianza: mostrando todos los carteles.", filter_empty: "Aún no hay carteles de tu red de confianza. Activa «Mostrar todos» para verlo todo.", show_all_label: "Mostrar todos los carteles", connect_extension: "Extensión del navegador (NIP-07)", connect_nsec: "Usar una clave privada (nsec)", connect_generate: "Crear una clave nueva", key_note: "Tu clave permanece en este navegador y solo se usa para leer tu lista de seguidos; nunca se envía a ningún sitio.", invalid_nsec: "Eso no parece una clave nsec válida.", gen_warning: "Esta es tu nueva clave secreta. Cualquiera que la tenga controla esta identidad: guárdala en un lugar seguro, porque VoxVera no puede recuperarla.", copy: "Copiar", copied: "Copiado", continue_label: "Continuar" },
de: { title: "Schwarzes Brett", subtitle: "Neueste auf den Relays veröffentlichte Plakate.", col_title: "Titel", col_link: "Link", col_author: "Veröffentlicht von", col_language: "Sprache", col_date: "Veröffentlicht", link_text: "Link", loading: "Plakate werden geladen…", empty: "Noch keine Plakate gefunden.", error: "Relays konnten nicht erreicht werden.", back: "← Zurück zu VoxVera", gate_title: "Verbinden, um das Brett zu sehen", gate_body: "Das Schwarze Brett ist für Personen sichtbar, die eine Nostr-Identität verbinden. Wähle unten, wie du dich verbinden möchtest.", connect: "Verbinden", connecting: "Verbindung…", no_extension: "Keine Nostr-Erweiterung gefunden. Installiere eine NIP-07-Browsererweiterung, um das Brett zu sehen.", connect_failed: "Verbindung abgebrochen oder fehlgeschlagen. Bitte erneut versuchen.", disconnect: "Trennen", filter_following: "Plakate von Personen, denen du folgst.", filter_seeded: "Du folgst noch niemandem – wir zeigen eine kuratierte Auswahl aus dem Vertrauensnetz von VoxVera.", filter_all: "Alle Plakate auf den Relays (ungefiltert).", filter_unavailable: "Vertrauensnetz konnte nicht geladen werden – alle Plakate werden angezeigt.", filter_empty: "Noch keine Plakate aus deinem Vertrauensnetz. Aktiviere „Alle anzeigen“, um alles zu sehen.", show_all_label: "Alle Plakate anzeigen", connect_extension: "Browser-Erweiterung (NIP-07)", connect_nsec: "Privaten Schlüssel (nsec) verwenden", connect_generate: "Neuen Schlüssel erstellen", key_note: "Dein Schlüssel bleibt in diesem Browser und wird nur zum Lesen deiner Folgeliste verwendet – er wird nirgendwohin gesendet.", invalid_nsec: "Das sieht nicht nach einem gültigen nsec-Schlüssel aus.", gen_warning: "Das ist dein neuer geheimer Schlüssel. Wer ihn hat, kontrolliert diese Identität – bewahre ihn sicher auf, denn VoxVera kann ihn nicht wiederherstellen.", copy: "Kopieren", copied: "Kopiert", continue_label: "Weiter" },
fr: { title: "Tableau d'affichage", subtitle: "Affiches les plus récentes publiées sur les relais.", col_title: "Titre", col_link: "Lien", col_author: "Publié par", col_language: "Langue", col_date: "Publié", link_text: "lien", loading: "Chargement des affiches…", empty: "Aucune affiche trouvée pour l'instant.", error: "Impossible de joindre les relais.", back: "← Retour à VoxVera", gate_title: "Connectez-vous pour voir le tableau", gate_body: "Le tableau d'affichage est visible par les personnes qui connectent une identité Nostr. Choisissez ci-dessous comment vous connecter.", connect: "Se connecter", connecting: "Connexion…", no_extension: "Aucune extension Nostr trouvée. Installez une extension de navigateur NIP-07 pour voir le tableau.", connect_failed: "Connexion annulée ou échouée. Veuillez réessayer.", disconnect: "Se déconnecter", filter_following: "Affiches des personnes que vous suivez.", filter_seeded: "Vous ne suivez encore personne – voici une sélection issue du réseau de confiance de VoxVera.", filter_all: "Toutes les affiches sur les relais (non filtrées).", filter_unavailable: "Impossible de charger un réseau de confiance – toutes les affiches sont affichées.", filter_empty: "Aucune affiche de votre réseau de confiance pour l'instant. Activez « Tout afficher » pour tout voir.", show_all_label: "Afficher toutes les affiches", connect_extension: "Extension de navigateur (NIP-07)", connect_nsec: "Utiliser une clé privée (nsec)", connect_generate: "Créer une nouvelle clé", key_note: "Votre clé reste dans ce navigateur et ne sert qu'à lire votre liste d'abonnements ; elle n'est jamais envoyée nulle part.", invalid_nsec: "Cela ne ressemble pas à une clé nsec valide.", gen_warning: "Voici votre nouvelle clé secrète. Quiconque la possède contrôle cette identité — conservez-la en lieu sûr, car VoxVera ne peut pas la récupérer.", copy: "Copier", copied: "Copié", continue_label: "Continuer" },
ru: { title: "Доска объявлений", subtitle: "Последние листовки, опубликованные на реле.", col_title: "Заголовок", col_link: "Ссылка", col_author: "Опубликовал", col_language: "Язык", col_date: "Опубликовано", link_text: "ссылка", loading: "Загрузка листовок…", empty: "Листовки пока не найдены.", error: "Не удалось подключиться к реле.", back: "← Назад в VoxVera", gate_title: "Подключитесь, чтобы увидеть доску", gate_body: "Доска объявлений видна тем, кто подключил идентификацию Nostr. Выберите способ подключения ниже.", connect: "Подключить", connecting: "Подключение…", no_extension: "Расширение Nostr не найдено. Установите расширение браузера NIP-07, чтобы увидеть доску.", connect_failed: "Подключение отменено или не удалось. Повторите попытку.", disconnect: "Отключить", filter_following: "Показаны листовки от тех, на кого вы подписаны.", filter_seeded: "Вы пока ни на кого не подписаны — показываем подборку из сети доверия VoxVera.", filter_all: "Показаны все листовки на реле (без фильтра).", filter_unavailable: "Не удалось загрузить сеть доверия — показаны все листовки.", filter_empty: "Пока нет листовок из вашей сети доверия. Включите «Показать все», чтобы увидеть всё.", show_all_label: "Показать все листовки", connect_extension: "Расширение браузера (NIP-07)", connect_nsec: "Использовать приватный ключ (nsec)", connect_generate: "Создать новый ключ", key_note: "Ваш ключ остаётся в этом браузере и используется только для чтения вашего списка подписок — он никуда не отправляется.", invalid_nsec: "Это не похоже на действительный ключ nsec.", gen_warning: "Это ваш новый секретный ключ. Любой, у кого он есть, управляет этой личностью — сохраните его в надёжном месте, потому что VoxVera не сможет его восстановить.", copy: "Копировать", copied: "Скопировано", continue_label: "Продолжить" },
he: { title: "לוח מודעות", subtitle: "הכרזות האחרונות שפורסמו לממסרים.", col_title: "כותרת", col_link: "קישור", col_author: "פורסם על ידי", col_language: "שפה", col_date: "פורסם", link_text: "קישור", loading: "טוען כרזות…", empty: "לא נמצאו כרזות עדיין.", error: "לא ניתן להתחבר לממסרים.", back: "← חזרה ל-VoxVera", gate_title: "התחבר כדי לראות את הלוח", gate_body: "לוח המודעות גלוי למי שמחבר זהות Nostr. בחר למטה כיצד להתחבר.", connect: "התחבר", connecting: "מתחבר…", no_extension: "לא נמצא תוסף Nostr. התקן תוסף דפדפן NIP-07 כדי לראות את הלוח.", connect_failed: "ההתחברות בוטלה או נכשלה. נסה שוב.", disconnect: "התנתק", filter_following: "מציג כרזות מאנשים שאתה עוקב אחריהם.", filter_seeded: "אינך עוקב עדיין אחר אף אחד — מוצגת בחירה מרשת האמון של VoxVera.", filter_all: "מציג את כל הכרזות בממסרים (ללא סינון).", filter_unavailable: "לא ניתן לטעון רשת אמון — מוצגות כל הכרזות.", filter_empty: "אין עדיין כרזות מרשת האמון שלך. הפעל «הצג הכול» כדי לראות הכול.", show_all_label: "הצג את כל הכרזות", connect_extension: "תוסף דפדפן (NIP-07)", connect_nsec: "השתמש במפתח פרטי (nsec)", connect_generate: "צור מפתח חדש", key_note: "המפתח שלך נשאר בדפדפן זה ומשמש רק לקריאת רשימת העוקבים שלך — הוא לעולם לא נשלח לשום מקום.", invalid_nsec: "זה לא נראה כמו מפתח nsec תקין.", gen_warning: "זהו המפתח הסודי החדש שלך. כל מי שיש לו אותו שולט בזהות הזו — שמור אותו במקום בטוח, מכיוון ש-VoxVera אינה יכולה לשחזר אותו.", copy: "העתק", copied: "הועתק", continue_label: "המשך" },
ar: { title: "لوحة الإعلانات", subtitle: "أحدث الملصقات المنشورة على المرحلات.", col_title: "العنوان", col_link: "رابط", col_author: "نُشر بواسطة", col_language: "اللغة", col_date: "تاريخ النشر", link_text: "رابط", loading: "جارٍ تحميل الملصقات…", empty: "لم يتم العثور على ملصقات بعد.", error: "تعذّر الوصول إلى المرحلات.", back: "← العودة إلى VoxVera", gate_title: "اتصل لعرض اللوحة", gate_body: "لوحة الإعلانات مرئية لمن يربط هوية Nostr. اختر أدناه كيفية الاتصال.", connect: "اتصال", connecting: "جارٍ الاتصال…", no_extension: "لم يتم العثور على إضافة Nostr. ثبّت إضافة متصفح NIP-07 لعرض اللوحة.", connect_failed: "تم إلغاء الاتصال أو فشل. يرجى المحاولة مرة أخرى.", disconnect: "قطع الاتصال", filter_following: "عرض الملصقات من الأشخاص الذين تتابعهم.", filter_seeded: "أنت لا تتابع أحدًا بعد — نعرض مجموعة مختارة من شبكة الثقة في VoxVera.", filter_all: "عرض جميع الملصقات على المرحلات (دون تصفية).", filter_unavailable: "تعذّر تحميل شبكة ثقة — يتم عرض جميع الملصقات.", filter_empty: "لا توجد ملصقات من شبكة الثقة الخاصة بك بعد. فعّل «عرض الكل» لرؤية كل شيء.", show_all_label: "عرض جميع الملصقات", connect_extension: "إضافة المتصفح (NIP-07)", connect_nsec: "استخدام مفتاح خاص (nsec)", connect_generate: "إنشاء مفتاح جديد", key_note: "يبقى مفتاحك في هذا المتصفح ويُستخدم فقط لقراءة قائمة متابَعاتك — ولا يُرسل إلى أي مكان أبدًا.", invalid_nsec: "لا يبدو هذا مفتاح nsec صالحًا.", gen_warning: "هذا مفتاحك السري الجديد. أي شخص يملكه يتحكم في هذه الهوية — احفظه في مكان آمن، لأن VoxVera لا يمكنها استعادته.", copy: "نسخ", copied: "تم النسخ", continue_label: "متابعة" },
fa: { title: "تابلوی اعلانات", subtitle: "جدیدترین پوسترهای منتشرشده روی رله‌ها.", col_title: "عنوان", col_link: "پیوند", col_author: "منتشرشده توسط", col_language: "زبان", col_date: "تاریخ انتشار", link_text: "پیوند", loading: "در حال بارگذاری پوسترها…", empty: "هنوز پوستری یافت نشد.", error: "اتصال به رله‌ها ممکن نشد.", back: "← بازگشت به VoxVera", gate_title: "برای مشاهده تابلو متصل شوید", gate_body: "تابلوی اعلانات برای کسانی که هویت Nostr را متصل می‌کنند قابل مشاهده است. در زیر نحوه اتصال را انتخاب کنید.", connect: "اتصال", connecting: "در حال اتصال…", no_extension: "هیچ افزونه Nostr یافت نشد. برای مشاهده تابلو یک افزونه مرورگر NIP-07 نصب کنید.", connect_failed: "اتصال لغو شد یا ناموفق بود. لطفاً دوباره تلاش کنید.", disconnect: "قطع اتصال", filter_following: "نمایش پوسترها از افرادی که دنبال می‌کنید.", filter_seeded: "هنوز کسی را دنبال نمی‌کنید — مجموعه‌ای منتخب از شبکه اعتماد VoxVera نمایش داده می‌شود.", filter_all: "نمایش همه پوسترها روی رله‌ها (بدون فیلتر).", filter_unavailable: "بارگذاری شبکه اعتماد ممکن نشد — همه پوسترها نمایش داده می‌شوند.", filter_empty: "هنوز پوستری از شبکه اعتماد شما نیست. «نمایش همه» را روشن کنید تا همه را ببینید.", show_all_label: "نمایش همه پوسترها", connect_extension: "افزونه مرورگر (NIP-07)", connect_nsec: "استفاده از کلید خصوصی (nsec)", connect_generate: "ایجاد کلید جدید", key_note: "کلید شما در همین مرورگر می‌ماند و فقط برای خواندن فهرست دنبال‌شده‌های شما استفاده می‌شود — هرگز به جایی ارسال نمی‌شود.", invalid_nsec: "این یک کلید nsec معتبر به نظر نمی‌رسد.", gen_warning: "این کلید مخفی جدید شماست. هر کسی آن را داشته باشد این هویت را کنترل می‌کند — آن را جایی امن ذخیره کنید، زیرا VoxVera نمی‌تواند آن را بازیابی کند.", copy: "کپی", copied: "کپی شد", continue_label: "ادامه" },
hi: { title: "बुलेटिन बोर्ड", subtitle: "रिले पर प्रकाशित सबसे हाल के पोस्टर।", col_title: "शीर्षक", col_link: "लिंक", col_author: "द्वारा पोस्ट किया गया", col_language: "भाषा", col_date: "पोस्ट किया गया", link_text: "लिंक", loading: "पोस्टर लोड हो रहे हैं…", empty: "अभी तक कोई पोस्टर नहीं मिला।", error: "रिले तक नहीं पहुँच सके।", back: "← VoxVera पर वापस", gate_title: "बोर्ड देखने के लिए कनेक्ट करें", gate_body: "बुलेटिन बोर्ड उन लोगों को दिखता है जो Nostr पहचान कनेक्ट करते हैं। नीचे चुनें कि कैसे कनेक्ट करना है।", connect: "कनेक्ट करें", connecting: "कनेक्ट हो रहा है…", no_extension: "कोई Nostr एक्सटेंशन नहीं मिला। बोर्ड देखने के लिए NIP-07 ब्राउज़र एक्सटेंशन इंस्टॉल करें।", connect_failed: "कनेक्शन रद्द या विफल। कृपया पुनः प्रयास करें।", disconnect: "डिस्कनेक्ट करें", filter_following: "उन लोगों के पोस्टर दिखा रहे हैं जिन्हें आप फ़ॉलो करते हैं।", filter_seeded: "आप अभी किसी को फ़ॉलो नहीं करते — VoxVera के विश्वास नेटवर्क से चुनिंदा पोस्टर दिखा रहे हैं।", filter_all: "रिले पर सभी पोस्टर दिखा रहे हैं (बिना फ़िल्टर)।", filter_unavailable: "विश्वास नेटवर्क लोड नहीं हो सका — सभी पोस्टर दिखा रहे हैं।", filter_empty: "आपके विश्वास नेटवर्क से अभी कोई पोस्टर नहीं। सब कुछ देखने के लिए “सभी दिखाएँ” चालू करें।", show_all_label: "सभी पोस्टर दिखाएँ", connect_extension: "ब्राउज़र एक्सटेंशन (NIP-07)", connect_nsec: "निजी कुंजी (nsec) का उपयोग करें", connect_generate: "नई कुंजी बनाएँ", key_note: "आपकी कुंजी इसी ब्राउज़र में रहती है और केवल आपकी फ़ॉलो सूची पढ़ने के लिए उपयोग होती है — इसे कहीं नहीं भेजा जाता।", invalid_nsec: "यह एक मान्य nsec कुंजी नहीं लगती।", gen_warning: "यह आपकी नई गुप्त कुंजी है। जिसके पास यह होगी वह इस पहचान को नियंत्रित कर सकता है — इसे सुरक्षित जगह सहेजें, क्योंकि VoxVera इसे पुनर्प्राप्त नहीं कर सकता।", copy: "कॉपी करें", copied: "कॉपी किया गया", continue_label: "जारी रखें" },
ja: { title: "掲示板", subtitle: "リレーに公開された最新のちらし。", col_title: "タイトル", col_link: "リンク", col_author: "投稿者", col_language: "言語", col_date: "投稿日", link_text: "リンク", loading: "ちらしを読み込んでいます…", empty: "まだちらしが見つかりません。", error: "リレーに接続できませんでした。", back: "← VoxVera に戻る", gate_title: "掲示板を見るには接続してください", gate_body: "掲示板はNostr IDを接続した人に表示されます。下で接続方法を選んでください。", connect: "接続", connecting: "接続中…", no_extension: "Nostr拡張機能が見つかりません。掲示板を見るにはNIP-07ブラウザ拡張機能をインストールしてください。", connect_failed: "接続がキャンセルされたか失敗しました。もう一度お試しください。", disconnect: "切断", filter_following: "フォローしている人のちらしを表示しています。", filter_seeded: "まだ誰もフォローしていません。VoxVera の信頼ネットワークから厳選したものを表示しています。", filter_all: "リレー上のすべてのちらしを表示しています(フィルターなし)。", filter_unavailable: "信頼ネットワークを読み込めませんでした。すべてのちらしを表示しています。", filter_empty: "あなたの信頼ネットワークのちらしはまだありません。「すべて表示」をオンにするとすべて表示されます。", show_all_label: "すべてのちらしを表示", connect_extension: "ブラウザ拡張機能 (NIP-07)", connect_nsec: "秘密鍵 (nsec) を使う", connect_generate: "新しい鍵を作成", key_note: "鍵はこのブラウザ内に留まり、フォローリストの読み取りにのみ使用されます。どこにも送信されません。", invalid_nsec: "有効な nsec 鍵ではないようです。", gen_warning: "これはあなたの新しい秘密鍵です。これを持つ人は誰でもこの ID を操作できます。VoxVera は復元できないので、安全な場所に保存してください。", copy: "コピー", copied: "コピーしました", continue_label: "続行" },
pt: { title: "Quadro de avisos", subtitle: "Panfletos mais recentes publicados nos relés.", col_title: "Título", col_link: "Link", col_author: "Publicado por", col_language: "Idioma", col_date: "Publicado", link_text: "link", loading: "Carregando panfletos…", empty: "Nenhum panfleto encontrado ainda.", error: "Não foi possível acessar os relés.", back: "← Voltar ao VoxVera", gate_title: "Conecte-se para ver o quadro", gate_body: "O quadro de avisos é visível para quem conecta uma identidade Nostr. Escolha abaixo como se conectar.", connect: "Conectar", connecting: "Conectando…", no_extension: "Nenhuma extensão Nostr encontrada. Instale uma extensão de navegador NIP-07 para ver o quadro.", connect_failed: "Conexão cancelada ou falhou. Tente novamente.", disconnect: "Desconectar", filter_following: "Mostrando panfletos de pessoas que você segue.", filter_seeded: "Você ainda não segue ninguém — mostrando uma seleção da rede de confiança da VoxVera.", filter_all: "Mostrando todos os panfletos nos relés (sem filtro).", filter_unavailable: "Não foi possível carregar uma rede de confiança — mostrando todos os panfletos.", filter_empty: "Ainda não há panfletos da sua rede de confiança. Ative “Mostrar todos” para ver tudo.", show_all_label: "Mostrar todos os panfletos", connect_extension: "Extensão do navegador (NIP-07)", connect_nsec: "Usar uma chave privada (nsec)", connect_generate: "Criar uma nova chave", key_note: "Sua chave permanece neste navegador e é usada apenas para ler sua lista de seguidos — nunca é enviada para lugar algum.", invalid_nsec: "Isso não parece uma chave nsec válida.", gen_warning: "Esta é sua nova chave secreta. Qualquer pessoa que a tenha controla esta identidade — guarde-a em local seguro, pois o VoxVera não pode recuperá-la.", copy: "Copiar", copied: "Copiado", continue_label: "Continuar" },
sw: { title: "Ubao wa matangazo", subtitle: "Mabango ya hivi karibuni yaliyochapishwa kwenye relay.", col_title: "Kichwa", col_link: "Kiungo", col_author: "Imechapishwa na", col_language: "Lugha", col_date: "Imechapishwa", link_text: "kiungo", loading: "Inapakia mabango…", empty: "Hakuna mabango yaliyopatikana bado.", error: "Imeshindwa kufikia relay.", back: "← Rudi VoxVera", gate_title: "Unganisha ili kuona ubao", gate_body: "Ubao wa matangazo unaonekana kwa wale wanaounganisha kitambulisho cha Nostr. Chagua hapa chini jinsi ya kuunganisha.", connect: "Unganisha", connecting: "Inaunganisha…", no_extension: "Hakuna kiendelezi cha Nostr kilichopatikana. Sakinisha kiendelezi cha kivinjari cha NIP-07 ili kuona ubao.", connect_failed: "Muunganisho umeghairiwa au umeshindwa. Tafadhali jaribu tena.", disconnect: "Tenganisha", filter_following: "Inaonyesha mabango kutoka kwa watu unaowafuata.", filter_seeded: "Bado hufuati mtu yeyote — tunaonyesha mkusanyiko uliochaguliwa kutoka mtandao wa kuaminiana wa VoxVera.", filter_all: "Inaonyesha mabango yote kwenye relay (bila kuchuja).", filter_unavailable: "Imeshindwa kupakia mtandao wa kuaminiana — inaonyesha mabango yote.", filter_empty: "Bado hakuna mabango kutoka mtandao wako wa kuaminiana. Washa “Onyesha yote” kuona yote.", show_all_label: "Onyesha mabango yote", connect_extension: "Kiendelezi cha kivinjari (NIP-07)", connect_nsec: "Tumia ufunguo wa faragha (nsec)", connect_generate: "Tengeneza ufunguo mpya", key_note: "Ufunguo wako unabaki katika kivinjari hiki na hutumika tu kusoma orodha yako ya unaowafuata — haupelekwi popote.", invalid_nsec: "Huu hauonekani kama ufunguo halali wa nsec.", gen_warning: "Huu ndio ufunguo wako mpya wa siri. Yeyote aliye nao anadhibiti utambulisho huu — uhifadhi mahali salama, kwa sababu VoxVera haiwezi kuurejesha.", copy: "Nakili", copied: "Imenakiliwa", continue_label: "Endelea" },
tr: { title: "İlan panosu", subtitle: "Rölelere yayımlanan en yeni el ilanları.", col_title: "Başlık", col_link: "Bağlantı", col_author: "Yayımlayan", col_language: "Dil", col_date: "Yayımlandı", link_text: "bağlantı", loading: "El ilanları yükleniyor…", empty: "Henüz el ilanı bulunamadı.", error: "Rölelere ulaşılamadı.", back: "← VoxVera'ya dön", gate_title: "Panoyu görmek için bağlanın", gate_body: "İlan panosu, bir Nostr kimliği bağlayanlara görünür. Aşağıdan nasıl bağlanacağını seç.", connect: "Bağlan", connecting: "Bağlanıyor…", no_extension: "Nostr uzantısı bulunamadı. Panoyu görmek için bir NIP-07 tarayıcı uzantısı kurun.", connect_failed: "Bağlantı iptal edildi veya başarısız oldu. Lütfen tekrar deneyin.", disconnect: "Bağlantıyı kes", filter_following: "Takip ettiğin kişilerin el ilanları gösteriliyor.", filter_seeded: "Henüz kimseyi takip etmiyorsun — VoxVera'nın güven ağından seçilmiş bir küme gösteriliyor.", filter_all: "Rölelerdeki tüm el ilanları gösteriliyor (filtresiz).", filter_unavailable: "Güven ağı yüklenemedi — tüm el ilanları gösteriliyor.", filter_empty: "Güven ağından henüz el ilanı yok. Her şeyi görmek için “Tümünü göster”i aç.", show_all_label: "Tüm el ilanlarını göster", connect_extension: "Tarayıcı uzantısı (NIP-07)", connect_nsec: "Özel anahtar (nsec) kullan", connect_generate: "Yeni anahtar oluştur", key_note: "Anahtarın bu tarayıcıda kalır ve yalnızca takip listeni okumak için kullanılır — hiçbir yere gönderilmez.", invalid_nsec: "Bu geçerli bir nsec anahtarı gibi görünmüyor.", gen_warning: "Bu senin yeni gizli anahtarın. Ona sahip olan herkes bu kimliği kontrol eder — güvenli bir yerde sakla, çünkü VoxVera onu kurtaramaz.", copy: "Kopyala", copied: "Kopyalandı", continue_label: "Devam et" },
zh: { title: "公告栏", subtitle: "发布到中继的最新传单。", col_title: "标题", col_link: "链接", col_author: "发布者", col_language: "语言", col_date: "发布时间", link_text: "链接", loading: "正在加载传单…", empty: "尚未找到传单。", error: "无法连接到中继。", back: "← 返回 VoxVera", gate_title: "连接以查看公告栏", gate_body: "公告栏对连接了 Nostr 身份的人可见。请在下方选择连接方式。", connect: "连接", connecting: "正在连接…", no_extension: "未找到 Nostr 扩展。请安装 NIP-07 浏览器扩展以查看公告栏。", connect_failed: "连接已取消或失败。请重试。", disconnect: "断开连接", filter_following: "正在显示你关注的人的传单。", filter_seeded: "你还没有关注任何人——正在显示来自 VoxVera 信任网络的精选内容。", filter_all: "正在显示中继上的所有传单(未过滤)。", filter_unavailable: "无法加载信任网络——正在显示所有传单。", filter_empty: "你的信任网络暂时没有传单。开启“显示全部”以查看所有内容。", show_all_label: "显示所有传单", connect_extension: "浏览器扩展 (NIP-07)", connect_nsec: "使用私钥 (nsec)", connect_generate: "创建新密钥", key_note: "你的密钥保留在此浏览器中,仅用于读取你的关注列表——绝不会发送到任何地方。", invalid_nsec: "这看起来不是有效的 nsec 密钥。", gen_warning: "这是你的新私钥。拥有它的任何人都能控制此身份——请妥善保存,因为 VoxVera 无法恢复它。", copy: "复制", copied: "已复制", continue_label: "继续" }
};
function pickLang() {
@@ -98,10 +113,10 @@
return lt ? lt[1] : "";
}
// Fetch recent VoxVera flyer events across the default relays. Resolves with
// the collected raw events plus whether any relay connection opened (so we can
// tell "no flyers" apart from "relays unreachable").
function queryRelays(relays, timeoutMs) {
// Run a single REQ with the given filter across the default relays. Resolves
// with the collected raw events plus whether any relay connection opened (so
// we can tell "nothing found" apart from "relays unreachable").
function queryRelays(relays, filter, timeoutMs) {
return new Promise((resolve) => {
const sub = "voxvera-board-" + Math.random().toString(16).slice(2);
const events = new Map();
@@ -123,7 +138,7 @@
sockets.push(ws);
ws.onopen = () => {
anyOpen = true;
ws.send(JSON.stringify(["REQ", sub, { kinds: [EVENT_KIND], "#t": ["voxvera"], limit: 200 }]));
ws.send(JSON.stringify(["REQ", sub, filter]));
};
ws.onmessage = (message) => {
try {
@@ -161,6 +176,7 @@
title: String(payload.title || "").trim(),
link: String(payload.tear_off_link || payload.url || "").trim(),
npub: npub,
pubkey: String(e.pubkey || "").toLowerCase(),
lang: payload.lang || langFromTags(tags) || "",
created_at: e.created_at || 0
});
@@ -274,6 +290,21 @@
setText("board-title", t("title"));
setText("board-subtitle", t("subtitle"));
setText("board-back", t("back"));
// Login gate + identity chip (localized whether or not they are visible).
setText("board-gate-title", t("gate_title"));
setText("board-gate-body", t("gate_body"));
setText("board-connect", connecting ? t("connecting") : t("connect_extension"));
setText("board-connect-nsec-toggle", t("connect_nsec"));
setText("board-generate", t("connect_generate"));
setText("board-nsec-submit", t("connect"));
setText("board-nsec-note", t("key_note"));
setText("board-gen-warning", t("gen_warning"));
setText("board-gen-continue", t("continue_label"));
const copyBtn = document.getElementById("board-gen-copy");
if (copyBtn) copyBtn.textContent = t("copy");
setText("board-disconnect", t("disconnect"));
refreshGateError();
updateFilterControls();
const sel = document.getElementById("board-lang");
if (sel) sel.value = currentLang;
setStatus(statusKey);
@@ -281,25 +312,372 @@
renderBody();
}
async function init() {
populateLangSelect();
const sel = document.getElementById("board-lang");
if (sel) sel.addEventListener("change", () => applyLang(sel.value || FALLBACK_LANG));
applyLang(currentLang);
// ---- Login gate (NIP-07) ----------------------------------------------
// The board is shown only to a connected Nostr identity. This is the seam
// for a future web-of-trust filter; today it just establishes who is viewing.
let connectedPubkey = null;
let connectedNpub = null;
let connecting = false;
let gateErrorKey = "";
// ---- Web-of-trust filter (NIP-02) -------------------------------------
// The board defaults to flyers from authors in the viewer's trust set: the
// people they follow. A fresh key with no follow list is seeded from
// FALLBACK_CURATOR_PUBKEY's follows so a new viewer still gets a curated board
// rather than the unmoderated firehose. "Show all" opts out of the filter.
let allRows = []; // every parsed flyer, pre-filter
let trustSet = new Set(); // hex pubkeys whose flyers we show
let trustSource = "viewer"; // "viewer" | "seeded" | "unavailable"
let showAll = false;
let boardLoaded = false;
function nip07Available() {
return !!(window.nostr && typeof window.nostr.getPublicKey === "function");
}
function getStoredPubkey() {
try {
const stored = window.localStorage.getItem(NPUB_STORAGE_KEY);
return stored && /^[0-9a-f]{64}$/i.test(stored) ? stored.toLowerCase() : "";
} catch (_) {
return "";
}
}
function refreshGateError() {
const node = document.getElementById("board-gate-error");
if (!node) return;
if (gateErrorKey) {
node.hidden = false;
node.textContent = t(gateErrorKey);
} else {
node.hidden = true;
node.textContent = "";
}
}
function showGate() {
const gate = document.getElementById("board-gate");
const content = document.getElementById("board-content");
const identity = document.getElementById("board-identity");
if (gate) gate.hidden = false;
if (content) content.hidden = true;
if (identity) identity.hidden = true;
// Collapse the alternate connect methods and clear any pasted secret.
const nsecRow = document.getElementById("board-nsec-row");
if (nsecRow) nsecRow.hidden = true;
const nsecInput = document.getElementById("board-nsec-input");
if (nsecInput) nsecInput.value = "";
const gen = document.getElementById("board-generated");
if (gen) gen.hidden = true;
pendingGeneratedPubkey = null;
}
function showConnected() {
const gate = document.getElementById("board-gate");
const content = document.getElementById("board-content");
const identity = document.getElementById("board-identity");
if (gate) gate.hidden = true;
if (content) content.hidden = false;
if (identity) {
identity.hidden = false;
const npubNode = document.getElementById("board-identity-npub");
if (npubNode) {
npubNode.textContent = shortNpub(connectedNpub);
npubNode.title = connectedNpub || "";
}
}
}
function setConnecting(isConnecting) {
connecting = isConnecting;
const btn = document.getElementById("board-connect");
if (btn) {
btn.disabled = isConnecting;
btn.textContent = isConnecting ? t("connecting") : t("connect_extension");
}
}
// Core: adopt a hex pubkey as the viewer identity, persist it (the board only
// ever needs the pubkey — it reads, never signs), and reveal the board.
function connectWithPubkey(pubkeyHex) {
if (!/^[0-9a-f]{64}$/i.test(String(pubkeyHex || ""))) {
gateErrorKey = "connect_failed";
refreshGateError();
return;
}
connectedPubkey = String(pubkeyHex).toLowerCase();
try {
connectedNpub = window.NostrTools.nip19.npubEncode(connectedPubkey);
} catch (_) {
connectedNpub = connectedPubkey;
}
try { window.localStorage.setItem(NPUB_STORAGE_KEY, connectedPubkey); } catch (_) {}
gateErrorKey = "";
refreshGateError();
showConnected();
loadBoard();
}
// Method 1: NIP-07 browser extension.
async function connectExtension() {
gateErrorKey = "";
refreshGateError();
if (!nip07Available()) {
gateErrorKey = "no_extension";
refreshGateError();
return;
}
setConnecting(true);
try {
const pubkey = await window.nostr.getPublicKey();
setConnecting(false);
if (!/^[0-9a-f]{64}$/i.test(String(pubkey || ""))) throw new Error("invalid pubkey");
connectWithPubkey(pubkey);
} catch (_) {
setConnecting(false);
gateErrorKey = "connect_failed";
refreshGateError();
}
}
// Method 2: paste an nsec. Decode in-page to derive the pubkey, then discard
// the secret — it is never stored or transmitted (only the pubkey persists).
function connectNsec() {
gateErrorKey = "";
refreshGateError();
const input = document.getElementById("board-nsec-input");
const raw = String((input && input.value) || "").trim();
let pubkey = "";
try {
const decoded = window.NostrTools.nip19.decode(raw);
if (decoded.type !== "nsec") throw new Error("not an nsec");
pubkey = window.NostrTools.getPublicKey(decoded.data);
} catch (_) {
gateErrorKey = "invalid_nsec";
refreshGateError();
return;
}
if (input) input.value = ""; // don't leave the secret sitting in the DOM
connectWithPubkey(pubkey);
}
function hexToBytes(hex) {
const out = new Uint8Array(32);
for (let i = 0; i < 32; i += 1) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
// Reuse this device's anon secret key if present, otherwise generate and store
// one (shared with the editor). Returns { pubkey, npub, nsec } for display.
function ensureDeviceKey() {
const tools = window.NostrTools;
let hex = "";
try {
const s = window.localStorage.getItem(ANON_SECRET_STORAGE_KEY);
if (s && /^[0-9a-f]{64}$/i.test(s)) hex = s.toLowerCase();
} catch (_) {}
if (!hex) {
hex = Array.from(tools.generateSecretKey(), (b) => b.toString(16).padStart(2, "0")).join("");
try { window.localStorage.setItem(ANON_SECRET_STORAGE_KEY, hex); } catch (_) {}
}
const bytes = hexToBytes(hex);
const pubkey = tools.getPublicKey(bytes);
return { pubkey, npub: tools.nip19.npubEncode(pubkey), nsec: tools.nip19.nsecEncode(bytes) };
}
// Method 3: create (or reuse) a device key and reveal it so the user can save
// the secret before continuing into the board.
let pendingGeneratedPubkey = null;
function showGenerated() {
gateErrorKey = "";
refreshGateError();
let key;
try { key = ensureDeviceKey(); } catch (_) { gateErrorKey = "connect_failed"; refreshGateError(); return; }
pendingGeneratedPubkey = key.pubkey;
const npubEl = document.getElementById("board-gen-npub");
const nsecEl = document.getElementById("board-gen-nsec");
if (npubEl) npubEl.value = key.npub;
if (nsecEl) nsecEl.value = key.nsec;
const nsecRow = document.getElementById("board-nsec-row");
if (nsecRow) nsecRow.hidden = true;
const gen = document.getElementById("board-generated");
if (gen) gen.hidden = false;
}
function continueGenerated() {
if (pendingGeneratedPubkey) connectWithPubkey(pendingGeneratedPubkey);
}
function toggleNsecRow() {
gateErrorKey = "";
refreshGateError();
const gen = document.getElementById("board-generated");
if (gen) gen.hidden = true;
const row = document.getElementById("board-nsec-row");
if (!row) return;
row.hidden = !row.hidden;
if (!row.hidden) {
const input = document.getElementById("board-nsec-input");
if (input) input.focus();
}
}
function disconnect() {
connectedPubkey = null;
connectedNpub = null;
try { window.localStorage.removeItem(NPUB_STORAGE_KEY); } catch (_) {}
gateErrorKey = "";
// Reset board state so a later reconnect rebuilds the trust filter fresh.
allRows = [];
rows = [];
trustSet = new Set();
trustSource = "viewer";
showAll = false;
boardLoaded = false;
showGate();
refreshGateError();
}
// Fetch the latest NIP-02 contact list (kind 3) for a pubkey and return the
// hex pubkeys it follows (its `p` tags). Empty if there is no list.
async function fetchFollows(pubkey) {
if (!/^[0-9a-f]{64}$/i.test(String(pubkey || ""))) return [];
const { events } = await queryRelays(
DEFAULT_RELAYS, { kinds: [3], authors: [pubkey], limit: 5 }, 5000
);
let latest = null;
events.forEach((e) => {
if (e && e.kind === 3 && (!latest || (e.created_at || 0) > (latest.created_at || 0))) latest = e;
});
if (!latest) return [];
return (latest.tags || [])
.filter((x) => x[0] === "p" && /^[0-9a-f]{64}$/i.test(String(x[1] || "")))
.map((x) => x[1].toLowerCase());
}
// Build the viewer's trust set: their own follows if they have any, otherwise
// seed from the curator's follows. The viewer always trusts themselves.
async function buildTrustSet(viewerPubkey) {
const viewerFollows = await fetchFollows(viewerPubkey);
let follows;
if (viewerFollows.length) {
follows = viewerFollows;
trustSource = "viewer";
} else {
const curatorFollows = await fetchFollows(FALLBACK_CURATOR_PUBKEY);
if (curatorFollows.length) {
follows = curatorFollows.concat(FALLBACK_CURATOR_PUBKEY);
trustSource = "seeded";
} else {
follows = [];
trustSource = "unavailable";
}
}
const set = new Set(follows.map((x) => x.toLowerCase()));
if (viewerPubkey) set.add(String(viewerPubkey).toLowerCase());
return set;
}
function updateFilterControls() {
const note = document.getElementById("board-filter-note");
if (note && boardLoaded) {
let key;
if (showAll) key = trustSource === "unavailable" ? "filter_unavailable" : "filter_all";
else key = trustSource === "viewer" ? "filter_following" : "filter_seeded";
note.textContent = t(key);
}
const chk = document.getElementById("board-show-all");
if (chk) chk.checked = showAll;
const lbl = document.getElementById("board-show-all-label");
if (lbl) lbl.textContent = t("show_all_label");
}
// Apply the trust filter to allRows, manage the empty/filtered status, and
// re-render. Assumes allRows is non-empty (the no-flyers case is handled in
// loadBoard before this runs).
function applyFilter() {
rows = showAll ? allRows.slice() : allRows.filter((r) => trustSet.has(r.pubkey));
setStatus(rows.length || showAll ? null : "filter_empty");
updateFilterControls();
renderBody();
}
async function loadBoard() {
if (!window.NostrTools) {
setStatus("error");
return;
}
const { events, anyOpen } = await queryRelays(DEFAULT_RELAYS, 6000);
rows = parseFlyers(events);
if (!rows.length) {
setStatus(anyOpen ? "empty" : "error");
setStatus("loading");
const [flyerRes, trust] = await Promise.all([
queryRelays(DEFAULT_RELAYS, { kinds: [EVENT_KIND], "#t": ["voxvera"], limit: 200 }, 6000),
buildTrustSet(connectedPubkey)
]);
allRows = parseFlyers(flyerRes.events);
trustSet = trust;
// With no trust data at all, filtering would hide everything — show all.
showAll = trustSource === "unavailable";
boardLoaded = true;
if (!allRows.length) {
rows = [];
setStatus(flyerRes.anyOpen ? "empty" : "error");
updateFilterControls();
renderBody();
return;
}
setStatus(null);
renderBody();
applyFilter();
}
async function init() {
populateLangSelect();
const sel = document.getElementById("board-lang");
if (sel) sel.addEventListener("change", () => applyLang(sel.value || FALLBACK_LANG));
const connectBtn = document.getElementById("board-connect");
if (connectBtn) connectBtn.addEventListener("click", connectExtension);
const nsecToggle = document.getElementById("board-connect-nsec-toggle");
if (nsecToggle) nsecToggle.addEventListener("click", toggleNsecRow);
const nsecSubmit = document.getElementById("board-nsec-submit");
if (nsecSubmit) nsecSubmit.addEventListener("click", connectNsec);
const nsecInput = document.getElementById("board-nsec-input");
if (nsecInput) nsecInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); connectNsec(); }
});
const generateBtn = document.getElementById("board-generate");
if (generateBtn) generateBtn.addEventListener("click", showGenerated);
const genContinue = document.getElementById("board-gen-continue");
if (genContinue) genContinue.addEventListener("click", continueGenerated);
const genCopy = document.getElementById("board-gen-copy");
if (genCopy) genCopy.addEventListener("click", async () => {
const nsecEl = document.getElementById("board-gen-nsec");
if (!nsecEl) return;
try {
await navigator.clipboard.writeText(nsecEl.value);
genCopy.textContent = t("copied");
setTimeout(() => { genCopy.textContent = t("copy"); }, 1500);
} catch (_) {
nsecEl.type = "text";
nsecEl.select();
}
});
const disconnectBtn = document.getElementById("board-disconnect");
if (disconnectBtn) disconnectBtn.addEventListener("click", disconnect);
const showAllChk = document.getElementById("board-show-all");
if (showAllChk) showAllChk.addEventListener("change", () => {
showAll = showAllChk.checked;
if (allRows.length) applyFilter();
else updateFilterControls();
});
applyLang(currentLang);
showGate();
// The board only needs the viewer's pubkey, so any prior connection (any
// method) is restored directly from the remembered pubkey — no prompt, no
// re-deriving from a secret. The visitor picks a method otherwise.
const storedPubkey = getStoredPubkey();
if (storedPubkey) {
connectWithPubkey(storedPubkey);
}
}
document.addEventListener("DOMContentLoaded", init);
+165
View File
@@ -1055,6 +1055,171 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
color: var(--accent);
}
/* ---- Bulletin board login gate (NIP-07) ---- */
.board-gate {
max-width: 520px;
margin: 48px auto;
text-align: center;
}
.board-gate h2 {
margin: 0 0 8px;
font-size: 22px;
}
.board-gate p {
color: var(--muted);
margin: 0 0 20px;
line-height: 1.4;
}
.board-connect {
font: inherit;
font-weight: 700;
padding: 10px 24px;
border: none;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.board-connect:disabled {
opacity: 0.6;
cursor: default;
}
.board-gate-error {
color: #8a4d00;
margin: 16px 0 0;
}
.board-connect-methods {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.board-connect-alt {
font: inherit;
font-size: 14px;
padding: 8px 18px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--accent);
cursor: pointer;
}
.board-nsec-row,
.board-generated {
max-width: 420px;
margin: 18px auto 0;
text-align: left;
}
.board-nsec-row input,
.board-key-field input {
width: 100%;
box-sizing: border-box;
font: inherit;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 6px;
}
.board-nsec-row .board-connect {
margin-top: 8px;
}
.board-key-note {
color: var(--muted);
font-size: 12px;
margin: 8px 0 0;
}
.board-key-warning {
color: #8a4d00;
font-size: 13px;
margin: 0 0 12px;
line-height: 1.4;
}
.board-key-field {
display: block;
font-size: 12px;
color: var(--muted);
margin: 0 0 10px;
}
.board-key-field input {
margin-top: 4px;
font-family: "Courier New", monospace;
}
.board-key-secret {
display: flex;
gap: 6px;
align-items: stretch;
}
.board-key-secret input {
flex: 1;
}
.board-identity {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* The `hidden` attribute must win over the display rule above. */
.board-identity[hidden] {
display: none;
}
.board-identity .board-npub {
font-family: "Courier New", monospace;
font-size: 12px;
color: var(--muted);
}
.board-disconnect {
font: inherit;
font-size: 13px;
padding: 4px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--accent);
cursor: pointer;
}
/* Web-of-trust filter bar above the board table. */
.board-filter {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin: 4px 0 12px;
}
.board-filter-note {
color: var(--muted);
font-size: 13px;
}
.board-show-all {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
/* ---- Publish confirmation modal ---- */
.modal-overlay {
position: fixed;