Compare commits

..
14 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 0ae69a2774 Board: show only real VoxVera flyers, not foreign kind-30078 events
kind 30078 is a shared "application data" kind, so other apps (e.g.
artstr.studio) publish under it too — and relays may ignore our #t
filter, or an unrelated note may carry a "voxvera" hashtag. The board
only checked kind + t-tag, never the payload, so foreign events could
appear.

parseFlyers now also requires VoxVera's own markers: a `voxvera:` d-tag
and a `type: "voxvera_flyer"` content payload (both real flyers and
tombstones have these), filtering foreign events out client-side
regardless of relay behavior.

Test: a foreign kind-30078 event carrying a voxvera t-tag is excluded
while a real flyer shows. Full suite 384 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 22:37:57 -04:00
thePR0M3TH3ANandClaude Opus 4.8 6fe9069f82 Add Open Graph preview image (1200x630 card) site-wide
Shared voxvera.org links now render a real image card, not just a text
summary. Adds site/og-card.jpg (1200x630, ~90KB, derived from the
"say something… voxvera.org" custom art) and wires og:image +
twitter:image + image dimensions/alt on the home, board, and safety
pages, bumping twitter:card to summary_large_image.

Per-flyer previews remain out of scope (fragment URLs are invisible to
servers; this stays a purely static site). Test asserts all three pages
declare the og:image and that the asset is served as JPEG. Full suite
378 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 22:27:03 -04:00
thePR0M3TH3ANandClaude Opus 4.8 18fa382632 Add NIP-46 nostrconnect (QR) login path; move board safety link to footer
NIP-46 now supports both connect directions on the editor and the board:
- bunker:// (existing): paste a link the signer app generated.
- nostrconnect:// (new): the client generates the link, renders it as a
  QR (+ a copyable link for one-device use), listens on a rendezvous
  relay (relay.nsec.app), and the signer app scans it and connects back —
  proven by echoing the client's random secret. The discovered signer
  pubkey then drives the durable connection (shared adopt path).

Both paths stay session-only with an ephemeral local key; the QR uses
the same hand-rolled NIP-44 client (NIP-04 decrypt fallback). qrcode-
generator is now loaded on the board too. 3 new localized strings
(scan / waiting / copy-link) across all 14 languages in NOSTR_UI and
BOARD_UI; i18n parity passes.

Also per feedback: the board's "Safety & privacy" link moves out of the
topbar (where it ran together with "Back to VoxVera") into a centered
footer link matching the home page, and both pages now link /safety.html
(works on the static host and Vercel alike).

Tests: editor and board each connect via a fake signer that "scans" the
nostrconnect QR (reads the on-screen URI, echoes the secret) — all 6
engines. Full suite 372 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 21:49:37 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b38dea1294 Add NIP-46 remote signer to the board login gate
The board's connect gate now offers "Remote signer (NIP-46)" alongside
NIP-07 / nsec / create-key. Pasting a bunker:// link establishes the
viewer's pubkey (for the web-of-trust filter and display) and keeps the
connection alive so getBoardSigner can sign reports and deletions
remotely this session — its whole point being that the secret never
leaves the signer app. After a reload the board reconnects read-only from
the remembered pubkey (session-only, like the editor).

The minimal NIP-46 client (parse bunker URI, NIP-44 codec with NIP-04
decrypt fallback, raw-WebSocket request/response) is ported into board.js,
which is an independent IIFE. Reuses the editor's translations for the 3
new BOARD_UI strings across all 14 languages (i18n parity passes).

Test: connect the board via a fake bunker signer doing real NIP-44
crypto, confirm it reveals as that identity, and that a report is signed
through the remote signer (kind-1984 with the signer's pubkey). All 6
engines. Full suite 360 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 15:04:12 -04:00
thePR0M3TH3ANandClaude Opus 4.8 29ed45533d Add localized Safety & privacy page + static link-preview meta
New standalone /safety page (safety.html / safety.js), localized across
all 14 languages, in plain language: what VoxVera protects, what it does
NOT (relays see your IP; the anonymous key is stored unencrypted; a short
PIN won't stop a targeted attacker; published events are public and
permanent; nsec-paste risk; reports are public; the host/relays still see
requests), and operational tips (Tor/VPN, disposable keys, signer apps).
Linked from the editor and the board. Aimed at the activist audience the
aesthetic invites — being honest about limits matters more than features.

Also adds static site-wide Open Graph / Twitter-card meta to the home,
board, and safety pages so a shared voxvera.org link renders a real
title/description instead of nothing. Per-flyer previews remain a
deliberate non-goal: the naddr lives in the URL fragment (invisible to
servers) and per-flyer meta would require server-side rendering, which is
out of scope for a purely static site.

i18n guard extended to SAFETY_UI (keys + equal bullet-list lengths per
language). New cross-engine test covers the page's sections, localization,
RTL, and the editor/board links. Full suite 354 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 14:53:48 -04:00
thePR0M3TH3ANandClaude Opus 4.8 53374ec87b Add NIP-56 reporting to the bulletin board
Others' flyers now offer a "Report" action (in the ⋯ menu, alongside
Block) that publishes a signed kind-1984 report: a category picker
(spam / illegal / impersonation / other → NIP-56 report types) plus an
optional free-text reason, tagged with the flyer (e) and author (p) so
other clients and moderators can aggregate it. Reporting also hides the
flyer locally, pairing with the existing per-device blocklist. Requires a
usable signer (device key or NIP-07); pasted-nsec viewers get a clear
"connect to report" message, same as delete.

Adds 11 localized strings across all 14 languages (i18n parity passes).
Test: report publishes a kind-1984 event with the right p/e tags + type +
reason and hides the row, across all 6 engines. Full suite 342 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 14:05:12 -04:00
thePR0M3TH3ANandClaude Opus 4.8 b344f92be7 Import/export a flyer design as a portable JSON file
Replaces the raw "Export event JSON" button with Import design / Export
design in the editor. A design file is content + relays only —
deliberately identity-free (no npub, naddr, event id, or signature, all
of which are derived at publish), so it can be handed to anyone and
published under their own key.

Format: { type: "voxvera_flyer_design", version: 1, flyer: <payload>,
relays: [] }. Export serializes the current form as-is (draft-friendly).
Import reads the picked file (capped at 256 KB), accepts the wrapper
format or a bare voxvera_flyer payload, then shows a confirm modal before
overwriting the editor; on confirm it fills the form and restores relays.
Imported content stays untrusted: the renderer escapes every field and
strict validation still runs at publish.

Adds 8 localized strings across all 14 languages (i18n parity passes).
Tests: export round-trip (identity-free assertions), import-then-confirm,
cancel-leaves-unchanged, and invalid-file rejection, across all 6
engines. Full suite 336 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 13:42:43 -04:00
thePR0M3TH3ANandClaude Opus 4.8 a4cb8dffcb Add NIP-46 remote signer (bunker) as an editor identity
The editor can now sign flyers via a NIP-46 remote signer: paste a
bunker:// link and the user's secret stays in their signer app
(nsec.app, Amber, …) while the browser holds only an ephemeral local
key. The vendored nostr-tools bundle has no nip46 module, so the client
is hand-rolled over a raw WebSocket — kind-24133 JSON-RPC encrypted with
NIP-44 (with a NIP-04 decrypt fallback), running connect →
get_public_key → sign_event. Connections are session-only; the bunker
token is never written to disk, so a reload returns to anonymous.

Switching to/from any other identity mode disconnects the signer, and a
successful connection mirrors the user pubkey to the board's connected
key. Adds 9 localized strings across all 14 languages (i18n parity test
passes).

Tested with a fake relay/signer doing real NIP-44 crypto: full connect →
get_public_key → sign_event → publish round-trip, across all 6 engines.
Full suite 312 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 13:17:17 -04:00
thePR0M3TH3ANandClaude Opus 4.8 bf90f1608b Degree-2 web-of-trust + editor↔board identity sync
Board trust filter now also includes follows-of-follows (degree 2): on
connect it fetches the viewer's NIP-02 follows, then the kind-3 lists of
those follows (capped at 150 authors), and trusts the union. Brand-new
keys still fall back to the curator seed; no-data still falls back to
show-all.

A deliberate editor identity choice (NIP-07 / imported / generated /
unlocked nsec / anonymous) now writes a shared local pubkey that the
board reads, so the same person sees a consistent identity across pages.

Tests: degree-2 inclusion (A direct, B friend-of-friend shown; stranger
C hidden) and editor→board key sync, across all 6 engines. Full suite
306 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 10:36:30 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c97624a5e0 Board "My flyers" filter + author display names; editor wrong-PIN lockout
Three quick-ish wins:

- "My flyers" board filter: a checkbox that shows only the connected user's own
  flyers (overrides the trust filter, since your own may not be in your follow
  graph). New mine_only string × 14.
- Author display names: the board fetches authors' kind-0 metadata and shows
  the display name (falling back to the npub) instead of a bare key. Names are
  untrusted — escaped at render and length-capped. (NIP-05 verification is still
  future.)
- Wrong-PIN lockout: after 3 bad PINs the editor refuses to unlock a remembered
  nsec for a 30s cooldown, persisted across reloads so it isn't reset by a
  refresh. A deterrent against casual on-device guessing (a leaked blob is still
  offline-brute-forceable). New too_many_attempts string × 14.

Adds e2e for each; the i18n guard enforces the new strings in all 14 languages.
294 e2e across all six engines; roadmap updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 10:20:16 -04:00
thePR0M3TH3ANandClaude Opus 4.8 78ee5bf0cf Add i18n key-parity guard + publish "N/M relays accepted"; fix missing translation
Two quick, high-value wins:

- i18n guard (e2e/i18n.spec.js): a pure-Node test that fails if any NOSTR_UI /
  BOARD_UI / locales dictionary is missing or has an extra key in some language.
  It immediately caught field_too_long, which was English-only — so non-English
  users saw the English "that field is too long" message. Added the missing
  translation to all 13 other languages.

- Publish transparency: the confirmation modal now shows "Relays accepted: N / M"
  (relays_accepted string × 14) and flags a zero-acceptance publish in red, so a
  weak publish is visible immediately instead of discovered later.

276 e2e across all six engines; roadmap updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 10:04:18 -04:00
thePR0M3TH3ANandClaude Opus 4.8 d160542f7d Default flyer: swap title/headline so the side tabs carry the project name
The tear-off tabs are labeled with the flyer's title, so swap the default
landing content's title and headline in every language: the title is now
"OPERATION VOX VERA" (the project name, shown on all ten tabs and as the top
line) and the headline is the dramatic "TOP SECRET" below it. Applied across all
14 languages in locales.js (e.g. de title "OPERATION VOX VERA" / headline
"STRENG GEHEIM").

Updated the e2e assertions (default title is now the project name; the German-
distinctive string is now the headline) and the spec payload example. 258 e2e
across all six engines.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 09:30:28 -04:00
thePR0M3TH3ANandClaude Opus 4.8 90e1bc8873 Mobile: flyer fits width, board collapses to cards, fix always-shown identity box
Three mobile/responsive fixes (diagnosed at phone widths):

- Flyer no longer side-scrolls/clips on phones. In viewer mode .flyer-preview is
  a flexbox, so the sheet (a flex item) was shrunk to the viewport, making the
  fit math compute scale = 1 and the full-width content clip. Add flex: 0 0 auto
  so the sheet keeps its true print width and the transform scales it to fully
  fit the screen.

- Bulletin board collapses to a card per flyer on ≤640px: title + the ⋯ menu
  stay visible; the secondary columns (link, posted-by, language, date, event
  id) move behind a per-row "Details" disclosure (labeled via data-label). New
  details_label string × 14 languages.

- Fix a real (not mobile-only) bug: the editor's nsec-import box and
  "locked identity" row showed on every load because .identity-import's
  display:flex overrode the hidden attribute. Add .identity-import[hidden]
  { display:none } (same class of bug we hit on the board identity chip).

Adds e2e: flyer fits width with no horizontal overflow, identity controls hidden
until chosen, and the board's narrow-screen card + Details toggle. The desktop
"sortable columns" test pins a desktop viewport (the header is hidden in card
mode). 258 e2e across all six engines; docs updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 09:20:33 -04:00
thePR0M3TH3ANandClaude Opus 4.8 053ea875b8 Tear-off tabs: title label, clickable "open & reprint" link, VoxVera.org event ID
Each of the ten tear-off tabs now reads, top to bottom:

- the flyer's TITLE, auto-sized (fitTabTitles) to be as large as fits the ~1in
  tab — big for short titles, shrinking instead of overflowing for long ones —
  so every tab is clearly labeled;
- a smaller, clickable "Open & reprint this flyer" link (the <a href>, so it
  stays live in a printed PDF) in place of the old "Click to open this poster."
  / "Share and reprint." (clicking, not "scan," is what a PDF link does);
- the tear-off QR; and
- a "Reopen at VoxVera.org — Event ID:" line (was just "Event ID:") so people
  know the id is for re-finding/reprinting the flyer at the site.

New tab_link / tab_event_id strings in all 14 languages (VoxVera.org stays the
Latin brand; the rest localizes). e2e covers the title, the link text + poster
href, and the event-id label. 240 e2e across all six engines.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 08:29:13 -04:00
14 changed files with 3246 additions and 105 deletions

No files matched your search

+6 -3
View File
@@ -9,18 +9,21 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch.
## Features
- **Author and publish.** Fill in the flyer fields and publish them as a Nostr `30078` parameterized replaceable event. Republishing with the same flyer name replaces the previous version.
- **Anonymous by default, your identity if you want it.** The editor signs flyers under one of three identities: an **anonymous** per-device key (default, no setup), a **NIP-07 browser extension** (your secret never touches the page), or an **imported `nsec`**. An imported key signs in-memory for the session; tick "Remember on this device" to keep it, encrypted with a PIN (PBKDF2 → AES-GCM) — the plaintext secret is never stored. A short PIN is a convenience lock, not protection against a targeted attacker (see the [spec](docs/nostr-static-client-spec.md#publishing-identity)); the extension path is stronger.
- **Anonymous by default, your identity if you want it.** The editor signs flyers under one of four identities: an **anonymous** per-device key (default, no setup), a **NIP-07 browser extension** (your secret never touches the page), an **imported `nsec`**, or a **NIP-46 remote signer** (paste a `bunker://` link or scan a `nostrconnect://` QR with your signer app — your secret stays in the app, the browser holds only an ephemeral key; session-only). An imported key signs in-memory for the session; tick "Remember on this device" to keep it, encrypted with a PIN (PBKDF2 → AES-GCM) — the plaintext secret is never stored. A short PIN is a convenience lock, not protection against a targeted attacker (see the [spec](docs/nostr-static-client-spec.md#publishing-identity)); the extension and remote-signer paths are stronger.
- **Publish confirmation.** After signing and publishing, a modal shows the new flyer's URL to copy, so authors know they're done and where to find it (localized, like everything else).
- **Edit, re-publish, and delete your flyers.** Because flyers are replaceable events, the author can edit one in place (same key + Flyer Name keeps the same URL), re-publish it as a keep-alive so relays don't age it out, or delete it. Delete publishes a replaceable **tombstone** (so VoxVera hides it even where relays ignore deletions) **and** a NIP-09 deletion request — best-effort, author-only.
- **Import / export a flyer design.** Save the current flyer as a portable `.json` file and load it back later — or hand it to someone else. The design file holds the content and relay list but **nothing identity-bound** (no npub, address, or signature; those are generated at publish), so a recipient can import it and publish under their own key. Importing asks for confirmation before replacing what's in the editor.
- **Open from a link or ID.** Load a flyer from an `naddr`, `nevent1`, `note1`, raw 64-char event ID, or `nostr:` URL. The lookup field tolerates copy/paste whitespace and newlines, so an event ID copied off a printed tear-off (where it wraps across two lines) still resolves.
- **Short, scannable poster URLs.** Each poster URL is a standard NIP-19 `naddr` carrying a single relay hint, placed in the URL `#fragment` (no `?addr=` query). Shorter URLs make the tear-off QR code easier to scan. Older relay-list `naddr`s and `?addr=` URLs still resolve.
- **Tear-off tabs.** The printable flyer has ten tear-off tabs. Each tab is a single hyperlinked call to action (the URL is the link, not printed as text) and prints the full event ID so anyone can re-find the flyer by typing it.
- **Tear-off tabs.** The printable flyer has ten tear-off tabs. Each tab is labeled with the flyer's **title** (auto-sized to be as large as fits), a clickable **"Open & reprint this flyer"** link (live in the printed PDF), a QR code, and a **"Reopen at VoxVera.org — Event ID:"** line with the full event ID so anyone can re-find and reprint the flyer by typing it in. Localized in every language.
- **Loading state on shared links.** Opening a poster/event link shows a localized "Loading content…" placeholder (and a "Could not load this flyer." state on failure) instead of the default flyer; the default sample content appears only on the bare site URL.
- **14 languages with RTL support.** Flyer content and UI are localized; right-to-left scripts render correctly. Letter-spacing is applied per script so cursive (Arabic/Persian) and combining (Devanagari) scripts are not broken — see [the spec](docs/nostr-static-client-spec.md#letter-spacing-by-script).
- **US Letter and A4.** Print size defaults to the right paper for the visitor's country (from the browser locale's region — `en-US` → Letter, `en-GB` → A4, no geolocation), with a manual override. Paper size is a local print preference and is never written into the event. See the [spec](docs/nostr-static-client-spec.md#print-paper-size).
- **Fits-the-page editing.** Fields that would overflow the printable sheet are rejected as you type, with an inline error under the affected field. See the [field length cheat sheet](docs/flyer-field-limits.md) for how much text fits each field per language and paper size.
- **QR codes.** The tear-off QR points at the poster URL (re-open and reprint); the main flyer QR points at the creator's content URL.
- **Bulletin board (members-only, trust-filtered).** A small screen-only link below the flyer preview (outside the printed sheet, never printed) opens a spreadsheet-style page of the most recent flyers found on the relays — title, link, posting npub, language, date, and event ID (with a copy button) — sortable by any column. Viewing the board requires connecting a Nostr identity — via a NIP-07 browser extension, by pasting a private key (`nsec`, decoded in-page and never stored), or by creating a new key (revealed so you can save it). The board then shows only flyers from authors in your **web of trust** (your NIP-02 follow graph), with a "Show all" opt-out. A brand-new key with no follows is seeded from a curated trust anchor so the board is never an unmoderated firehose. On your own flyers the board offers inline **Edit / Re-broadcast / Delete**; on others' it offers a local **Block** (a per-device hide list). Connecting is not required to author or print flyers, which stay fully anonymous. See the [roadmap](docs/roadmap.md) for the phased trust model.
- **Bulletin board (members-only, trust-filtered).** A small screen-only link below the flyer preview (outside the printed sheet, never printed) opens a spreadsheet-style page of the most recent flyers found on the relays — title, link, posting npub, language, date, and event ID (with a copy button) — sortable by any column. Viewing the board requires connecting a Nostr identity — via a NIP-07 browser extension, by pasting a private key (`nsec`, decoded in-page and never stored), or by creating a new key (revealed so you can save it). The board then shows only flyers from authors in your **web of trust** (your NIP-02 follow graph), with a "Show all" opt-out. A brand-new key with no follows is seeded from a curated trust anchor so the board is never an unmoderated firehose. On your own flyers the board offers inline **Edit / Re-broadcast / Delete**; on others' it offers **Report** (a public, signed NIP-56 report with a category and optional reason, which also hides the flyer for you) and a local **Block** (a per-device hide list). Connecting is not required to author or print flyers, which stay fully anonymous. See the [roadmap](docs/roadmap.md) for the phased trust model.
- **Mobile-friendly.** The flyer preview shrinks to fully fit the screen width (no side-scrolling), the editor and viewer are single-column with large tap targets, and the bulletin board collapses to a card per flyer with a per-row **Details** toggle on narrow screens.
- **Honest safety page.** A localized **[Safety & privacy](https://voxvera.org/safety)** page (linked from the editor and board) explains in plain language what VoxVera protects and — just as importantly — what it does *not*: relays see your IP when you publish or browse, the anonymous key is stored unencrypted in your browser, a short PIN won't stop a targeted attacker, and published events are public and effectively permanent. Plus operational tips (Tor/VPN, disposable keys, signer apps). No overpromising to the people most likely to rely on it.
- **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`.
## What Is In `main`
+77 -16
View File
@@ -81,9 +81,9 @@ Payload:
"folder_name": "voxvera",
"name": "Vox Vera Printable Flyers",
"lang": "en",
"title": "TOP SECRET",
"title": "OPERATION VOX VERA",
"subtitle": "DO ~~NOT~~ DISTRIBUTE",
"headline": "OPERATION VOX VERA",
"headline": "TOP SECRET",
"content": "Flyer body text",
"url_message": "Follow this link to learn more.",
"url": "https://creator.example/action",
@@ -103,6 +103,8 @@ Default behavior: `qr_target: "flyer_url"`.
When publishing, the client computes the replaceable-event `naddr` and writes the generated poster URL to `tear_off_link`. The tear-off tabs and tear-off QR code point at `tear_off_link`, so people can re-open and reprint the flyer. The main flyer QR code points at `url`, so viewers can visit the creator's intended destination.
Each tear-off tab is labeled with the flyer's **title** (`.tear-off-title`, auto-sized by `fitTabTitles()` to be as large as fits the ~1in tab, shrinking rather than overflowing for long titles), a clickable **"Open & reprint this flyer"** link (`tab_link`, the `<a href>` so it stays live in a printed PDF), the tear-off QR, and a **"Reopen at VoxVera.org — Event ID:"** line (`tab_event_id`, the brand stays Latin; the rest is localized) with the full event id. All strings are translated in every supported language.
The `naddr` is encoded with a **single relay hint** (the primary configured relay) instead of the full relay list. This keeps the poster URL short and its tear-off QR code easy to scan — a typical poster URL drops from ~233 characters (full list) to ~171 — while still pointing a fresh viewer at a relay that has the event. Viewers also fall back to `DEFAULT_RELAYS`, which is where flyers are published by default. This stays a standard NIP-19 `naddr`, so any Nostr client can resolve it, and older relay-bearing `naddr` URLs continue to decode and resolve unchanged.
The naddr is carried in the URL **fragment** (`#naddr1...`) rather than a `?addr=` query parameter. The fragment is shorter (less QR clutter), needs no URL-encoding, and never reaches the static host. `getUrlEventReference` reads the naddr from query params, the fragment, or anywhere in the full URL, so older `?addr=` poster URLs and printed QR codes still resolve.
@@ -127,7 +129,7 @@ The client and schema helper must:
## Publishing identity
The editor signs flyer events under one of three identities, chosen in the
The editor signs flyer events under one of four identities, chosen in the
identity controls (`activeIdentity` / `signActiveEvent` in `nostr-client.js`):
- **Anonymous (default).** The per-device key in `localStorage`
@@ -141,12 +143,30 @@ identity controls (`activeIdentity` / `signActiveEvent` in `nostr-client.js`):
memory** for the session. If the user ticks "Remember on this device", the
secret is encrypted with a PIN and stored as `voxvera_identity_nsec_enc`; the
plaintext secret is never written to storage.
- **Remote signer (NIP-46).** The user's real secret never reaches the browser;
the client mints an **ephemeral** local key and exchanges encrypted
kind-`24133` JSON-RPC requests (`connect` → `get_public_key` → `sign_event`)
over a raw WebSocket. Two connect paths are offered:
- **`bunker://` (paste).** The user pastes a link the signer app generated;
the client connects out to that bunker relay.
- **`nostrconnect://` (QR).** The client generates the link itself, renders it
as a QR (`makeQrSvg`) plus a copyable link, and listens on a rendezvous relay
(`relay.nsec.app`); the signer app scans it and connects back, proving the
connection by echoing the client's random `secret`. The discovered signer
pubkey is then used for the durable connection.
Requests are encrypted with **NIP-44** (with a NIP-04 decrypt fallback for
older signers); the vendored nostr-tools bundle has no nip46 module, so this
client is hand-rolled. The connection is **session-only** — nothing is
persisted, so a reload returns to anonymous.
The chosen mode is remembered in `voxvera_identity_mode` (and the npub in
`voxvera_identity_npub` for display). On reload: NIP-07 re-fetches the pubkey;
a remembered nsec shows a **locked** state until the PIN is entered (`unlock`);
a session-only nsec falls back to anonymous. `naddr`/poster-URL/`author npub`
outputs all reflect the active identity.
a session-only nsec or a remote-signer session falls back to anonymous.
`naddr`/poster-URL/`author npub` outputs all reflect the active identity. A
deliberate identity choice is also mirrored to `voxvera_connected_pubkey` so the
bulletin board reflects the same identity.
**PIN encryption details.** PBKDF2 (`SHA-256`, 600k iterations, random 16-byte
salt) derives an AES-GCM-256 key (random 12-byte IV); the blob stores
@@ -158,8 +178,9 @@ but longer PINs are allowed and encouraged.
> exfiltrated (targeted XSS, or devtools access on a shared/stolen device) it is
> brute-forceable offline regardless of PBKDF2 cost. PIN-at-rest protects against
> casual snooping and untargeted localStorage scraping, **not** a determined or
> targeted attacker. The NIP-07 extension path (secret never in the page) is the
> stronger option; a NIP-46 remote signer is a future addition. See
> targeted attacker. The NIP-07 extension path (secret never in the page) and the
> NIP-46 remote signer (secret stays in the signer app; the browser holds only an
> ephemeral key — paste a `bunker://` link) are the stronger options. See
> [`roadmap.md`](roadmap.md).
## Editing, re-publishing, and deleting flyers
@@ -192,6 +213,31 @@ arriving later); the viewer shows a localized "This flyer was removed." state
replaceable overwrites and NIP-09 may still serve the original — so it is framed
as a request, not a guarantee.
## Importing and exporting a flyer design
The editor can save and load a flyer **design** as a portable `.json` file
(`Export design` / `Import design`), independent of publishing. The file format
is `{ type: "voxvera_flyer_design", version: 1, flyer: <payload>, relays: [] }`,
where `flyer` is exactly `rawPayloadFromForm()` — the content fields plus the
relay list, and **nothing identity-bound**: no npub, `naddr`, event id, or
signature (all of those are derived at publish time by `withPosterUrl` /
`signAndPublish`). That makes a design portable: it can be handed to anyone and
published under *their* key with a fresh address.
- **Export** (`exportDesign`) serializes the current form as-is (draft-friendly,
no strict validation) and downloads `<folder_name>-voxvera-design.json`.
- **Import** (`importDesignFromFile`) reads the picked file (capped at 256 KB),
`JSON.parse`s it, and accepts either the wrapper format or a bare
`voxvera_flyer` payload (`readDesignFile`, lenient). It then shows a **confirm
modal** (`#import-modal`) before overwriting the editor, and on confirm
(`applyPendingImport`) fills the form via `fillEditorFromConfig` and restores
the relay list. Imported content is untrusted, but it flows through the lenient
view path (the renderer escapes every field) and still faces strict
`validatePayload` at publish (raw-HTML rejection, length caps, URL schemes).
This replaced the earlier raw "Export event JSON" button; the published
(signed) event JSON is still shown in `#event-json-output` after a publish.
## Print paper size
The flyer supports two print sizes: **US Letter** (8.5×11in) and **A4**
@@ -237,15 +283,20 @@ de-duplicated per author+`d` identifier, keeping the latest.
never print (hidden in the `@media print` block alongside the other app
chrome).
- **The board is gated behind a connected Nostr identity.** On load it shows a
login gate (`#board-gate`) instead of the table, with three ways to connect:
a **NIP-07** extension (`window.nostr.getPublicKey()`), **pasting an `nsec`**
(decoded in-page to derive the pubkey, then discarded — the secret is never
stored or transmitted), or **creating a new key** (reuses this device's anon
key in `voxvera_nostr_anon_secret_hex` if present, else generates one, and
reveals the `nsec` with a save-it warning before continuing). On success it
reveals the table and surfaces the connected npub (`#board-identity`) with a
Disconnect control. The board only ever needs the *pubkey* (it reads, never
signs), so the connected pubkey is remembered in `localStorage`
login gate (`#board-gate`) instead of the table, with four ways to connect:
a **NIP-07** extension (`window.nostr.getPublicKey()`), a **NIP-46 remote
signer** (either paste a `bunker://` link or scan a `nostrconnect://` QR; same
hand-rolled client as the editor — it establishes the viewer pubkey via
`get_public_key` and keeps the connection so `getBoardSigner` can sign
reports/deletes remotely this session, then falls back to read-only after a
reload), **pasting an `nsec`** (decoded in-page to
derive the pubkey, then discarded — the secret is never stored or transmitted),
or **creating a new key** (reuses this device's anon key in
`voxvera_nostr_anon_secret_hex` if present, else generates one, and reveals the
`nsec` with a save-it warning before continuing). On success it reveals the
table and surfaces the connected npub (`#board-identity`) with a Disconnect
control. The board only ever needs the *pubkey* to view (it reads to filter),
so the connected pubkey is remembered in `localStorage`
(`voxvera_connected_pubkey`, shared with the main client) and a return visit
reconnects from it directly — no prompt or re-entry, any method. This is
**not access control** — the events are public on the relays regardless — it
@@ -286,6 +337,16 @@ de-duplicated per author+`d` identifier, keeping the latest.
pubkeys (`voxvera_blocked_pubkeys`) that always hides them, with a "Clear
blocked" control. The blocklist is local "hide this," separate from the
web-of-trust filter (roadmap #1).
- **Narrow screens (≤640px):** the table collapses to a card per flyer — the
**title** and the **⋯ menu** stay visible, and the secondary fields (link,
posted-by, language, date, event id) sit behind a per-row **Details**
disclosure (`.board-details-toggle` toggles `tr.expanded`; the columns become
labeled rows via their `data-label`).
On mobile the flyer preview scales to fully fit the screen width
(`updatePreviewScale` / `--flyer-preview-scale`): the sheet keeps its true print
width (`flex: 0 0 auto`, so the viewer-mode flexbox doesn't shrink it) and is
visually scaled with a transform — so it never side-scrolls or clips.
The login gate (Phase 1) and the web-of-trust filter (Phase 2) are in place.
Remaining gaps — degree-2 trust, a blocklist/report flow, NIP-05 badges, and
+90 -20
View File
@@ -15,9 +15,13 @@ The trust model (#1–#2) has moved from undefined to implemented:
- **Bulletin board login gate** — viewing the board requires a connected Nostr
identity (NIP-07 extension, a pasted `nsec`, or a generated key).
- **Web-of-trust filter** — the board defaults to flyers from authors in the
viewer's NIP-02 follow graph, seeded from a curator account for fresh keys,
with a "Show all" opt-out (so it is no longer an unmoderated firehose).
- **Web-of-trust filter (degree-2)** — the board defaults to flyers from authors
in the viewer's NIP-02 follow graph *and the people those follows follow*
(follows-of-follows), seeded from a curator account for fresh keys, with a
"Show all" opt-out (so it is no longer an unmoderated firehose).
- **Editor↔board identity sync** — a deliberate editor identity choice (NIP-07,
imported/generated/unlocked nsec, or anonymous) is shared with the board via a
local pubkey, so the same person sees a consistent identity across both pages.
- **Editor publishing identity** — authors can publish anonymously (default),
via NIP-07, or with an imported `nsec` that is optionally PIN-encrypted at
rest (PBKDF2 → AES-GCM; plaintext never stored).
@@ -31,10 +35,19 @@ The trust model (#1–#2) has moved from undefined to implemented:
- **Local blocklist** — a per-device "Block" on the board hides flyers from any
author you didn't post (separate from the web-of-trust filter), with a clear
control. This is the local blocklist direction from #1.
- **Report (NIP-56)** — a board "Report" action on others' flyers publishes a
signed **kind-1984** report (category + optional reason) referencing the flyer
(`e`) and author (`p`), so other clients and moderators can aggregate it.
Reporting also hides the flyer locally (pairs with the blocklist).
- **Author display names** — the board shows each author's kind-0 display name
(falling back to the npub), so flyers aren't labeled by bare keys. (NIP-05
*verification* is still a future addition.)
- **"My flyers" filter** — a board toggle to show only the connected user's own
flyers (overrides the trust filter, since your own may not be in your graph).
All of the above is localized across the 14 languages and covered by the
cross-engine Playwright suite. **What remains** on #1–#2 is polish (degree-2
trust, blocklist/report, NIP-05 badges, an identified-vs-anonymous display) plus
cross-engine Playwright suite. **What remains** on #1–#2 is polish (NIP-05
badges, an identified-vs-anonymous display) plus
the editor follow-ups noted under the *Planned direction* section. **#3–#5 are
still fully open** — distribution centralization (#3) is now the
highest-leverage untouched item.
@@ -47,7 +60,7 @@ highest-leverage untouched item.
> Nostr identity to view, and it now filters to flyers from the viewer's NIP-02
> web of trust (with a bootstrap seed for new keys). See *Planned direction:
> identity + web-of-trust on the board* below (jointly addresses #1 and #2).
> Remaining: degree-2 trust, blocklist/report, NIP-05 badges (Phase 3).
> Remaining: NIP-05 badges (Phase 3). Degree-2 trust + NIP-56 report shipped.
**Problem.** `board.html` lists every Nostr event tagged `t=voxvera` / `t=flyer`
@@ -156,10 +169,12 @@ board can show them flyers from people they trust.
no trust data can be fetched at all, the board falls back to showing
everything (never mysteriously empty). Covered by the cross-engine e2e suite
(followed-only view + show-all opt-out; curator-seeded fallback).
- **Phase 3 — Optional / next.** Degree-2 trust (follows-of-follows), a local
blocklist / "hide this", report-to-list, and NIP-05 verified badges. Also:
the bootstrap curator is currently a single hardcoded pubkey — consider making
it configurable or a small curated set.
- **Phase 3 — In progress.** Degree-2 trust (follows-of-follows) ✅ shipped; the
local blocklist / "hide this" ✅ shipped; NIP-56 reporting (kind 1984) ✅
shipped. **Remaining:** NIP-05 verified badges, and a curator-side flow that
*consumes* reports (today a report is published for others to aggregate, but
the board does not yet act on report counts). Also: the bootstrap curator is a
single hardcoded pubkey — consider making it configurable or a small set.
**Decisions made (2026-06-10).**
1. **Board viewing: login-required.** The board is blank (a Connect prompt)
@@ -181,18 +196,36 @@ board can show them flyers from people they trust.
**Follow-ups this opens.**
- **In-page nsec handling.** Pasting a secret key into a web page is inherently
riskier than a NIP-07 extension that keeps the key isolated; the input is a
password field, the value is cleared after use, and nothing is transmitted, but
a NIP-46 remote signer would be a safer mobile path to add later.
- **Editor identity (shipped).** The editor now signs under anon / NIP-07 /
imported nsec. A remembered nsec is PIN-encrypted at rest (PBKDF2 600k →
password field, the value is cleared after use, and nothing is transmitted. The
**NIP-46 remote signer** (below) is now the safer mobile path.
- **NIP-46 remote signer (shipped — editor *and* board, both connect paths).**
Both the editor's publishing identity and the board's login gate can connect a
remote signer two ways: **(a) `bunker://`** — paste a link the signer app
generated; and **(b) `nostrconnect://` (QR)** — *we* generate the link, show it
as a QR (and a copyable link for one-device use), and the signer app scans it
and connects back (verified by echoing our secret). Either way the user's
secret stays in their signer app (nsec.app, Amber, …) and the browser holds
only an ephemeral local key. The client is hand-rolled over a raw WebSocket
using the vendored NIP-44 (with a NIP-04 decrypt fallback) — the bundle ships
no nip46 module; the QR flow uses `relay.nsec.app` as the rendezvous relay. On
the board it establishes the viewer pubkey and keeps the connection so
reports/deletes are signed remotely (read-only after a reload). Connections are
**session-only** (nothing is written to disk, so a reload returns to anonymous
/ read-only). Covered by the cross-engine e2e suite for both paths on both
pages (bunker and nostrconnect), against a fake signer doing real NIP-44
crypto.
- **Editor identity (shipped).** The editor signs under anon / NIP-07 / imported
nsec / remote signer. A remembered nsec is PIN-encrypted at rest (PBKDF2 600k →
AES-GCM); plaintext is never stored. **Known weakness:** a short numeric PIN is
brute-forceable offline if the encrypted blob leaks — it guards casual snooping,
not a targeted attacker. Next: NIP-46 remote signer; an attempt-limit / lockout
on repeated wrong PINs; and optionally encrypting the anonymous device key the
same way (today it is stored in plaintext, as before).
- **Editor vs board identity are independent.** The editor's publishing identity
and the board's viewing identity are stored separately and don't yet sync; a
future pass could share one connected identity across both pages.
not a targeted attacker. A **wrong-PIN lockout** is now in place (3 bad PINs →
a 30s cooldown, persisted across reloads). Optionally encrypting the anonymous
device key the same way is still open (today it is stored in plaintext, as
before).
- **Editor↔board identity sync (shipped).** A deliberate editor identity choice
(NIP-07 / imported / generated / unlocked nsec / remote signer) writes a shared
local pubkey the board reads, so one connected identity is reflected across both
pages.
## 3. Client distribution is centralized (a single chokepoint) — **Open**
@@ -235,6 +268,17 @@ for events not on the default set.
- Reconsider how many relay hints to embed in the poster `naddr` (tradeoff:
URL length / QR density vs. fetch robustness — see the QR Target notes in the
spec).
- **Self-contained fallback payload in the URL fragment (future).** Because a
printed flyer cannot be edited, a pruned/unreachable event leaves a dead QR.
The flyer payload is small (text only), so it could be compressed and encoded
into the URL `#fragment` alongside the `naddr`, letting the viewer render the
flyer *even when no relay has the event* — relay-independent durability, which
fits the censorship-resistance pitch. **Constraint:** the tear-off QR URL is
already near scannable capacity, so this likely can't be added without
reducing the number of tear-off tabs and/or enlarging the QR (denser code,
harder scan). Best treated as an opt-in "durable QR" mode rather than the
default, and decided alongside the tab-count/QR-size tradeoff. The `naddr`
remains the primary resolver; the embedded payload is a last-resort fallback.
## 5. Content-safety guardrails are minimal — **Open**
@@ -257,6 +301,32 @@ should follow the board's trust model.
noted so it is not mistaken for a contract.
- **Favicon — ✅ done.** A small inline SVG (`site/favicon.svg`) is linked from
both pages, so browsers no longer request (and 404 on) `/favicon.ico`.
- **i18n key-parity guard — ✅ done.** `e2e/i18n.spec.js` fails if any
`NOSTR_UI` / `BOARD_UI` / `SAFETY_UI` / locales dictionary is missing (or has
an extra) key in some language (and that the `SAFETY_UI` bullet lists are the
same length in every language). It immediately caught `field_too_long`, which
had been English-only across all 13 other languages.
- **Safety & privacy page — ✅ done.** A standalone `/safety` page
(`safety.html` / `safety.js`), localized across all 14 languages, in plain
language: what VoxVera protects, what it does **not** (relays see your IP; the
anon key is plaintext in the browser; a short PIN won't stop a targeted
attacker; published events are public/permanent; nsec-paste risk; reports are
public; the host/relays still see requests), and operational tips (Tor/VPN,
disposable keys, signer apps). Linked from the editor and the board. Aimed at
the activist/whistleblower audience the aesthetic invites — being honest about
limits matters more than any feature.
- **Link previews / Open Graph — shipped (site-wide).** Static OG +
Twitter-card meta are on the home, board, and safety pages, including a
**1200×630 preview image** (`site/og-card.jpg`, `summary_large_image`), so a
shared `voxvera.org` link renders a real title, description, and image card.
**Per-flyer previews remain a deliberate non-goal:** the poster `naddr` lives
in the URL `#fragment` (invisible to servers), and a static host can't render
per-flyer `<meta>`/image without server-side code — out of scope by choice
(this stays a free, purely static site). Revisit only if that constraint ever
changes.
- **Publish transparency — ✅ done.** The publish confirmation modal now reports
how many relays accepted the event ("Relays accepted: N / M") and flags a
zero-acceptance publish, so a weak publish is visible immediately.
- **WebKit/Safari coverage in CI** needs `libavif16` on Linux runners
(`sudo npx playwright install-deps`); headless WebKit is also not a perfect
stand-in for real Safari/iOS print + clipboard behavior, so a real-device
+621 -6
View File
@@ -96,8 +96,12 @@ test.describe("VoxVera static client", () => {
await page.goto("/");
const sheet = page.locator("#flyer-preview .container").first();
await expect(sheet).toBeVisible();
// Default sample content should render on the bare URL.
await expect(page.locator(".content h1").first()).toContainText("TOP SECRET");
// Default sample content: title (the project name) renders first, the
// dramatic line is the headline below it.
await expect(page.locator(".content h1").first()).toContainText("OPERATION VOX VERA");
await expect(page.locator(".content h1").nth(1)).toContainText("TOP SECRET");
// The side tabs are labeled with the title (the project name).
await expect(page.locator(".tear-off-title").first()).toContainText("OPERATION VOX VERA");
// A real Letter sheet is taller than it is wide.
const box = await sheet.boundingBox();
expect(box && box.height).toBeGreaterThan(box ? box.width : 0);
@@ -117,6 +121,20 @@ test.describe("VoxVera static client", () => {
expect(await res.text()).toContain("<svg");
});
test("all pages declare the Open Graph preview image, and it is served", async ({ page }) => {
for (const path of ["/", "/board.html", "/safety.html"]) {
await page.goto(path);
const ogImage = await page.locator('meta[property="og:image"]').getAttribute("content");
expect(ogImage, `${path} og:image`).toBe("https://voxvera.org/og-card.jpg");
const twCard = await page.locator('meta[name="twitter:card"]').getAttribute("content");
expect(twCard, `${path} twitter:card`).toBe("summary_large_image");
}
// The card asset itself is served and is a JPEG.
const res = await page.request.get("/og-card.jpg");
expect(res.status()).toBe(200);
expect(res.headers()["content-type"] || "").toContain("jpeg");
});
test("print media hides app chrome and keeps the flyer", async ({ page }, testInfo) => {
await page.goto("/");
await page.emulateMedia({ media: "print" });
@@ -210,6 +228,38 @@ test.describe("VoxVera static client", () => {
expect(href || "").not.toMatch(/^javascript:/i);
});
test("tear-off tabs carry the title, an open-&-reprint link, and a VoxVera.org event-id", async ({ page }) => {
const PK = "c".repeat(64);
const IDENT = "voxvera:tab-test";
await stubRelays(page, [{
id: "ab".repeat(32), kind: 30078, pubkey: PK, created_at: 3000,
tags: [["d", IDENT], ["t", "voxvera"], ["t", "flyer"], ["language", "en"]],
content: JSON.stringify({
type: "voxvera_flyer", version: 1, folder_name: "tab-test", lang: "en",
name: "N", title: "BITCOIN", subtitle: "s", headline: "h", content: "c",
url_message: "m", url: "https://example.com/x", footer_message: "f",
tear_off_link: "https://voxvera.org/#naddr1tab", qr_target: "flyer_url"
})
}]);
await page.goto("/");
const naddr = await page.evaluate(({ pk, ident }) =>
window.NostrTools.nip19.naddrEncode({ identifier: ident, pubkey: pk, kind: 30078, relays: [] }),
{ pk: PK, ident: IDENT });
await page.goto("about:blank");
await page.goto(`/#${naddr}`);
const tab = page.locator(".tear-off").first();
// Title labels the tab and is auto-sized to a concrete font size.
await expect(tab.locator(".tear-off-title")).toHaveText("BITCOIN");
expect(await tab.locator(".tear-off-title").evaluate((n) => n.style.fontSize)).toMatch(/\d+px/);
// The instruction line is the clickable link to the poster URL.
const link = tab.locator("a.tear-off-link");
await expect(link).toHaveText("Open & reprint this flyer");
await expect(link).toHaveAttribute("href", "https://voxvera.org/#naddr1tab");
// Event-id label points people back to VoxVera.org.
await expect(tab.locator(".tear-off-event-id")).toContainText("Reopen at VoxVera.org");
await expect(tab.locator(".tear-off-event-id")).toContainText("Event ID");
});
test("viewer resolves a flyer from relays that ignore the #d filter", async ({ page }) => {
// Simulate a relay that does NOT honor the addressable "#d" tag filter (it
// returns nothing for such a REQ) but does answer a broad author query —
@@ -257,6 +307,45 @@ test.describe("VoxVera static client", () => {
await expect(page.locator(".flyer-status-message")).toHaveCount(0);
});
test("on a narrow screen the flyer shrinks to fully fit the width", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await page.goto("/");
await page.waitForSelector("#flyer-preview .container");
await page.waitForTimeout(200);
const m = await page.evaluate(() => {
const sheet = document.querySelector("#flyer-preview .container");
return {
rectW: Math.round(sheet.getBoundingClientRect().width),
win: window.innerWidth,
overflow: document.documentElement.scrollWidth > window.innerWidth
};
});
expect(m.rectW).toBeLessThanOrEqual(m.win); // no clipping past the screen edge
expect(m.overflow).toBe(false); // no horizontal page scroll
});
test("editor identity controls stay hidden until chosen", async ({ page }) => {
await page.goto("/#editor");
await expect(page.locator("#nsec-import")).toBeHidden();
await expect(page.locator("#identity-locked-row")).toBeHidden();
});
test("on a narrow screen the board collapses to cards with a Details toggle", async ({ page }) => {
await page.setViewportSize({ width: 380, height: 800 });
await stubNip07(page);
await stubRelays(page, [flyerEvent("solo", "b".repeat(64), "Mobile Flyer")]);
await page.goto("/board.html");
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
const row = page.locator("#board-rows tr").first();
const toggle = row.locator(".board-details-toggle");
await expect(toggle).toBeVisible(); // shown only at narrow widths
await expect(row.locator('td[data-col="author"]')).toBeHidden(); // collapsed
await toggle.click();
await expect(row).toHaveClass(/expanded/);
await expect(row.locator('td[data-col="author"]')).toBeVisible(); // revealed
});
test("infers language from the first supported browser preference", async ({ page }) => {
// Browser prefers Dutch (unsupported), then German (supported), then
// English. We should honor German rather than jumping to the English
@@ -272,8 +361,8 @@ test.describe("VoxVera static client", () => {
});
});
await page.goto("/");
// German default headline (locales.js de.landing.title).
await expect(page.locator(".content h1").first()).toContainText("STRENG GEHEIM");
// German-distinctive default headline (locales.js de.landing.headline).
await expect(page.locator(".content h1").nth(1)).toContainText("STRENG GEHEIM");
await expect(page.locator("html")).toHaveAttribute("lang", "de");
});
@@ -319,9 +408,10 @@ test.describe("VoxVera static client", () => {
test("bulletin board link sits outside the flyer sheet and is not printed", async ({ page }) => {
await page.goto("/");
const link = page.locator(".preview-band > .board-link a");
const link = page.locator('.preview-band > .board-link a[href="board.html"]');
await expect(link).toHaveText("Bulletin board");
await expect(link).toHaveAttribute("href", "board.html");
// The safety link sits alongside it, below the preview.
await expect(page.locator('.preview-band > .board-link a[href="/safety.html"]')).toHaveText("Safety & privacy");
// It is app chrome below the preview, not part of the printable sheet.
await expect(page.locator(".container .board-link")).toHaveCount(0);
// Screen-only — it must not appear in print output.
@@ -330,6 +420,8 @@ test.describe("VoxVera static client", () => {
});
test("bulletin board page renders localized, sortable columns", async ({ page }) => {
// Desktop table mode (narrow screens collapse to cards with the header hidden).
await page.setViewportSize({ width: 1024, height: 768 });
await stubNip07(page);
await stubRelays(page, []);
await page.goto("/board.html");
@@ -490,6 +582,28 @@ test.describe("VoxVera static client", () => {
await expect(page.locator("#board-filter-note")).toContainText(/all flyers/i);
});
test("the web-of-trust filter includes follows-of-follows (degree 2)", async ({ page }) => {
const A = "a".repeat(64);
const B = "b".repeat(64);
const C = "c".repeat(64);
await stubNip07(page); // viewer = ME (1*64)
await stubRelays(page, [
contactList(ME, [A]), // you follow A (degree 1)
contactList(A, [B]), // A follows B (degree 2)
flyerEvent("fa", A, "Direct Follow Flyer"),
flyerEvent("fb", B, "Friend Of Friend Flyer"),
flyerEvent("fc", C, "Stranger Flyer")
]);
await page.goto("/board.html");
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
// Trust-filtered (not show-all): degree-1 and degree-2 appear, stranger hidden.
await expect(page.locator("#board-rows")).toContainText("Direct Follow Flyer");
await expect(page.locator("#board-rows")).toContainText("Friend Of Friend Flyer");
await expect(page.locator("#board-rows")).not.toContainText("Stranger Flyer");
await expect(page.locator("#board-show-all")).not.toBeChecked();
});
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.
@@ -547,6 +661,8 @@ test.describe("VoxVera static client", () => {
await expect(page.locator("#publish-modal-message")).toContainText("Copy this URL");
// The URL is a poster URL (naddr in the fragment).
await expect(page.locator("#publish-modal-url")).toHaveValue(/#naddr1[0-9a-z]+/);
// The relays-accepted line reports how many of the relays took the event.
await expect(page.locator("#publish-modal-relays")).toContainText(/3\s*\/\s*3/);
await page.locator("#publish-modal-close").click();
await expect(modal).toBeHidden();
});
@@ -556,6 +672,261 @@ test.describe("VoxVera static client", () => {
await expect(page.locator("#signer-state")).toHaveText("Publishing anonymously");
});
test("a deliberate editor identity choice syncs to the board's connected key", async ({ page }) => {
const PK = "a".repeat(64);
await page.addInitScript((pk) => {
window.nostr = { getPublicKey: async () => pk, signEvent: async (e) => e };
}, PK);
await page.goto("/#editor");
await page.locator("#connect-nip07").click();
await expect(page.locator("#signer-state")).toHaveText("Publishing as your extension identity");
// The board reads this shared key to reflect the same identity.
expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toBe(PK);
});
test("editor connects a NIP-46 remote signer and publishes through it", async ({ page }) => {
const errors = trackErrors(page);
// A fake relay that also plays the remote signer: it decrypts NIP-46
// requests with real NIP-44 crypto and replies with signed responses.
await page.addInitScript(() => {
function hexToBytes(hex) {
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i += 1) b[i] = parseInt(hex.substr(i * 2, 2), 16);
return b;
}
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") {
this.sub = m[1];
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EOSE", this.sub]) }), 1);
return;
}
if (m[0] !== "EVENT") return;
const ev = m[1];
const NT = window.NostrTools;
if (ev.kind === 24133) {
const skBytes = hexToBytes(window.__nip46SkHex);
const ck = NT.nip44.getConversationKey(skBytes, ev.pubkey);
let req; try { req = JSON.parse(NT.nip44.decrypt(ev.content, ck)); } catch (_) { return; }
let result = "";
if (req.method === "connect") result = "ack";
else if (req.method === "get_public_key") result = window.__nip46Pubkey;
else if (req.method === "sign_event") {
result = JSON.stringify(NT.finalizeEvent(JSON.parse(req.params[0]), skBytes));
}
const content = NT.nip44.encrypt(JSON.stringify({ id: req.id, result }), ck);
const resp = NT.finalizeEvent({
kind: 24133, created_at: Math.floor(Date.now() / 1000),
tags: [["p", ev.pubkey]], content
}, skBytes);
const sub = this.sub;
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", sub, resp]) }), 1);
return;
}
// A published flyer event → acknowledge with OK.
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", ev.id, true, ""]) }), 1);
}
close() {}
}
window.WebSocket = FakeWS;
});
await page.goto("/#editor");
// Mint the signer keypair in-page (NostrTools is loaded there).
const signerPubkey = await page.evaluate(() => {
const sk = window.NostrTools.generateSecretKey();
window.__nip46SkHex = Array.from(sk).map((b) => b.toString(16).padStart(2, "0")).join("");
window.__nip46Pubkey = window.NostrTools.getPublicKey(sk);
return window.__nip46Pubkey;
});
const expectedNpub = await page.evaluate((pk) => window.NostrTools.nip19.npubEncode(pk), signerPubkey);
// Paste a bunker URI pointing at the fake signer and connect.
await page.locator("#use-nip46-toggle").click();
await page.locator("#nip46-input").fill(`bunker://${signerPubkey}?relay=wss://relay.damus.io&secret=hunter2`);
await page.locator("#nip46-submit").click();
// The connect + get_public_key handshake resolves the user's identity.
await expect(page.locator("#signer-state")).toHaveText("Publishing via remote signer");
await expect(page.locator("#author-npub-output")).toHaveText(expectedNpub);
// Publishing routes signing through the remote signer (sign_event round-trip).
await page.locator("#field-title").fill("Remote Signed Flyer");
await page.locator("#publish-event").click();
await expect(page.locator("#event-id-output")).not.toHaveText(/not published/i);
expect(errors, errors.join("\n")).toHaveLength(0);
});
// A fake relay that also plays a signer "scanning" the nostrconnect QR: when it
// sees the client's listener subscription it reads the on-screen nostrconnect
// URI, echoes the secret back (the connect proof), then answers requests.
function nostrConnectSignerInit() {
function hexToBytes(hex) {
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i += 1) b[i] = parseInt(hex.substr(i * 2, 2), 16);
return b;
}
class FakeWS {
constructor() { this.readyState = 1; setTimeout(() => this.onopen && this.onopen(), 1); }
send(data) {
let m; try { m = JSON.parse(data); } catch (_) { return; }
const NT = window.NostrTools;
if (m[0] === "REQ") {
this.sub = m[1];
const f = m[2] || {};
if (f["#p"] && f.kinds && f.kinds.indexOf(24133) !== -1 && window.__nip46SkHex) {
const clientPubkey = f["#p"][0];
const uriEl = document.getElementById("nip46-uri") || document.getElementById("board-nip46-uri");
const uri = uriEl ? uriEl.value : "";
const mm = uri.match(/secret=([0-9a-f]+)/i);
if (mm) {
const sk = hexToBytes(window.__nip46SkHex);
const ck = NT.nip44.getConversationKey(sk, clientPubkey);
const content = NT.nip44.encrypt(JSON.stringify({ id: "echo", result: mm[1] }), ck);
const echo = NT.finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [["p", clientPubkey]], content }, sk);
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", this.sub, echo]) }), 5);
}
}
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EOSE", this.sub]) }), 1);
return;
}
if (m[0] !== "EVENT") return;
const ev = m[1];
if (ev.kind === 24133) {
const sk = hexToBytes(window.__nip46SkHex);
const ck = NT.nip44.getConversationKey(sk, ev.pubkey);
let req; try { req = JSON.parse(NT.nip44.decrypt(ev.content, ck)); } catch (_) { return; }
let result = "";
if (req.method === "get_public_key") result = window.__nip46Pubkey;
else if (req.method === "sign_event") result = JSON.stringify(NT.finalizeEvent(JSON.parse(req.params[0]), sk));
else if (req.method === "connect") result = "ack";
const content = NT.nip44.encrypt(JSON.stringify({ id: req.id, result }), ck);
const resp = NT.finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [["p", ev.pubkey]], content }, sk);
const sub = this.sub;
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", sub, resp]) }), 5);
return;
}
window.__published = window.__published || [];
window.__published.push(ev);
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", ev.id, true, ""]) }), 1);
}
close() {}
}
window.WebSocket = FakeWS;
}
async function mintSignerKey(page) {
return page.evaluate(() => {
const sk = window.NostrTools.generateSecretKey();
window.__nip46SkHex = Array.from(sk).map((b) => b.toString(16).padStart(2, "0")).join("");
window.__nip46Pubkey = window.NostrTools.getPublicKey(sk);
return window.__nip46Pubkey;
});
}
test("editor connects via the NIP-46 nostrconnect (QR) flow", async ({ page }) => {
const errors = trackErrors(page);
await page.addInitScript(nostrConnectSignerInit);
await page.goto("/#editor");
const signerPubkey = await mintSignerKey(page);
const expectedNpub = await page.evaluate((pk) => window.NostrTools.nip19.npubEncode(pk), signerPubkey);
await page.locator("#use-nip46-toggle").click();
await page.locator("#nip46-scan").click();
// A nostrconnect:// link + QR are produced for the signer to scan.
await expect(page.locator("#nip46-uri")).toHaveValue(/^nostrconnect:\/\//);
await expect(page.locator("#nip46-qr svg")).toHaveCount(1);
// The fake signer "scans" it and connects back → identity adopts the signer.
await expect(page.locator("#signer-state")).toHaveText("Publishing via remote signer");
await expect(page.locator("#author-npub-output")).toHaveText(expectedNpub);
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("exports the current flyer design as a portable, identity-free JSON file", async ({ page }) => {
await page.goto("/#editor");
// Wait for the editor to finish populating defaults before overwriting them
await page.goto("/#editor");
// Wait for the editor to finish populating defaults before overwriting them
// (otherwise setDefaultText can race ahead of fill() on some engines).
await expect(page.locator("#flyer-preview .container").first()).toBeVisible();
await expect(page.locator("#field-url")).toHaveValue(/voxvera\.org/);
// Edit fields with no live auto-fit guard so the values reliably stick on
// every engine (the printable-fit guard on title/url can revert text whose
// rendering overflows the sheet, which is engine/font-metric dependent).
await page.locator("#field-name").fill("Campaign Alpha");
await page.locator("#field-folder-name").fill("alpha-campaign");
await page.locator("#editor-relays").fill("wss://relay.example.com");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.locator("#export-design").click()
]);
const data = JSON.parse(require("fs").readFileSync(await download.path(), "utf8"));
expect(data.type).toBe("voxvera_flyer_design");
expect(data.flyer.name).toBe("Campaign Alpha");
expect(data.flyer.folder_name).toBe("alpha-campaign");
expect(data.relays).toContain("wss://relay.example.com");
// The url field is captured faithfully, whatever the form currently holds.
expect(data.flyer.url).toBe(await page.locator("#field-url").inputValue());
// Identity-free: no naddr, event id, pubkey, or signature is baked in.
const blob = JSON.stringify(data);
expect(blob).not.toContain("naddr");
expect(blob).not.toContain("\"sig\"");
expect(blob).not.toContain("\"pubkey\"");
await expect(page.locator("#publish-status")).toHaveText("Design exported.");
});
test("imports a design file and replaces the editor after confirming", async ({ page }) => {
const errors = trackErrors(page);
await page.goto("/#editor");
const design = {
type: "voxvera_flyer_design",
version: 1,
flyer: {
type: "voxvera_flyer", version: 1, folder_name: "imported", lang: "en",
name: "Imported Name", title: "Imported Title", subtitle: "", headline: "",
content: "Imported body", url_message: "", url: "https://example.org/imported",
footer_message: "", attachment_path: "", attachment_filename: "", qr_target: "flyer_url"
},
relays: ["wss://imported.example.com"]
};
await page.setInputFiles("#import-design-input", {
name: "design.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(design))
});
// A confirm step guards against clobbering the current draft.
await expect(page.locator("#import-modal")).toBeVisible();
await page.locator("#import-modal-confirm").click();
await expect(page.locator("#import-modal")).toBeHidden();
await expect(page.locator("#field-title")).toHaveValue("Imported Title");
await expect(page.locator("#field-name")).toHaveValue("Imported Name");
await expect(page.locator("#field-content")).toHaveValue("Imported body");
await expect(page.locator("#editor-relays")).toHaveValue("wss://imported.example.com");
await expect(page.locator("#publish-status")).toHaveText("Design imported.");
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("cancelling the import confirm leaves the editor unchanged", async ({ page }) => {
await page.goto("/#editor");
await page.locator("#field-title").fill("Keep Me");
await page.setInputFiles("#import-design-input", {
name: "design.json", mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({
type: "voxvera_flyer_design", version: 1,
flyer: { type: "voxvera_flyer", version: 1, title: "Should Not Apply", lang: "en" },
relays: []
}))
});
await expect(page.locator("#import-modal")).toBeVisible();
await page.locator("#import-modal-cancel").click();
await expect(page.locator("#import-modal")).toBeHidden();
await expect(page.locator("#field-title")).toHaveValue("Keep Me");
});
test("rejects a file that isn't a valid VoxVera design", async ({ page }) => {
await page.goto("/#editor");
await page.setInputFiles("#import-design-input", {
name: "notes.json", mimeType: "application/json", buffer: Buffer.from('{"hello":"world"}')
});
await expect(page.locator("#import-modal")).toBeHidden();
await expect(page.locator("#publish-status")).toHaveText("That file isn't a valid VoxVera design.");
});
test("editor can connect a NIP-07 identity and publish under it", async ({ page }) => {
const PK = "a".repeat(64);
await page.addInitScript((pk) => {
@@ -718,6 +1089,27 @@ test.describe("VoxVera static client", () => {
await expect(page.locator(".content h1")).toHaveCount(0);
});
test("board shows only VoxVera flyers, not foreign kind-30078 app data", async ({ page }) => {
const OTHER = "b".repeat(64);
await stubNip07(page); // viewer = 1*64, no follows → show-all
await stubRelays(page, [
flyerEvent("real", OTHER, "Real VoxVera Flyer"),
// A foreign app's kind-30078 event that still carries a "voxvera" tag
// (hashtag collision, or a relay ignoring the #t filter). It has neither a
// voxvera: d-tag nor a voxvera_flyer payload, so it must be excluded.
{
id: "f".repeat(64), kind: 30078, pubkey: OTHER, created_at: 5000,
tags: [["d", "artstr:gallery"], ["t", "voxvera"], ["t", "flyer"]],
content: JSON.stringify({ type: "artstr_note", title: "Foreign App Note", url: "https://artstr.studio/x" })
}
]);
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("Real VoxVera Flyer");
await expect(page.locator("#board-rows")).not.toContainText("Foreign App Note");
});
test("blocking an author hides their flyers, persists, and clears", async ({ page }) => {
const OTHER = "b".repeat(64);
await stubNip07(page); // viewer = 1*64
@@ -741,6 +1133,39 @@ test.describe("VoxVera static client", () => {
await expect(page.locator("#board-rows")).toContainText("Someone Else's Flyer");
});
test("the 'My flyers' filter shows only the connected user's flyers", async ({ page }) => {
const ME = "1".repeat(64);
const OTHER = "b".repeat(64);
await stubNip07(page); // viewer = ME
await stubRelays(page, [flyerEvent("mine", ME, "My Own Flyer"), flyerEvent("theirs", OTHER, "Other 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("My Own Flyer");
await expect(page.locator("#board-rows")).toContainText("Other Flyer");
// Click the label (WebKit treats a bare checkbox as not actionable); this is
// also how a user toggles it.
await page.locator("#board-mine-only-label").click();
await expect(page.locator("#board-mine-only")).toBeChecked();
await expect(page.locator("#board-rows")).toContainText("My Own Flyer");
await expect(page.locator("#board-rows")).not.toContainText("Other Flyer");
});
test("the board shows author display names from kind-0 metadata", async ({ page }) => {
const A = "a".repeat(64);
await stubNip07(page);
await stubRelays(page, [
flyerEvent("byalice", A, "Alice's Flyer"),
{ id: "0".repeat(64), kind: 0, pubkey: A, created_at: 9000, tags: [], content: JSON.stringify({ name: "alice", display_name: "Alice A" }) }
]);
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("Alice's Flyer");
// display_name (preferred) appears once the kind-0 profiles load.
await expect(page.locator(".board-author-name")).toContainText("Alice A");
});
test("the board shows manage actions on your flyers and Block on others", async ({ page }) => {
const ME = "1".repeat(64);
const OTHER = "b".repeat(64);
@@ -816,6 +1241,181 @@ test.describe("VoxVera static client", () => {
expect(pub.some((e) => e.kind === 5 && (e.tags || []).some((t) => t[0] === "a" && t[1] === `30078:${ME}:voxvera:mine`))).toBe(true);
});
test("reporting a flyer publishes a NIP-56 report and hides it", async ({ page }) => {
const ME = "1".repeat(64);
const AUTHOR = "b".repeat(64);
const FLYER_ID = "f".repeat(64);
await page.addInitScript((cfg) => {
window.nostr = {
getPublicKey: async () => cfg.me,
signEvent: async (e) => ({ ...e, pubkey: cfg.me, id: "a".repeat(64), sig: "0".repeat(128) })
};
window.__published = [];
const flyer = {
id: cfg.flyerId, kind: 30078, pubkey: cfg.author, created_at: 2000,
tags: [["d", "voxvera:bad"], ["t", "voxvera"], ["t", "flyer"], ["language", "en"]],
content: JSON.stringify({ type: "voxvera_flyer", version: 1, lang: "en", title: "Reported Flyer", url: "https://example.com/x" })
};
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") {
const sub = m[1], f = m[2] || {};
const serve = f.kinds && f.kinds.indexOf(30078) !== -1; // flyers only
setTimeout(() => {
if (serve) this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", sub, flyer]) });
this.onmessage && this.onmessage({ data: JSON.stringify(["EOSE", sub]) });
}, 1);
} else if (m[0] === "EVENT") {
window.__published.push(m[1]);
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", m[1].id, true, ""]) }), 1);
}
}
close() {}
}
window.WebSocket = FakeWS;
}, { me: ME, author: AUTHOR, flyerId: FLYER_ID });
await page.goto("/board.html");
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
const theirRow = page.locator("#board-rows tr", { hasText: "Reported Flyer" });
await theirRow.locator(".board-menu-btn").click({ force: true });
await theirRow.locator('.board-menu-item[data-act="report"]').click({ force: true });
await expect(page.locator("#board-report-modal")).toBeVisible();
await page.locator("#board-report-category").selectOption("spam");
await page.locator("#board-report-reason").fill("clearly spam");
await page.locator("#board-report-confirm").click({ force: true });
// The flyer is hidden for the reporter (pairs with the local blocklist)…
await expect(page.locator("#board-rows")).not.toContainText("Reported Flyer");
// …and a signed NIP-56 (kind 1984) report was published referencing it.
const reports = await page.evaluate(() => window.__published.filter((e) => e.kind === 1984));
expect(reports.length).toBeGreaterThan(0);
const rep = reports[0];
expect(rep.tags.some((tg) => tg[0] === "p" && tg[1] === AUTHOR)).toBe(true);
expect(rep.tags.some((tg) => tg[0] === "e" && tg[1] === FLYER_ID && tg[2] === "spam")).toBe(true);
expect(rep.content).toBe("clearly spam");
});
test("board can connect via a NIP-46 remote signer and report through it", async ({ page }) => {
const AUTHOR = "c".repeat(64);
const FLYER_ID = "f".repeat(64);
await page.addInitScript((cfg) => {
function hexToBytes(hex) {
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i += 1) b[i] = parseInt(hex.substr(i * 2, 2), 16);
return b;
}
window.__published = [];
const flyer = {
id: cfg.flyerId, kind: 30078, pubkey: cfg.author, created_at: 2000,
tags: [["d", "voxvera:bad"], ["t", "voxvera"], ["t", "flyer"], ["language", "en"]],
content: JSON.stringify({ type: "voxvera_flyer", version: 1, lang: "en", title: "Stranger Flyer", url: "https://example.com/x" })
};
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") {
this.sub = m[1];
const f = m[2] || {};
const serveFlyer = f.kinds && f.kinds.indexOf(30078) !== -1;
setTimeout(() => {
if (serveFlyer) this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", this.sub, flyer]) });
this.onmessage && this.onmessage({ data: JSON.stringify(["EOSE", this.sub]) });
}, 1);
return;
}
if (m[0] !== "EVENT") return;
const ev = m[1];
const NT = window.NostrTools;
if (ev.kind === 24133) {
const skBytes = hexToBytes(window.__nip46SkHex);
const ck = NT.nip44.getConversationKey(skBytes, ev.pubkey);
let req; try { req = JSON.parse(NT.nip44.decrypt(ev.content, ck)); } catch (_) { return; }
let result = "";
if (req.method === "connect") result = "ack";
else if (req.method === "get_public_key") result = window.__nip46Pubkey;
else if (req.method === "sign_event") result = JSON.stringify(NT.finalizeEvent(JSON.parse(req.params[0]), skBytes));
const content = NT.nip44.encrypt(JSON.stringify({ id: req.id, result }), ck);
const resp = NT.finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [["p", ev.pubkey]], content }, skBytes);
const sub = this.sub;
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", sub, resp]) }), 1);
return;
}
window.__published.push(ev);
setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", ev.id, true, ""]) }), 1);
}
close() {}
}
window.WebSocket = FakeWS;
}, { author: AUTHOR, flyerId: FLYER_ID });
await page.goto("/board.html");
const signerPubkey = await page.evaluate(() => {
const sk = window.NostrTools.generateSecretKey();
window.__nip46SkHex = Array.from(sk).map((b) => b.toString(16).padStart(2, "0")).join("");
window.__nip46Pubkey = window.NostrTools.getPublicKey(sk);
return window.__nip46Pubkey;
});
// Connect the board via the bunker link.
await page.locator("#board-connect-nip46-toggle").click();
await page.locator("#board-nip46-input").fill(`bunker://${signerPubkey}?relay=wss://relay.damus.io&secret=x`);
await page.locator("#board-nip46-submit").click();
// The board reveals as the remote-signer identity and shows the flyer.
await expect(page.locator("#board-content")).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toBe(signerPubkey);
const theirRow = page.locator("#board-rows tr", { hasText: "Stranger Flyer" });
await expect(theirRow).toBeVisible();
// Reporting signs through the remote signer (sign_event round-trip).
await theirRow.locator(".board-menu-btn").click({ force: true });
await theirRow.locator('.board-menu-item[data-act="report"]').click({ force: true });
await page.locator("#board-report-confirm").click({ force: true });
await expect(page.locator("#board-rows")).not.toContainText("Stranger Flyer");
const reports = await page.evaluate(() => window.__published.filter((e) => e.kind === 1984));
expect(reports.length).toBeGreaterThan(0);
expect(reports[0].pubkey).toBe(signerPubkey);
expect(reports[0].tags.some((tg) => tg[0] === "p" && tg[1] === AUTHOR)).toBe(true);
});
test("board connects via the NIP-46 nostrconnect (QR) flow", async ({ page }) => {
const errors = trackErrors(page);
await page.addInitScript(nostrConnectSignerInit);
await page.goto("/board.html");
const signerPubkey = await mintSignerKey(page);
await page.locator("#board-connect-nip46-toggle").click();
await page.locator("#board-nip46-scan").click();
// A nostrconnect:// link + QR are produced for the signer to scan.
await expect(page.locator("#board-nip46-uri")).toHaveValue(/^nostrconnect:\/\//);
await expect(page.locator("#board-nip46-qr svg")).toHaveCount(1);
// The fake signer "scans" and connects back → the board reveals as that key.
await expect(page.locator("#board-content")).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toBe(signerPubkey);
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("safety page renders all sections, localizes, and is linked from editor + board", async ({ page }) => {
const errors = trackErrors(page);
await page.goto("/safety.html");
await expect(page.locator("#safety-title")).toHaveText("Safety & privacy");
await expect(page.locator("#safety-protects li")).toHaveCount(4);
await expect(page.locator("#safety-exposes li")).toHaveCount(7);
await expect(page.locator("#safety-tips li")).toHaveCount(5);
// The headline warning (relays see your IP) must be present.
await expect(page.locator("#safety-exposes")).toContainText("IP address");
// Switching language localizes the content and flips direction for RTL.
await page.locator("#safety-lang").selectOption("ar");
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
await expect(page.locator("#safety-title")).toHaveText("الأمان والخصوصية");
await expect(page.locator("#safety-exposes li")).toHaveCount(7);
// Reachable from the editor and the board via a footer link.
await page.goto("/");
await expect(page.locator('.board-link a[href="/safety.html"]')).toHaveCount(1);
await page.goto("/board.html");
await expect(page.locator("#board-safety")).toHaveAttribute("href", "/safety.html");
await expect(page.locator(".board-link #board-safety")).toBeVisible();
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("editor imports an nsec for the session without storing the secret", async ({ page }) => {
await page.goto("/#editor");
const { nsec, npub } = await page.evaluate(() => {
@@ -903,6 +1503,21 @@ test.describe("VoxVera static client", () => {
expect(await page.evaluate(() => localStorage.getItem("voxvera_identity_nsec_enc"))).toBeNull();
});
test("unlock is locked out while the wrong-PIN cooldown is active", async ({ page }) => {
// Seed a locked identity + an active lockout deadline so we exercise the
// enforcement path without running several slow PBKDF2 decrypts.
await page.addInitScript(() => {
localStorage.setItem("voxvera_identity_mode", "nsec");
localStorage.setItem("voxvera_identity_nsec_enc", JSON.stringify({ v: 1, iters: 600000, salt: "AAAA", iv: "BBBB", ct: "CCCC" }));
localStorage.setItem("voxvera_identity_unlock_until", String(Date.now() + 60000));
});
await page.goto("/#editor");
await expect(page.locator("#identity-locked-row")).toBeVisible();
await page.locator("#unlock-pin").fill("1234");
await page.locator("#unlock-key").click();
await expect(page.locator("#identity-error")).toContainText(/too many attempts/i);
});
test("bulletin board language selector localizes the page", async ({ page }) => {
await page.goto("/board.html");
await expect(page.locator("#board-title")).toHaveText("Bulletin Board");
+97
View File
@@ -0,0 +1,97 @@
// @ts-check
// Pure-Node guard (no browser): fails if any localized dictionary is missing a
// key in some language, or has an extra one. We add user-facing strings across
// 14 languages by hand, so this catches drift before it ships as a fallback to
// English (or a raw key) for some users.
const { test, expect } = require("@playwright/test");
const fs = require("fs");
const path = require("path");
function readSite(file) {
return fs.readFileSync(path.join(__dirname, "..", "site", file), "utf8");
}
// Extract the first balanced { ... } object literal after a marker, string-aware
// so braces inside strings don't throw off the depth count.
function extractObject(src, marker) {
const start = src.indexOf(marker);
if (start === -1) throw new Error(`marker not found: ${marker}`);
const open = src.indexOf("{", start);
let depth = 0;
let str = null;
let esc = false;
for (let j = open; j < src.length; j += 1) {
const c = src[j];
if (str) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === str) str = null;
} else if (c === '"' || c === "'" || c === "`") {
str = c;
} else if (c === "{") {
depth += 1;
} else if (c === "}") {
depth -= 1;
if (depth === 0) return src.slice(open, j + 1);
}
}
throw new Error(`unbalanced braces for ${marker}`);
}
function evalObject(objectText) {
// The literals are pure data (label strings); safe to evaluate.
return Function(`return (${objectText});`)();
}
function checkParity(label, dict, refLang = "en") {
const langs = Object.keys(dict);
expect(langs, `${label}: no ${refLang} reference`).toContain(refLang);
const ref = Object.keys(dict[refLang]).sort();
for (const lang of langs) {
const keys = Object.keys(dict[lang]).sort();
const missing = ref.filter((k) => !keys.includes(k));
const extra = keys.filter((k) => !ref.includes(k));
expect(missing, `${label}[${lang}] is missing: ${missing.join(", ")}`).toEqual([]);
expect(extra, `${label}[${lang}] has unexpected keys: ${extra.join(", ")}`).toEqual([]);
}
}
test.describe("i18n key parity", () => {
test("NOSTR_UI defines every key in all languages", () => {
const dict = evalObject(extractObject(readSite("nostr-client.js"), "const NOSTR_UI ="));
expect(Object.keys(dict).length).toBe(14);
checkParity("NOSTR_UI", dict);
});
test("BOARD_UI defines every key in all languages", () => {
const dict = evalObject(extractObject(readSite("board.js"), "const BOARD_UI ="));
expect(Object.keys(dict).length).toBe(14);
checkParity("BOARD_UI", dict);
});
test("SAFETY_UI defines every key (and list length) in all languages", () => {
const dict = evalObject(extractObject(readSite("safety.js"), "const SAFETY_UI ="));
expect(Object.keys(dict).length).toBe(14);
checkParity("SAFETY_UI", dict);
// The bullet lists must have the same number of items in every language, or
// some readers silently get fewer warnings than others.
for (const listKey of ["protects", "exposes", "tips"]) {
const refLen = dict.en[listKey].length;
for (const lang of Object.keys(dict)) {
expect(dict[lang][listKey].length, `SAFETY_UI[${lang}].${listKey} length`).toBe(refLen);
}
}
});
test("locales labels and landing match across all languages", () => {
const locales = Function(`var window={}; ${readSite("locales.js")}; return window.VoxVeraLocales;`)();
const labels = {};
const landing = {};
for (const [lang, v] of Object.entries(locales)) {
labels[lang] = v.labels || {};
landing[lang] = v.landing || {};
}
checkParity("locales.labels", labels);
checkParity("locales.landing", landing);
});
});
+53
View File
@@ -9,6 +9,20 @@
/board/... and 404. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/nostr-client.css">
<meta name="description" content="Recent VoxVera flyers published to the Nostr relays, filtered by your web of trust.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="VoxVera">
<meta property="og:title" content="VoxVera — Bulletin Board">
<meta property="og:description" content="Recent VoxVera flyers published to the Nostr relays, filtered by your web of trust.">
<meta property="og:url" content="https://voxvera.org/board">
<meta property="og:image" content="https://voxvera.org/og-card.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="VoxVera — say something. voxvera.org">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="VoxVera — Bulletin Board">
<meta name="twitter:description" content="Recent VoxVera flyers published to the Nostr relays, filtered by your web of trust.">
<meta name="twitter:image" content="https://voxvera.org/og-card.jpg">
</head>
<body class="board-page">
<header class="topbar">
@@ -35,9 +49,28 @@
<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-nip46-toggle" type="button" class="board-connect-alt">Remote signer (NIP-46)</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>
<!-- NIP-46 remote signer (bunker): the local key is ephemeral and
in-memory; the user's secret stays in their signer app. Establishes
the viewer pubkey and keeps the connection so reports/deletes can be
signed remotely this session. -->
<div id="board-nip46-row" class="board-nsec-row" hidden>
<input id="board-nip46-input" type="text" autocomplete="off" spellcheck="false" placeholder="bunker://…">
<button id="board-nip46-submit" type="button" class="board-connect">Connect</button>
<p id="board-nip46-note" class="board-key-note"></p>
<button id="board-nip46-scan" type="button" class="board-connect-alt">Scan with your signer app</button>
<!-- nostrconnect:// QR: this client generates the link; the signer app
scans it and connects back. The local key is ephemeral. -->
<div id="board-nip46-qr-wrap" hidden>
<div id="board-nip46-qr" class="nip46-qr"></div>
<input id="board-nip46-uri" readonly>
<button id="board-nip46-uri-copy" type="button" class="board-connect-alt">Copy link</button>
<p id="board-nip46-scan-status" class="board-key-note" role="status"></p>
</div>
</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>
@@ -67,6 +100,10 @@
<span id="board-blocked-note" class="board-blocked-note" hidden>
<span id="board-blocked-count">0</span>&nbsp;·&nbsp;<button id="board-unblock-all" type="button" class="board-action">Clear blocked</button>
</span>
<label class="board-show-all">
<input type="checkbox" id="board-mine-only">
<span id="board-mine-only-label">My flyers</span>
</label>
<label class="board-show-all">
<input type="checkbox" id="board-show-all">
<span id="board-show-all-label">Show all flyers</span>
@@ -80,6 +117,8 @@
</div>
</main>
<p class="board-link"><a id="board-safety" href="/safety.html">Safety &amp; privacy</a></p>
<div id="board-delete-modal" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="board-delete-title">
<h2 id="board-delete-title">Delete this flyer?</h2>
@@ -91,8 +130,22 @@
</div>
</div>
<div id="board-report-modal" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="board-report-title">
<h2 id="board-report-title">Report this flyer?</h2>
<p id="board-report-message">This publishes a public report to the relays and hides the flyer for you. Reports can't be undone.</p>
<select id="board-report-category" class="board-report-category" aria-label="Report category"></select>
<input id="board-report-reason" type="text" autocomplete="off" spellcheck="false" placeholder="Reason (optional)" maxlength="280">
<div class="modal-actions">
<button id="board-report-cancel" type="button" class="board-action">Cancel</button>
<button id="board-report-confirm" type="button" class="board-action danger">Submit report</button>
</div>
</div>
</div>
<script src="/locales.js"></script>
<script src="/vendor/nostr-tools/nostr.bundle.js"></script>
<script src="/vendor/qrcode-generator/qrcode.js"></script>
<script src="/board.js"></script>
</body>
</html>
+504 -29
View File
@@ -32,22 +32,32 @@
const FALLBACK_CURATOR_PUBKEY = "a4a6b5849bc917b3befd5c81865ee0b88773690609c207ba6588ef3e1e05b95b";
const FALLBACK_LANG = "en";
const LOCALES = window.VoxVeraLocales || {};
// NIP-46 remote signer (bunker). Long timeouts: connect/sign need a human to
// approve in the signer app. Session-only (the bunker token is never stored).
const NIP46_KIND = 24133;
const NIP46_RELAY_TIMEOUT_MS = 12000;
const NIP46_CONNECT_TIMEOUT_MS = 90000;
const NIP46_SIGN_TIMEOUT_MS = 90000;
// Rendezvous relay for the nostrconnect:// (QR) flow — the client proposes it
// and the signer app connects to it. relay.nsec.app is the de-facto NIP-46
// relay the common signer apps already speak.
const NIP46_NOSTRCONNECT_RELAY = "wss://relay.nsec.app";
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", col_event_id: "Event ID", act_edit: "Edit", act_delete: "Delete", act_rebroadcast: "Re-broadcast", act_block: "Block", delete_title: "Delete this flyer?", delete_message: "This requests deletion from the relays and replaces it with a deletion marker so VoxVera hides it. Deletion can't be guaranteed across every relay.", delete_confirm: "Delete", cancel: "Cancel", deleted_done: "Deletion requested.", rebroadcast_done: "Re-broadcast to the relays.", sign_unavailable: "To delete from here, connect with a browser extension or a generated key — or delete from the editor.", unblock_all: "Clear blocked", actions_label: "Actions",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", col_event_id: "ID del evento", act_edit: "Editar", act_delete: "Eliminar", act_rebroadcast: "Reemitir", act_block: "Bloquear", delete_title: "¿Eliminar este cartel?", delete_message: "Esto solicita la eliminación a los relés y lo reemplaza por una marca de eliminación para que VoxVera lo oculte. No se puede garantizar la eliminación en todos los relés.", delete_confirm: "Eliminar", cancel: "Cancelar", deleted_done: "Eliminación solicitada.", rebroadcast_done: "Reemitido a los relés.", sign_unavailable: "Para eliminar desde aquí, conéctate con una extensión del navegador o una clave generada, o elimínalo desde el editor.", unblock_all: "Borrar bloqueados", actions_label: "Acciones", 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", col_event_id: "Event-ID", act_edit: "Bearbeiten", act_delete: "Löschen", act_rebroadcast: "Erneut senden", act_block: "Blockieren", delete_title: "Dieses Plakat löschen?", delete_message: "Dies fordert die Löschung von den Relays an und ersetzt es durch eine Löschmarkierung, sodass VoxVera es ausblendet. Eine Löschung über alle Relays hinweg kann nicht garantiert werden.", delete_confirm: "Löschen", cancel: "Abbrechen", deleted_done: "Löschung angefordert.", rebroadcast_done: "Erneut an die Relays gesendet.", sign_unavailable: "Zum Löschen hier mit einer Browser-Erweiterung oder einem generierten Schlüssel verbinden – oder im Editor löschen.", unblock_all: "Blockierte löschen", actions_label: "Aktionen",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é", col_event_id: "ID de l'événement", act_edit: "Modifier", act_delete: "Supprimer", act_rebroadcast: "Rediffuser", act_block: "Bloquer", delete_title: "Supprimer cette affiche ?", delete_message: "Cela demande la suppression aux relais et la remplace par un marqueur de suppression pour que VoxVera la masque. La suppression ne peut pas être garantie sur tous les relais.", delete_confirm: "Supprimer", cancel: "Annuler", deleted_done: "Suppression demandée.", rebroadcast_done: "Rediffusée aux relais.", sign_unavailable: "Pour supprimer ici, connectez-vous avec une extension de navigateur ou une clé générée — ou supprimez depuis l'éditeur.", unblock_all: "Effacer les blocages", actions_label: "Actions",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: "Опубликовано", col_event_id: "ID события", act_edit: "Изменить", act_delete: "Удалить", act_rebroadcast: "Переслать", act_block: "Заблокировать", delete_title: "Удалить эту листовку?", delete_message: "Это запросит удаление у реле и заменит её маркером удаления, чтобы VoxVera её скрыл. Удаление на всех реле не гарантируется.", delete_confirm: "Удалить", cancel: "Отмена", deleted_done: "Запрошено удаление.", rebroadcast_done: "Переслано на реле.", sign_unavailable: "Чтобы удалить отсюда, подключитесь через расширение браузера или сгенерированный ключ — или удалите в редакторе.", unblock_all: "Очистить заблокированных", actions_label: "Действия",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: "פורסם", col_event_id: "מזהה אירוע", act_edit: "ערוך", act_delete: "מחק", act_rebroadcast: "שדר מחדש", act_block: "חסום", delete_title: "למחוק את הכרזה הזו?", delete_message: "פעולה זו מבקשת מחיקה מהממסרים ומחליפה אותה בסימן מחיקה כך ש-VoxVera יסתיר אותה. לא ניתן להבטיח מחיקה בכל הממסרים.", delete_confirm: "מחק", cancel: "ביטול", deleted_done: "התבקשה מחיקה.", rebroadcast_done: "שודר מחדש לממסרים.", sign_unavailable: "כדי למחוק מכאן, התחבר עם תוסף דפדפן או מפתח שנוצר — או מחק מהעורך.", unblock_all: "נקה חסומים", actions_label: "פעולות",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: "تاريخ النشر", col_event_id: "معرّف الحدث", act_edit: "تعديل", act_delete: "حذف", act_rebroadcast: "إعادة بث", act_block: "حظر", delete_title: "حذف هذا الملصق؟", delete_message: "يطلب هذا الحذف من المرحلات ويستبدله بعلامة حذف حتى يخفيه VoxVera. لا يمكن ضمان الحذف عبر كل مرحل.", delete_confirm: "حذف", cancel: "إلغاء", deleted_done: "تم طلب الحذف.", rebroadcast_done: "أُعيد بثه إلى المرحلات.", sign_unavailable: "للحذف من هنا، اتصل بإضافة متصفح أو مفتاح مُنشأ — أو احذف من المحرر.", unblock_all: "مسح المحظورين", actions_label: "إجراءات",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: "تاریخ انتشار", col_event_id: "شناسه رویداد", act_edit: "ویرایش", act_delete: "حذف", act_rebroadcast: "بازپخش", act_block: "مسدود کردن", delete_title: "این پوستر حذف شود؟", delete_message: "این از رله‌ها درخواست حذف می‌کند و آن را با یک نشان حذف جایگزین می‌کند تا VoxVera آن را پنهان کند. حذف در همه رله‌ها تضمین نمی‌شود.", delete_confirm: "حذف", cancel: "لغو", deleted_done: "درخواست حذف ارسال شد.", rebroadcast_done: "به رله‌ها بازپخش شد.", sign_unavailable: "برای حذف از اینجا، با افزونه مرورگر یا کلید ساخته‌شده متصل شوید — یا از ویرایشگر حذف کنید.", unblock_all: "پاک کردن مسدودها", actions_label: "اقدامات",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: "पोस्ट किया गया", col_event_id: "इवेंट ID", act_edit: "संपादित करें", act_delete: "हटाएँ", act_rebroadcast: "पुनः प्रसारित करें", act_block: "ब्लॉक करें", delete_title: "इस पोस्टर को हटाएँ?", delete_message: "यह रिले से हटाने का अनुरोध करता है और इसे एक विलोपन चिह्न से बदल देता है ताकि VoxVera इसे छिपा दे। हर रिले पर विलोपन की गारंटी नहीं दी जा सकती।", delete_confirm: "हटाएँ", cancel: "रद्द करें", deleted_done: "विलोपन का अनुरोध किया गया।", rebroadcast_done: "रिले पर पुनः प्रसारित किया गया।", sign_unavailable: "यहाँ से हटाने के लिए, ब्राउज़र एक्सटेंशन या जेनरेट की गई कुंजी से कनेक्ट करें — या संपादक से हटाएँ।", unblock_all: "ब्लॉक किए गए साफ़ करें", actions_label: "क्रियाएँ",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 इसे पुनर्प्राप्त नहीं कर सकतLine truncated
ja: { title: "掲示板", subtitle: "リレーに公開された最新のちらし。", col_title: "タイトル", col_link: "リンク", col_author: "投稿者", col_language: "言語", col_date: "投稿日", col_event_id: "イベントID", act_edit: "編集", act_delete: "削除", act_rebroadcast: "再配信", act_block: "ブロック", delete_title: "このちらしを削除しますか?", delete_message: "リレーに削除を要求し、削除マーカーに置き換えて VoxVera で非表示にします。すべてのリレーでの削除は保証できません。", delete_confirm: "削除", cancel: "キャンセル", deleted_done: "削除を要求しました。", rebroadcast_done: "リレーに再配信しました。", sign_unavailable: "ここから削除するには、ブラウザ拡張機能または生成した鍵で接続してください。またはエディターから削除してください。", unblock_all: "ブロックを消去", actions_label: "操作",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", col_event_id: "ID do evento", act_edit: "Editar", act_delete: "Excluir", act_rebroadcast: "Retransmitir", act_block: "Bloquear", delete_title: "Excluir este panfleto?", delete_message: "Isso solicita a exclusão aos relés e o substitui por um marcador de exclusão para que o VoxVera o oculte. A exclusão não pode ser garantida em todos os relés.", delete_confirm: "Excluir", cancel: "Cancelar", deleted_done: "Exclusão solicitada.", rebroadcast_done: "Retransmitido aos relés.", sign_unavailable: "Para excluir aqui, conecte-se com uma extensão de navegador ou uma chave gerada — ou exclua no editor.", unblock_all: "Limpar bloqueados", actions_label: "Ações", 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", col_event_id: "Kitambulisho cha tukio", act_edit: "Hariri", act_delete: "Futa", act_rebroadcast: "Tangaza tena", act_block: "Zuia", delete_title: "Futa bango hili?", delete_message: "Hii inaomba kufuta kutoka relay na kulibadilisha na alama ya kufuta ili VoxVera ilifiche. Kufuta hakuwezi kuhakikishwa kwenye kila relay.", delete_confirm: "Futa", cancel: "Ghairi", deleted_done: "Kufuta kumeombwa.", rebroadcast_done: "Limetangazwa tena kwenye relay.", sign_unavailable: "Ili kufuta hapa, unganisha kwa kiendelezi cha kivinjari au ufunguo uliotengenezwa — au futa kutoka kwa kihariri.", unblock_all: "Futa walizuiliwa", actions_label: "Vitendo",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ı", col_event_id: "Olay ID", act_edit: "Düzenle", act_delete: "Sil", act_rebroadcast: "Yeniden yayınla", act_block: "Engelle", delete_title: "Bu el ilanı silinsin mi?", delete_message: "Bu, rölelerden silmeyi ister ve VoxVera'nın gizlemesi için bir silme işaretiyle değiştirir. Silme her rölede garanti edilemez.", delete_confirm: "Sil", cancel: "İptal", deleted_done: "Silme istendi.", rebroadcast_done: "Rölelere yeniden yayınlandı.", sign_unavailable: "Buradan silmek için bir tarayıcı uzantısı veya oluşturulmuş bir anahtarla bağlan — ya da düzenleyiciden sil.", unblock_all: "Engellenenleri temizle", actions_label: "İşlemler",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: "发布时间", col_event_id: "事件 ID", act_edit: "编辑", act_delete: "删除", act_rebroadcast: "重新广播", act_block: "屏蔽", delete_title: "删除此传单?", delete_message: "这将请求中继删除它,并用删除标记替换它,使 VoxVera 将其隐藏。无法保证在每个中继上都删除。", delete_confirm: "删除", cancel: "取消", deleted_done: "已请求删除。", rebroadcast_done: "已重新广播到中继。", sign_unavailable: "若要在此删除,请使用浏览器扩展或生成的密钥连接——或在编辑器中删除。", unblock_all: "清除屏蔽", actions_label: "操作",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: "继续" }
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", col_event_id: "Event ID", act_edit: "Edit", act_delete: "Delete", act_rebroadcast: "Re-broadcast", act_block: "Block", act_report: "Report", safety_link: "Safety & privacy", connect_nip46: "Remote signer (NIP-46)", nip46_hint: "Paste a bunker:// link from your signer app (e.g. nsec.app, Amber).", nip46_bad_uri: "That doesn't look like a bunker:// link.", nip46_scan: "Scan with your signer app", nip46_waiting: "Waiting for your signer app to connect…", nip46_copy: "Copy link", report_title: "Report this flyer?", report_message: "This publishes a public report to the relays and hides the flyer for you. Reports can't be undone.", report_reason: "Reason (optional)", report_submit: "Submit report", report_done: "Report submitted.", report_unavailable: "To report from here, connect with a browser extension or a generated key.", report_cat_spam: "Spam", report_cat_illegal: "Illegal content", report_cat_impersonation: "Impersonation", report_cat_other: "Other", delete_title: "Delete this flyer?", delete_message: "This requests deletion from the relays and replaces it with a deletion marker so VoxVera hides it. Deletion can't be guaranteed across every relay.", delete_confirm: "Delete", cancel: "Cancel", deleted_done: "Deletion requested.", rebroadcast_done: "Re-broadcast to the relays.", sign_unavailable: "To delete from here, connect with a browser extension or a generated key — or delete from the editor.", unblock_all: "Clear blocked", mine_only: "My flyers", details_label: "Details", actions_label: "Actions",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", col_event_id: "ID del evento", act_edit: "Editar", act_delete: "Eliminar", act_rebroadcast: "Reemitir", act_block: "Bloquear", act_report: "Denunciar", safety_link: "Seguridad y privacidad", connect_nip46: "Firmante remoto (NIP-46)", nip46_hint: "Pega un enlace bunker:// de tu app de firma (p. ej. nsec.app, Amber).", nip46_bad_uri: "Esto no parece un enlace bunker://.", nip46_scan: "Escanear con tu app de firma", nip46_waiting: "Esperando a que tu app de firma se conecte…", nip46_copy: "Copiar enlace", report_title: "¿Denunciar este cartel?", report_message: "Esto publica una denuncia pública en los relés y oculta el cartel para ti. Las denuncias no se pueden deshacer.", report_reason: "Motivo (opcional)", report_submit: "Enviar denuncia", report_done: "Denuncia enviada.", report_unavailable: "Para denunciar desde aquí, conéctate con una extensión de navegador o una clave generada.", report_cat_spam: "Spam", report_cat_illegal: "Contenido ilegal", report_cat_impersonation: "Suplantación", report_cat_other: "Otro", delete_title: "¿Eliminar este cartel?", delete_message: "Esto solicita la eliminación a los relés y lo reemplaza por una marca de eliminación para que VoxVera lo oculte. No se puede garantizar la eliminación en todos los relés.", delete_confirm: "Eliminar", cancel: "Cancelar", deleted_done: "Eliminación solicitada.", rebroadcast_done: "Reemitido a los relés.", sign_unavailable: "Para eliminar desde aquí, conéctate con una extensión del navegador o una clave generada, o elimínalo desde el editor.", unblock_all: "Borrar bloqueados", mine_only: "Mis carteles", details_label: "Detalles", actions_label: "Acciones", 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", col_event_id: "Event-ID", act_edit: "Bearbeiten", act_delete: "Löschen", act_rebroadcast: "Erneut senden", act_block: "Blockieren", act_report: "Melden", safety_link: "Sicherheit & Privatsphäre", connect_nip46: "Externer Signierer (NIP-46)", nip46_hint: "Füge einen bunker://-Link aus deiner Signier-App ein (z. B. nsec.app, Amber).", nip46_bad_uri: "Das sieht nicht nach einem bunker://-Link aus.", nip46_scan: "Mit deiner Signier-App scannen", nip46_waiting: "Warte auf die Verbindung deiner Signier-App…", nip46_copy: "Link kopieren", report_title: "Diesen Flyer melden?", report_message: "Dies veröffentlicht eine öffentliche Meldung auf den Relays und blendet den Flyer für dich aus. Meldungen können nicht rückgängig gemacht werden.", report_reason: "Grund (optional)", report_submit: "Meldung senden", report_done: "Meldung gesendet.", report_unavailable: "Zum Melden von hier verbinde dich mit einer Browser-Erweiterung oder einem generierten Schlüssel.", report_cat_spam: "Spam", report_cat_illegal: "Illegale Inhalte", report_cat_impersonation: "Identitätsdiebstahl", report_cat_other: "Sonstiges", delete_title: "Dieses Plakat löschen?", delete_message: "Dies fordert die Löschung von den Relays an und ersetzt es durch eine Löschmarkierung, sodass VoxVera es ausblendet. Eine Löschung über alle Relays hinweg kann nicht garantiert werden.", delete_confirm: "Löschen", cancel: "Abbrechen", deleted_done: "Löschung angefordert.", rebroadcast_done: "Erneut an die Relays gesendet.", sign_unavailable: "Zum Löschen hier mit einer Browser-Erweiterung oder einem generierten Schlüssel verbinden – oder im Editor löschen.", unblock_all: "Blockierte löschen", mine_only: "Meine Plakate", details_label: "Details", actions_label: "Aktionen",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é", col_event_id: "ID de l'événement", act_edit: "Modifier", act_delete: "Supprimer", act_rebroadcast: "Rediffuser", act_block: "Bloquer", act_report: "Signaler", safety_link: "Sécurité et confidentialité", connect_nip46: "Signataire distant (NIP-46)", nip46_hint: "Collez un lien bunker:// depuis votre app de signature (ex. nsec.app, Amber).", nip46_bad_uri: "Cela ne ressemble pas à un lien bunker://.", nip46_scan: "Scanner avec votre app de signature", nip46_waiting: "En attente de la connexion de votre app de signature…", nip46_copy: "Copier le lien", report_title: "Signaler ce tract ?", report_message: "Ceci publie un signalement public sur les relais et masque le tract pour vous. Les signalements sont irréversibles.", report_reason: "Motif (facultatif)", report_submit: "Envoyer le signalement", report_done: "Signalement envoyé.", report_unavailable: "Pour signaler depuis ici, connectez-vous avec une extension de navigateur ou une clé générée.", report_cat_spam: "Spam", report_cat_illegal: "Contenu illégal", report_cat_impersonation: "Usurpation d'identité", report_cat_other: "Autre", delete_title: "Supprimer cette affiche ?", delete_message: "Cela demande la suppression aux relais et la remplace par un marqueur de suppression pour que VoxVera la masque. La suppression ne peut pas être garantie sur tous les relais.", delete_confirm: "Supprimer", cancel: "Annuler", deleted_done: "Suppression demandée.", rebroadcast_done: "Rediffusée aux relais.", sign_unavailable: "Pour supprimer ici, connectez-vous avec une extension de navigateur ou une clé générée — ou supprimez depuis l'éditeur.", unblock_all: "Effacer les blocages", mine_only: "Mes affiches", details_label: "Détails", actions_label: "Actions",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: "Опубликовано", col_event_id: "ID события", act_edit: "Изменить", act_delete: "Удалить", act_rebroadcast: "Переслать", act_block: "Заблокировать", act_report: "Пожаловаться", safety_link: "Безопасность и конфиденциальность", connect_nip46: "Удалённый подписант (NIP-46)", nip46_hint: "Вставьте ссылку bunker:// из вашего приложения для подписи (например, nsec.app, Amber).", nip46_bad_uri: "Это не похоже на ссылку bunker://.", nip46_scan: "Сканировать приложением для подписи", nip46_waiting: "Ожидание подключения приложения для подписи…", nip46_copy: "Скопировать ссылку", report_title: "Пожаловаться на эту листовку?", report_message: "Это публикует публичную жалобу на реле и скрывает листовку для вас. Жалобу нельзя отменить.", report_reason: "Причина (необязательно)", report_submit: "Отправить жалобу", report_done: "Жалоба отправлена.", report_unavailable: "Чтобы пожаловаться отсюда, подключитесь через расширение браузера или сгенерированный ключ.", report_cat_spam: "Спам", report_cat_illegal: "Незаконный контент", report_cat_impersonation: "Выдача себя за другого", report_cat_other: "Другое", delete_title: "Удалить эту листовку?", delete_message: "Это запросит удаление у реле и заменит её маркером удаления, чтобы VoxVera её скрыл. Удаление на всех реле не гарантируется.", delete_confirm: "Удалить", cancel: "Отмена", deleted_done: "Запрошено удаление.", rebroadcast_done: "Переслано на реле.", sign_unavailable: "Чтобы удалить отсюда, подключитесь через расширение браузера или сгенерированный ключ — или удалите в редакторе.", unblock_all: "Очистить заблокированных", mine_only: "Мои листовки", details_label: "Подробности", actions_label: "Действия",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: "Это ваш новый Line truncated
he: { title: "לוח מודעות", subtitle: "הכרזות האחרונות שפורסמו לממסרים.", col_title: "כותרת", col_link: "קישור", col_author: "פורסם על ידי", col_language: "שפה", col_date: "פורסם", col_event_id: "מזהה אירוע", act_edit: "ערוך", act_delete: "מחק", act_rebroadcast: "שדר מחדש", act_block: "חסום", act_report: "דווח", safety_link: "בטיחות ופרטיות", connect_nip46: "חותם מרוחק (NIP-46)", nip46_hint: "הדבק קישור bunker:// מאפליקציית החתימה שלך (למשל nsec.app או Amber).", nip46_bad_uri: "זה לא נראה כמו קישור bunker://.", nip46_scan: "סרוק עם אפליקציית החתימה", nip46_waiting: "ממתין לחיבור אפליקציית החתימה…", nip46_copy: "העתק קישור", report_title: "לדווח על הפלייר הזה?", report_message: "פעולה זו מפרסמת דיווח ציבורי לממסרים ומסתירה את הפלייר עבורך. לא ניתן לבטל דיווחים.", report_reason: "סיבה (אופציונלי)", report_submit: "שלח דיווח", report_done: "הדיווח נשלח.", report_unavailable: "כדי לדווח מכאן, התחבר עם תוסף דפדפן או מפתח שנוצר.", report_cat_spam: "ספאם", report_cat_illegal: "תוכן בלתי חוקי", report_cat_impersonation: "התחזות", report_cat_other: "אחר", delete_title: "למחוק את הכרזה הזו?", delete_message: "פעולה זו מבקשת מחיקה מהממסרים ומחליפה אותה בסימן מחיקה כך ש-VoxVera יסתיר אותה. לא ניתן להבטיח מחיקה בכל הממסרים.", delete_confirm: "מחק", cancel: "ביטול", deleted_done: "התבקשה מחיקה.", rebroadcast_done: "שודר מחדש לממסרים.", sign_unavailable: "כדי למחוק מכאן, התחבר עם תוסף דפדפן או מפתח שנוצר — או מחק מהעורך.", unblock_all: "נקה חסומים", mine_only: "הכרזות שלי", details_label: "פרטים", actions_label: "פעולות",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: "تاريخ النشر", col_event_id: "معرّف الحدث", act_edit: "تعديل", act_delete: "حذف", act_rebroadcast: "إعادة بث", act_block: "حظر", act_report: "إبلاغ", safety_link: "الأمان والخصوصية", connect_nip46: "موقّع عن بُعد (NIP-46)", nip46_hint: "الصق رابط bunker:// من تطبيق التوقيع لديك (مثل nsec.app أو Amber).", nip46_bad_uri: "لا يبدو هذا رابط bunker://.", nip46_scan: "امسح باستخدام تطبيق التوقيع", nip46_waiting: "بانتظار اتصال تطبيق التوقيع…", nip46_copy: "نسخ الرابط", report_title: "هل تريد الإبلاغ عن هذا الملصق؟", report_message: "يؤدي هذا إلى نشر بلاغ عام على المرحلات وإخفاء الملصق بالنسبة لك. لا يمكن التراجع عن البلاغات.", report_reason: "السبب (اختياري)", report_submit: "إرسال البلاغ", report_done: "تم إرسال البلاغ.", report_unavailable: "للإبلاغ من هنا، اتصل عبر إضافة متصفح أو مفتاح مُولَّد.", report_cat_spam: "بريد مزعج", report_cat_illegal: "محتوى غير قانوني", report_cat_impersonation: "انتحال هوية", report_cat_other: "أخرى", delete_title: "حذف هذا الملصق؟", delete_message: "يطلب هذا الحذف من المرحلات ويستبدله بعلامة حذف حتى يخفيه VoxVera. لا يمكن ضمان الحذف عبر كل مرحل.", delete_confirm: "حذف", cancel: "إلغاء", deleted_done: "تم طلب الحذف.", rebroadcast_done: "أُعيد بثه إلى المرحلات.", sign_unavailable: "للحذف من هنا، اتصل بإضافة متصفح أو مفتاح مُنشأ — أو احذف من المحرر.", unblock_all: "مسح المحظورين", mine_only: "ملصقاتي", details_label: "تفاصيل", actions_label: "إجراءات",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: "تاریخ انتشار", col_event_id: "شناسه رویداد", act_edit: "ویرایش", act_delete: "حذف", act_rebroadcast: "بازپخش", act_block: "مسدود کردن", act_report: "گزارش", safety_link: "ایمنی و حریم خصوصی", connect_nip46: "امضاکننده از راه دور (NIP-46)", nip46_hint: "پیوند bunker:// را از برنامه امضای خود وارد کنید (مثل nsec.app یا Amber).", nip46_bad_uri: "این یک پیوند bunker:// به نظر نمی‌رسد.", nip46_scan: "با برنامه امضای خود اسکن کنید", nip46_waiting: "در انتظار اتصال برنامه امضای شما…", nip46_copy: "کپی پیوند", report_title: "این پوستر گزارش شود؟", report_message: "این یک گزارش عمومی روی رله‌ها منتشر می‌کند و پوستر را برای شما پنهان می‌کند. گزارش‌ها قابل بازگردانی نیستند.", report_reason: "دلیل (اختیاری)", report_submit: "ارسال گزارش", report_done: "گزارش ارسال شد.", report_unavailable: "برای گزارش از اینجا، با افزونه مرورگر یا کلید ساخته‌شده متصل شوید.", report_cat_spam: "هرزنامه", report_cat_illegal: "محتوای غیرقانونی", report_cat_impersonation: "جعل هویت", report_cat_other: "سایر", delete_title: "این پوستر حذف شود؟", delete_message: "این از رله‌ها درخواست حذف می‌کند و آن را با یک نشان حذف جایگزین می‌کند تا VoxVera آن را پنهان کند. حذف در همه رله‌ها تضمین نمی‌شود.", delete_confirm: "حذف", cancel: "لغو", deleted_done: "درخواست حذف ارسال شد.", rebroadcast_done: "به رله‌ها بازپخش شد.", sign_unavailable: "برای حذف از اینجا، با افزونه مرورگر یا کلید ساخته‌شده متصل شوید — یا از ویرایشگر حذف کنید.", unblock_all: "پاک کردن مسدودها", mine_only: "پوسترهای من", details_label: "جزئیات", actions_label: "اقدامات",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: "पोस्ट किया गया", col_event_id: "इवेंट ID", act_edit: "संपादित करें", act_delete: "हटाएँ", act_rebroadcast: "पुनः प्रसारित करें", act_block: "ब्लॉक करें", act_report: "रिपोर्ट करें", safety_link: "सुरक्षा और निजता", connect_nip46: "रिमोट साइनर (NIP-46)", nip46_hint: "अपने साइनर ऐप से bunker:// लिंक पेस्ट करें (जैसे nsec.app, Amber)।", nip46_bad_uri: "यह bunker:// लिंक जैसा नहीं लगता।", nip46_scan: "अपने साइनर ऐप से स्कैन करें", nip46_waiting: "आपके साइनर ऐप के कनेक्ट होने की प्रतीक्षा…", nip46_copy: "लिंक कॉपी करें", report_title: "इस फ़्लायर की रिपोर्ट करें?", report_message: "यह रिले पर एक सार्वजनिक रिपोर्ट प्रकाशित करता है और फ़्लायर को आपके लिए छिपा देता है। रिपोर्ट पूर्ववत नहीं की जा सकती।", report_reason: "कारण (वैकल्पिक)", report_submit: "रिपोर्ट भेजें", report_done: "रिपोर्ट भेजी गई।", report_unavailable: "यहाँ से रिपोर्ट करने के लिए, ब्राउज़र एक्सटेंशन या जनरेट की गई कुंजी से कनेक्ट करें।", report_cat_spam: "स्पैम", report_cat_illegal: "अवैध सामग्री", report_cat_impersonation: "पहचान की नकल", report_cat_other: "अन्य", delete_title: "इस पोस्टर को हटाएँ?", delete_message: "यह रिले से हटाने का अनुरोध करता है और इसे एक विलोपन चिह्न से बदल देता है ताकि VoxVera इसे छिपा दे। हर रिले पर विलोपन की गारंटी नहीं दी जा सकती।", delete_confirm: "हटाएँ", cancel: "रद्द करें", deleted_done: "विलोपन का अनुरोध किया गया।", rebroadcast_done: "रिले पर पुनः प्रसारित किया गया।", sign_unavailable: "यहाँ से हटाने के लिए, ब्राउज़र एक्सटेंशन या जेनरेट की गई कुंजी से कनेक्ट करें — या संपादक से हटाएँ।", unblock_all: "ब्लॉक किए गए साफ़ करें", mine_only: "मेरे पोस्टर", details_label: "विवरण", actions_label: "क्रियाएँ",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 के विशLine truncated
ja: { title: "掲示板", subtitle: "リレーに公開された最新のちらし。", col_title: "タイトル", col_link: "リンク", col_author: "投稿者", col_language: "言語", col_date: "投稿日", col_event_id: "イベントID", act_edit: "編集", act_delete: "削除", act_rebroadcast: "再配信", act_block: "ブロック", act_report: "報告", safety_link: "安全とプライバシー", connect_nip46: "リモート署名(NIP-46)", nip46_hint: "署名アプリの bunker:// リンクを貼り付けてください(例:nsec.app、Amber)。", nip46_bad_uri: "これは bunker:// リンクではないようです。", nip46_scan: "署名アプリでスキャン", nip46_waiting: "署名アプリの接続を待っています…", nip46_copy: "リンクをコピー", report_title: "このちらしを報告しますか?", report_message: "リレーに公開報告を投稿し、このちらしをあなたの表示から隠します。報告は取り消せません。", report_reason: "理由(任意)", report_submit: "報告を送信", report_done: "報告を送信しました。", report_unavailable: "ここから報告するには、ブラウザ拡張機能または生成した鍵で接続してください。", report_cat_spam: "スパム", report_cat_illegal: "違法なコンテンツ", report_cat_impersonation: "なりすまし", report_cat_other: "その他", delete_title: "このちらしを削除しますか?", delete_message: "リレーに削除を要求し、削除マーカーに置き換えて VoxVera で非表示にします。すべてのリレーでの削除は保証できません。", delete_confirm: "削除", cancel: "キャンセル", deleted_done: "削除を要求しました。", rebroadcast_done: "リレーに再配信しました。", sign_unavailable: "ここから削除するには、ブラウザ拡張機能または生成した鍵で接続してください。またはエディターから削除してください。", unblock_all: "ブロックを消去", mine_only: "自分のちらし", details_label: "詳細", actions_label: "操作",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", col_event_id: "ID do evento", act_edit: "Editar", act_delete: "Excluir", act_rebroadcast: "Retransmitir", act_block: "Bloquear", act_report: "Denunciar", safety_link: "Segurança e privacidade", connect_nip46: "Assinador remoto (NIP-46)", nip46_hint: "Cole um link bunker:// do seu app de assinatura (ex.: nsec.app, Amber).", nip46_bad_uri: "Isso não parece um link bunker://.", nip46_scan: "Escanear com seu app de assinatura", nip46_waiting: "Aguardando seu app de assinatura conectar…", nip46_copy: "Copiar link", report_title: "Denunciar este panfleto?", report_message: "Isto publica uma denúncia pública nos relés e oculta o panfleto para você. As denúncias não podem ser desfeitas.", report_reason: "Motivo (opcional)", report_submit: "Enviar denúncia", report_done: "Denúncia enviada.", report_unavailable: "Para denunciar daqui, conecte-se com uma extensão de navegador ou uma chave gerada.", report_cat_spam: "Spam", report_cat_illegal: "Conteúdo ilegal", report_cat_impersonation: "Falsidade ideológica", report_cat_other: "Outro", delete_title: "Excluir este panfleto?", delete_message: "Isso solicita a exclusão aos relés e o substitui por um marcador de exclusão para que o VoxVera o oculte. A exclusão não pode ser garantida em todos os relés.", delete_confirm: "Excluir", cancel: "Cancelar", deleted_done: "Exclusão solicitada.", rebroadcast_done: "Retransmitido aos relés.", sign_unavailable: "Para excluir aqui, conecte-se com uma extensão de navegador ou uma chave gerada — ou exclua no editor.", unblock_all: "Limpar bloqueados", mine_only: "Meus panfletos", details_label: "Detalhes", actions_label: "Ações", 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", col_event_id: "Kitambulisho cha tukio", act_edit: "Hariri", act_delete: "Futa", act_rebroadcast: "Tangaza tena", act_block: "Zuia", act_report: "Ripoti", safety_link: "Usalama na faragha", connect_nip46: "Mtia-saini wa mbali (NIP-46)", nip46_hint: "Bandika kiungo cha bunker:// kutoka programu yako ya kutia saini (k.m. nsec.app, Amber).", nip46_bad_uri: "Hiki hakionekani kama kiungo cha bunker://.", nip46_scan: "Changanua kwa programu yako ya kutia saini", nip46_waiting: "Inasubiri programu yako ya kutia saini iunganishe…", nip46_copy: "Nakili kiungo", report_title: "Ripoti kipeperushi hiki?", report_message: "Hii huchapisha ripoti ya umma kwenye relay na kuficha kipeperushi kwako. Ripoti haziwezi kutenduliwa.", report_reason: "Sababu (hiari)", report_submit: "Tuma ripoti", report_done: "Ripoti imetumwa.", report_unavailable: "Ili kuripoti kutoka hapa, unganisha kwa kiendelezi cha kivinjari au ufunguo ulioundwa.", report_cat_spam: "Taka", report_cat_illegal: "Maudhui haramu", report_cat_impersonation: "Kujifanya mtu mwingine", report_cat_other: "Nyingine", delete_title: "Futa bango hili?", delete_message: "Hii inaomba kufuta kutoka relay na kulibadilisha na alama ya kufuta ili VoxVera ilifiche. Kufuta hakuwezi kuhakikishwa kwenye kila relay.", delete_confirm: "Futa", cancel: "Ghairi", deleted_done: "Kufuta kumeombwa.", rebroadcast_done: "Limetangazwa tena kwenye relay.", sign_unavailable: "Ili kufuta hapa, unganisha kwa kiendelezi cha kivinjari au ufunguo uliotengenezwa — au futa kutoka kwa kihariri.", unblock_all: "Futa walizuiliwa", mine_only: "Mabango yangu", details_label: "Maelezo", actions_label: "Vitendo",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ı", col_event_id: "Olay ID", act_edit: "Düzenle", act_delete: "Sil", act_rebroadcast: "Yeniden yayınla", act_block: "Engelle", act_report: "Bildir", safety_link: "Güvenlik ve gizlilik", connect_nip46: "Uzak imzalayıcı (NIP-46)", nip46_hint: "İmzalama uygulamanızdan bir bunker:// bağlantısı yapıştırın (ör. nsec.app, Amber).", nip46_bad_uri: "Bu bir bunker:// bağlantısına benzemiyor.", nip46_scan: "İmzalama uygulamanızla tarayın", nip46_waiting: "İmzalama uygulamanızın bağlanması bekleniyor…", nip46_copy: "Bağlantıyı kopyala", report_title: "Bu el ilanı bildirilsin mi?", report_message: "Bu, rölelere herkese açık bir bildirim yayımlar ve el ilanını sizin için gizler. Bildirimler geri alınamaz.", report_reason: "Neden (isteğe bağlı)", report_submit: "Bildirim gönder", report_done: "Bildirim gönderildi.", report_unavailable: "Buradan bildirmek için bir tarayıcı uzantısı veya oluşturulmuş bir anahtarla bağlanın.", report_cat_spam: "İstenmeyen", report_cat_illegal: "Yasa dışı içerik", report_cat_impersonation: "Kimlik taklidi", report_cat_other: "Diğer", delete_title: "Bu el ilanı silinsin mi?", delete_message: "Bu, rölelerden silmeyi ister ve VoxVera'nın gizlemesi için bir silme işaretiyle değiştirir. Silme her rölede garanti edilemez.", delete_confirm: "Sil", cancel: "İptal", deleted_done: "Silme istendi.", rebroadcast_done: "Rölelere yeniden yayınlandı.", sign_unavailable: "Buradan silmek için bir tarayıcı uzantısı veya oluşturulmuş bir anahtarla bağlan — ya da düzenleyiciden sil.", unblock_all: "Engellenenleri temizle", mine_only: "El ilanlarım", details_label: "Ayrıntılar", actions_label: "İşlemler",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: "发布时间", col_event_id: "事件 ID", act_edit: "编辑", act_delete: "删除", act_rebroadcast: "重新广播", act_block: "屏蔽", act_report: "举报", safety_link: "安全与隐私", connect_nip46: "远程签名器(NIP-46)", nip46_hint: "粘贴来自签名应用的 bunker:// 链接(如 nsec.app、Amber)。", nip46_bad_uri: "这看起来不是 bunker:// 链接。", nip46_scan: "用你的签名应用扫描", nip46_waiting: "正在等待你的签名应用连接…", nip46_copy: "复制链接", report_title: "举报此传单?", report_message: "这将向中继发布一条公开举报,并为你隐藏该传单。举报无法撤销。", report_reason: "原因(可选)", report_submit: "提交举报", report_done: "举报已提交。", report_unavailable: "要从此处举报,请使用浏览器扩展或生成的密钥进行连接。", report_cat_spam: "垃圾信息", report_cat_illegal: "非法内容", report_cat_impersonation: "冒充他人", report_cat_other: "其他", delete_title: "删除此传单?", delete_message: "这将请求中继删除它,并用删除标记替换它,使 VoxVera 将其隐藏。无法保证在每个中继上都删除。", delete_confirm: "删除", cancel: "取消", deleted_done: "已请求删除。", rebroadcast_done: "已重新广播到中继。", sign_unavailable: "若要在此删除,请使用浏览器扩展或生成的密钥连接——或在编辑器中删除。", unblock_all: "清除屏蔽", mine_only: "我的传单", details_label: "详情", actions_label: "操作",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() {
@@ -180,7 +190,16 @@
if (!e || e.kind !== EVENT_KIND) return;
const tags = e.tags || [];
if (!tags.some((x) => x[0] === "t" && x[1] === "voxvera")) return;
const key = e.pubkey + ":" + dtagOf(tags);
// kind 30078 is a shared "application data" kind, and relays may ignore the
// #t filter (or another app may carry a "voxvera" hashtag), so foreign
// events can arrive. Require VoxVera's own markers: a voxvera: d-tag and a
// voxvera_flyer content payload (real flyers and tombstones both have it).
const d = dtagOf(tags);
if (!/^voxvera:/.test(d)) return;
let payload;
try { payload = JSON.parse(e.content); } catch (_) { return; }
if (!payload || payload.type !== "voxvera_flyer") return;
const key = e.pubkey + ":" + d;
const prev = latest.get(key);
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) latest.set(key, e);
});
@@ -211,10 +230,12 @@
let sortKey = "date";
let sortDir = "desc";
let statusKey = "loading";
let profiles = new Map(); // pubkey -> display name (from kind-0 metadata)
const COLS = [
{ key: "title", label: () => t("col_title"),
cell: (r) => `<span class="board-title">${escapeHtml(r.title || "—")}</span>`,
cell: (r) => `<span class="board-title">${escapeHtml(r.title || "—")}</span>`
+ `<button type="button" class="board-details-toggle" aria-expanded="false">${escapeHtml(t("details_label"))} ▾</button>`,
cmp: (a, b) => (a.title || "").localeCompare(b.title || "") },
{ key: "link", label: () => t("col_link"),
cell: (r) => isSafeUrl(r.link)
@@ -222,8 +243,12 @@
: "—",
cmp: (a, b) => (a.link || "").localeCompare(b.link || "") },
{ key: "author", label: () => t("col_author"),
cell: (r) => `<span class="board-npub" title="${escapeHtml(r.npub)}">${escapeHtml(shortNpub(r.npub))}</span>`,
cmp: (a, b) => (a.npub || "").localeCompare(b.npub || "") },
cell: (r) => {
const npubSpan = `<span class="board-npub" title="${escapeHtml(r.npub)}">${escapeHtml(shortNpub(r.npub))}</span>`;
const name = profiles.get(r.pubkey);
return name ? `<span class="board-author-name">${escapeHtml(name)}</span><br>${npubSpan}` : npubSpan;
},
cmp: (a, b) => (profiles.get(a.pubkey) || a.npub || "").localeCompare(profiles.get(b.pubkey) || b.npub || "") },
{ key: "language", label: () => t("col_language"),
cell: (r) => escapeHtml(langLabel(r.lang)),
cmp: (a, b) => langLabel(a.lang).localeCompare(langLabel(b.lang)) },
@@ -254,7 +279,9 @@
const del = `<button type="button" class="board-menu-item danger" role="menuitem" data-act="delete" data-id="${escapeHtml(r.id)}">${escapeHtml(t("act_delete"))}</button>`;
items = edit + reb + del;
} else {
items = `<button type="button" class="board-menu-item" role="menuitem" data-act="block" data-pubkey="${escapeHtml(r.pubkey)}">${escapeHtml(t("act_block"))}</button>`;
const report = `<button type="button" class="board-menu-item" role="menuitem" data-act="report" data-id="${escapeHtml(r.id)}" data-pubkey="${escapeHtml(r.pubkey)}">${escapeHtml(t("act_report"))}</button>`;
const block = `<button type="button" class="board-menu-item danger" role="menuitem" data-act="block" data-pubkey="${escapeHtml(r.pubkey)}">${escapeHtml(t("act_block"))}</button>`;
items = report + block;
}
return `<div class="board-menu-wrap">`
+ `<button type="button" class="board-menu-btn" data-menu aria-haspopup="true" aria-expanded="false" aria-label="${escapeHtml(t("actions_label"))}">⋯</button>`
@@ -327,7 +354,8 @@
return sortDir === "asc" ? r : -r;
});
document.getElementById("board-rows").innerHTML = sorted
.map((r) => `<tr>${COLS.map((c) => `<td>${c.cell(r)}</td>`).join("")}</tr>`)
.map((r) => `<tr>${COLS.map((c) =>
`<td data-col="${c.key}" data-label="${escapeHtml(c.label())}">${c.cell(r)}</td>`).join("")}</tr>`)
.join("");
}
@@ -380,10 +408,16 @@
setText("board-title", t("title"));
setText("board-subtitle", t("subtitle"));
setText("board-back", t("back"));
setText("board-safety", t("safety_link"));
// 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-nip46-toggle", t("connect_nip46"));
setText("board-nip46-submit", t("connect"));
setText("board-nip46-note", t("nip46_hint"));
setText("board-nip46-scan", t("nip46_scan"));
setText("board-nip46-uri-copy", t("nip46_copy"));
setText("board-connect-nsec-toggle", t("connect_nsec"));
setText("board-generate", t("connect_generate"));
setText("board-nsec-submit", t("connect"));
@@ -398,6 +432,14 @@
setText("board-delete-message", t("delete_message"));
setText("board-delete-confirm", t("delete_confirm"));
setText("board-delete-cancel", t("cancel"));
// Report modal.
setText("board-report-title", t("report_title"));
setText("board-report-message", t("report_message"));
setText("board-report-confirm", t("report_submit"));
setText("board-report-cancel", t("cancel"));
const reasonInput = document.getElementById("board-report-reason");
if (reasonInput) reasonInput.placeholder = t("report_reason");
populateReportCategories();
refreshGateError();
updateFilterControls();
updateBlockedNote();
@@ -425,6 +467,7 @@
let trustSet = new Set(); // hex pubkeys whose flyers we show
let trustSource = "viewer"; // "viewer" | "seeded" | "unavailable"
let showAll = false;
let mineOnly = false;
let boardLoaded = false;
function nip07Available() {
@@ -464,6 +507,11 @@
if (nsecRow) nsecRow.hidden = true;
const nsecInput = document.getElementById("board-nsec-input");
if (nsecInput) nsecInput.value = "";
const nip46Row = document.getElementById("board-nip46-row");
if (nip46Row) nip46Row.hidden = true;
const nip46Input = document.getElementById("board-nip46-input");
if (nip46Input) nip46Input.value = "";
cancelNip46Scan();
const gen = document.getElementById("board-generated");
if (gen) gen.hidden = true;
pendingGeneratedPubkey = null;
@@ -558,6 +606,259 @@
connectWithPubkey(pubkey);
}
// Method 4: NIP-46 remote signer (bunker). The vendored nostr-tools bundle has
// no nip46 module, so this is a minimal hand-rolled client over a raw
// WebSocket (mirrors the editor). It establishes the viewer pubkey AND keeps
// the connection so reports/deletes can be signed remotely this session.
let nip46Signer = null; // { conn, userPubkey } when connected via bunker
function parseBunkerUri(uri) {
const text = String(uri || "").trim();
const match = text.match(/^bunker:\/\/([0-9a-f]{64})\??([\s\S]*)$/i);
if (!match) return null;
let params;
try { params = new URLSearchParams(match[2] || ""); } catch (_) { return null; }
const relays = params.getAll("relay").filter((r) => /^wss?:\/\//i.test(r));
if (!relays.length) return null;
return { remotePubkey: match[1].toLowerCase(), relays, secret: params.get("secret") || "" };
}
// Encrypt with NIP-44 (modern signers), accept either NIP-44 or legacy NIP-04
// when decrypting responses.
function nip46Codec(localSkHex, remotePubkey) {
const tools = window.NostrTools;
let convKey = null;
try { convKey = tools.nip44.getConversationKey(hexToBytes(localSkHex), remotePubkey); } catch (_) {}
return {
encrypt(text) {
if (convKey) return tools.nip44.encrypt(text, convKey);
return tools.nip04.encrypt(localSkHex, remotePubkey, text);
},
decrypt(ciphertext) {
if (convKey) { try { return tools.nip44.decrypt(ciphertext, convKey); } catch (_) {} }
return tools.nip04.decrypt(localSkHex, remotePubkey, ciphertext);
}
};
}
function openNip46Connection({ relay, remotePubkey, localSkHex, localPubkey }) {
const tools = window.NostrTools;
const codec = nip46Codec(localSkHex, remotePubkey);
const subId = "voxvera46-" + Math.random().toString(36).slice(2);
const pending = new Map();
let ws = null;
let closed = false;
function rejectAll(err) {
pending.forEach(({ reject, timer }) => { clearTimeout(timer); reject(err); });
pending.clear();
}
function handleEvent(ev) {
if (!ev || ev.kind !== NIP46_KIND || ev.pubkey !== remotePubkey) return;
let text;
try { text = codec.decrypt(ev.content); } catch (_) { return; }
let msg;
try { msg = JSON.parse(text); } catch (_) { return; }
if (!msg || !msg.id) return;
const slot = pending.get(msg.id);
if (!slot) return;
if (msg.result === "auth_url") {
const url = msg.error || "";
if (/^https?:\/\//i.test(url)) { try { window.open(url, "_blank", "noopener"); } catch (_) {} }
return;
}
pending.delete(msg.id);
clearTimeout(slot.timer);
if (msg.error) slot.reject(new Error(String(msg.error)));
else slot.resolve(msg.result);
}
function open() {
return new Promise((resolve, reject) => {
try { ws = new WebSocket(relay); } catch (e) { reject(e); return; }
const openTimer = setTimeout(() => { try { ws.close(); } catch (_) {} reject(new Error("timeout")); }, NIP46_RELAY_TIMEOUT_MS);
ws.onopen = () => {
clearTimeout(openTimer);
const since = Math.floor(Date.now() / 1000) - 10;
ws.send(JSON.stringify(["REQ", subId, { kinds: [NIP46_KIND], "#p": [localPubkey], since }]));
resolve();
};
ws.onmessage = (m) => {
let data;
try { data = JSON.parse(m.data); } catch (_) { return; }
if (data[0] === "EVENT" && data[1] === subId) handleEvent(data[2]);
};
ws.onerror = () => {};
ws.onclose = () => { if (!closed) rejectAll(new Error("disconnected")); };
});
}
function request(method, params, timeoutMs) {
return new Promise((resolve, reject) => {
if (closed || !ws || ws.readyState !== 1) { reject(new Error("not connected")); return; }
const id = Math.random().toString(36).slice(2);
let content;
try { content = codec.encrypt(JSON.stringify({ id, method, params })); } catch (e) { reject(e); return; }
let signed;
try {
signed = tools.finalizeEvent({
kind: NIP46_KIND, created_at: Math.floor(Date.now() / 1000),
tags: [["p", remotePubkey]], content
}, hexToBytes(localSkHex));
} catch (e) { reject(e); return; }
const timer = setTimeout(() => { pending.delete(id); reject(new Error("timeout")); }, timeoutMs || NIP46_SIGN_TIMEOUT_MS);
pending.set(id, { resolve, reject, timer });
try { ws.send(JSON.stringify(["EVENT", signed])); }
catch (e) { pending.delete(id); clearTimeout(timer); reject(e); }
});
}
function close() {
closed = true;
rejectAll(new Error("disconnected"));
try { ws && ws.close(); } catch (_) {}
ws = null;
}
return { open, request, close };
}
let nip46Scan = null; // { ws, aborted } — the live nostrconnect (QR) listener
function cancelNip46Scan() {
if (nip46Scan) {
nip46Scan.aborted = true;
if (nip46Scan.ws) { try { nip46Scan.ws.close(); } catch (_) {} }
}
nip46Scan = null;
const wrap = document.getElementById("board-nip46-qr-wrap");
if (wrap) wrap.hidden = true;
}
function disconnectNip46() {
cancelNip46Scan();
if (nip46Signer && nip46Signer.conn) { try { nip46Signer.conn.close(); } catch (_) {} }
nip46Signer = null;
}
function toggleNip46Row() {
gateErrorKey = "";
refreshGateError();
const gen = document.getElementById("board-generated");
if (gen) gen.hidden = true;
const nsecRow = document.getElementById("board-nsec-row");
if (nsecRow) nsecRow.hidden = true;
const row = document.getElementById("board-nip46-row");
if (!row) return;
row.hidden = !row.hidden;
if (row.hidden) { cancelNip46Scan(); return; }
const input = document.getElementById("board-nip46-input");
if (input) input.focus();
}
// Adopt a connected NIP-46 transport (shared by bunker + nostrconnect paths):
// learn the user pubkey, keep the signer for reports/deletes, reveal the board.
async function adoptNip46(conn) {
const userPubkey = await conn.request("get_public_key", [], NIP46_CONNECT_TIMEOUT_MS);
if (!/^[0-9a-f]{64}$/i.test(String(userPubkey || ""))) throw new Error("bad pubkey");
disconnectNip46();
nip46Signer = { conn, userPubkey: String(userPubkey).toLowerCase() };
setConnecting(false);
connectWithPubkey(userPubkey);
}
// Bunker flow: the signer app made the link; we paste it and connect out.
async function connectNip46() {
gateErrorKey = "";
refreshGateError();
const input = document.getElementById("board-nip46-input");
const parsed = parseBunkerUri(input && input.value);
if (!parsed) { gateErrorKey = "nip46_bad_uri"; refreshGateError(); return; }
setConnecting(true);
const tools = window.NostrTools;
const skBytes = tools.generateSecretKey();
const localSkHex = Array.from(skBytes, (b) => b.toString(16).padStart(2, "0")).join("");
const localPubkey = tools.getPublicKey(skBytes);
const conn = openNip46Connection({ relay: parsed.relays[0], remotePubkey: parsed.remotePubkey, localSkHex, localPubkey });
try {
await conn.open();
await conn.request("connect", [parsed.remotePubkey, parsed.secret || ""], NIP46_CONNECT_TIMEOUT_MS);
if (input) input.value = "";
await adoptNip46(conn);
} catch (_) {
try { conn.close(); } catch (_) {}
setConnecting(false);
gateErrorKey = "connect_failed";
refreshGateError();
}
}
function boardQrSvg(value) {
if (typeof qrcode !== "function") return "";
try {
const qr = qrcode(0, "M");
qr.addData(value);
qr.make();
return qr.createSvgTag({ cellSize: 2, margin: 2, scalable: true });
} catch (_) { return ""; }
}
function randomHex(bytes) {
const buf = new Uint8Array(bytes || 16);
window.crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
}
// nostrconnect (QR) flow: WE generate the link + QR; the signer app scans it
// and connects back, echoing our secret. We then bind a durable connection.
function startNip46Scan() {
cancelNip46Scan();
gateErrorKey = "";
refreshGateError();
const tools = window.NostrTools;
const skBytes = tools.generateSecretKey();
const clientSkHex = Array.from(skBytes, (b) => b.toString(16).padStart(2, "0")).join("");
const clientPubkey = tools.getPublicKey(skBytes);
const secret = randomHex(16);
const relay = NIP46_NOSTRCONNECT_RELAY;
const uri = `nostrconnect://${clientPubkey}?relay=${encodeURIComponent(relay)}&secret=${secret}&name=VoxVera`;
const qr = document.getElementById("board-nip46-qr"); if (qr) qr.innerHTML = boardQrSvg(uri);
const uriEl = document.getElementById("board-nip46-uri"); if (uriEl) uriEl.value = uri;
const wrap = document.getElementById("board-nip46-qr-wrap"); if (wrap) wrap.hidden = false;
const status = document.getElementById("board-nip46-scan-status"); if (status) status.textContent = t("nip46_waiting");
const state = { ws: null, aborted: false };
nip46Scan = state;
const subId = "voxveranc-" + Math.random().toString(36).slice(2);
let ws;
try { ws = new WebSocket(relay); } catch (_) { gateErrorKey = "connect_failed"; refreshGateError(); return; }
state.ws = ws;
const timer = setTimeout(() => {
if (state.aborted) return;
cancelNip46Scan();
gateErrorKey = "connect_failed";
refreshGateError();
}, NIP46_CONNECT_TIMEOUT_MS);
ws.onopen = () => {
const since = Math.floor(Date.now() / 1000) - 10;
try { ws.send(JSON.stringify(["REQ", subId, { kinds: [NIP46_KIND], "#p": [clientPubkey], since }])); } catch (_) {}
};
ws.onmessage = (m) => {
if (state.aborted) return;
let data; try { data = JSON.parse(m.data); } catch (_) { return; }
if (data[0] !== "EVENT" || data[1] !== subId) return;
const ev = data[2];
if (!ev || ev.kind !== NIP46_KIND) return;
let text; try { text = nip46Codec(clientSkHex, ev.pubkey).decrypt(ev.content); } catch (_) { return; }
let msg; try { msg = JSON.parse(text); } catch (_) { return; }
if (!msg || (msg.result !== secret && msg.result !== "ack")) return;
clearTimeout(timer);
state.aborted = true;
try { ws.close(); } catch (_) {}
const conn = openNip46Connection({ relay, remotePubkey: ev.pubkey, localSkHex: clientSkHex, localPubkey: clientPubkey });
conn.open()
.then(() => adoptNip46(conn))
.catch(() => { try { conn.close(); } catch (_) {} gateErrorKey = "connect_failed"; refreshGateError(); });
};
ws.onerror = () => {};
}
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);
@@ -597,6 +898,9 @@
if (nsecEl) nsecEl.value = key.nsec;
const nsecRow = document.getElementById("board-nsec-row");
if (nsecRow) nsecRow.hidden = true;
const nip46Row = document.getElementById("board-nip46-row");
if (nip46Row) nip46Row.hidden = true;
cancelNip46Scan();
const gen = document.getElementById("board-generated");
if (gen) gen.hidden = false;
}
@@ -610,6 +914,9 @@
refreshGateError();
const gen = document.getElementById("board-generated");
if (gen) gen.hidden = true;
const nip46Row = document.getElementById("board-nip46-row");
if (nip46Row) nip46Row.hidden = true;
cancelNip46Scan();
const row = document.getElementById("board-nsec-row");
if (!row) return;
row.hidden = !row.hidden;
@@ -620,6 +927,7 @@
}
function disconnect() {
disconnectNip46();
connectedPubkey = null;
connectedNpub = null;
try { window.localStorage.removeItem(NPUB_STORAGE_KEY); } catch (_) {}
@@ -652,25 +960,45 @@
.map((x) => x[1].toLowerCase());
}
// Degree-2: fetch the contact lists of a batch of authors and return the union
// of everyone they follow. Bounded so the board doesn't fan out unboundedly.
async function fetchFollowsOfMany(authors) {
const list = Array.from(new Set(authors.filter((a) => /^[0-9a-f]{64}$/i.test(a)))).slice(0, 150);
if (!list.length) return [];
const { events } = await queryRelays(DEFAULT_RELAYS, { kinds: [3], authors: list, limit: 400 }, 6000);
const latest = new Map();
events.forEach((e) => {
if (!e || e.kind !== 3) return;
const prev = latest.get(e.pubkey);
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) latest.set(e.pubkey, e);
});
const out = new Set();
latest.forEach((e) => (e.tags || []).forEach((x) => {
if (x[0] === "p" && /^[0-9a-f]{64}$/i.test(String(x[1] || ""))) out.add(x[1].toLowerCase());
}));
return Array.from(out);
}
// 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;
const set = new Set();
if (viewerFollows.length) {
follows = viewerFollows;
trustSource = "viewer";
viewerFollows.forEach((x) => set.add(x.toLowerCase()));
// Degree-2: also trust the people your follows follow (web of trust).
(await fetchFollowsOfMany(viewerFollows)).forEach((x) => set.add(x));
} else {
const curatorFollows = await fetchFollows(FALLBACK_CURATOR_PUBKEY);
if (curatorFollows.length) {
follows = curatorFollows.concat(FALLBACK_CURATOR_PUBKEY);
trustSource = "seeded";
curatorFollows.forEach((x) => set.add(x.toLowerCase()));
set.add(FALLBACK_CURATOR_PUBKEY);
} else {
follows = [];
trustSource = "unavailable";
}
}
const set = new Set(follows.map((x) => x.toLowerCase()));
if (viewerPubkey) set.add(String(viewerPubkey).toLowerCase());
return set;
}
@@ -687,26 +1015,60 @@
if (chk) chk.checked = showAll;
const lbl = document.getElementById("board-show-all-label");
if (lbl) lbl.textContent = t("show_all_label");
const mineChk = document.getElementById("board-mine-only");
if (mineChk) mineChk.checked = mineOnly;
const mineLbl = document.getElementById("board-mine-only-label");
if (mineLbl) mineLbl.textContent = t("mine_only");
}
// Apply the trust filter to allRows, manage the empty/filtered status, and
// Apply the active filters 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() {
// Blocked authors are hidden regardless of the trust/show-all mode.
const visible = allRows.filter((r) => !blocked.has(r.pubkey));
rows = showAll ? visible.slice() : visible.filter((r) => trustSet.has(r.pubkey));
setStatus(rows.length || showAll ? null : "filter_empty");
if (mineOnly && connectedPubkey) {
// "My flyers" overrides the trust filter — your own flyers may not be in
// your follow graph.
rows = visible.filter((r) => r.pubkey === connectedPubkey);
} else {
rows = showAll ? visible.slice() : visible.filter((r) => trustSet.has(r.pubkey));
}
setStatus(rows.length || (showAll && !mineOnly) ? null : "filter_empty");
updateFilterControls();
updateBlockedNote();
renderBody();
}
// Fetch authors' kind-0 metadata so the board can show display names instead
// of bare npubs. Names are untrusted (escaped at render) and length-capped.
async function fetchProfiles(pubkeys) {
const map = new Map();
const list = Array.from(new Set(pubkeys.filter((p) => /^[0-9a-f]{64}$/i.test(p))));
if (!list.length) return map;
const { events } = await queryRelays(DEFAULT_RELAYS, { kinds: [0], authors: list, limit: 500 }, 5000);
const latest = new Map();
events.forEach((e) => {
if (!e || e.kind !== 0) return;
const prev = latest.get(e.pubkey);
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) latest.set(e.pubkey, e);
});
latest.forEach((e, pk) => {
try {
const meta = JSON.parse(e.content);
const name = String(meta.display_name || meta.name || "").trim().slice(0, 48);
if (name) map.set(pk, name);
} catch (_) {}
});
return map;
}
async function loadBoard() {
if (!window.NostrTools) {
setStatus("error");
return;
}
profiles = new Map();
setStatus("loading");
const [flyerRes, trust] = await Promise.all([
queryRelays(DEFAULT_RELAYS, { kinds: [EVENT_KIND], "#t": ["voxvera"], limit: 200 }, 6000),
@@ -725,6 +1087,10 @@
return;
}
applyFilter();
// Enrich with author display names (kind-0); re-render when they arrive.
fetchProfiles(allRows.map((r) => r.pubkey))
.then((map) => { if (map.size) { profiles = map; renderBody(); } })
.catch(() => {});
}
// ---- Per-row management & blocklist -----------------------------------
@@ -772,6 +1138,15 @@
// connection whose secret was discarded — delete from the editor instead).
async function getBoardSigner() {
const tools = window.NostrTools;
// A live NIP-46 remote signer for the connected identity signs remotely.
if (nip46Signer && nip46Signer.conn && nip46Signer.userPubkey === connectedPubkey) {
return async (unsigned) => {
const res = await nip46Signer.conn.request(
"sign_event", [JSON.stringify({ ...unsigned, pubkey: connectedPubkey })], NIP46_SIGN_TIMEOUT_MS
);
return typeof res === "string" ? JSON.parse(res) : res;
};
}
try {
const hex = window.localStorage.getItem(ANON_SECRET_STORAGE_KEY);
if (hex && /^[0-9a-f]{64}$/i.test(hex)) {
@@ -863,6 +1238,65 @@
flashStatus("deleted_done");
}
// --- NIP-56 reporting (kind 1984) ------------------------------------------
// A report is a public, signed event referencing the flyer (e tag) and its
// author (p tag) with a report type, so other clients and moderators can
// aggregate it. Reporting also hides the flyer locally (pairs with Block).
const REPORT_CATEGORIES = [
{ key: "report_cat_spam", type: "spam" },
{ key: "report_cat_illegal", type: "illegal" },
{ key: "report_cat_impersonation", type: "impersonation" },
{ key: "report_cat_other", type: "other" }
];
let pendingReportRow = null;
function populateReportCategories() {
const sel = document.getElementById("board-report-category");
if (!sel) return;
const previous = sel.value;
sel.innerHTML = REPORT_CATEGORIES
.map((c) => `<option value="${c.type}">${escapeHtml(t(c.key))}</option>`).join("");
if (previous) sel.value = previous;
}
function openReportModal(row) {
pendingReportRow = row;
const reason = document.getElementById("board-report-reason");
if (reason) reason.value = "";
const sel = document.getElementById("board-report-category");
if (sel) sel.value = "spam";
const modal = document.getElementById("board-report-modal");
if (modal) modal.hidden = false;
}
function closeReportModal() {
pendingReportRow = null;
const modal = document.getElementById("board-report-modal");
if (modal) modal.hidden = true;
}
function buildReport(row, type, reason) {
const tags = [["p", row.pubkey]];
if (/^[0-9a-f]{64}$/i.test(row.id)) tags.push(["e", row.id, type]);
else tags[0].push(type);
return { kind: 1984, created_at: nowSec(), tags, content: reason || "" };
}
async function confirmReport() {
const row = pendingReportRow;
const sel = document.getElementById("board-report-category");
const reasonInput = document.getElementById("board-report-reason");
const type = (sel && sel.value) || "other";
const reason = (reasonInput && reasonInput.value || "").trim();
closeReportModal();
if (!row) return;
const signer = await getBoardSigner();
if (!signer) { setStatus("report_unavailable"); return; }
await publishRaw(await signer(buildReport(row, type, reason)));
// Reporting also hides the author for you (and re-renders via applyFilter).
blockAuthor(row.pubkey);
flashStatus("report_done");
}
// One delegated click handler for all per-row action buttons.
function handleRowAction(event) {
const btn = event.target.closest("[data-act]");
@@ -870,6 +1304,9 @@
const act = btn.getAttribute("data-act");
if (act === "block") {
blockAuthor(btn.getAttribute("data-pubkey"));
} else if (act === "report") {
const row = findRowById(btn.getAttribute("data-id"));
if (row) openReportModal(row);
} else if (act === "rebroadcast") {
rebroadcastRow(btn.getAttribute("data-id")).catch(() => {});
} else if (act === "delete") {
@@ -884,6 +1321,21 @@
if (sel) sel.addEventListener("change", () => applyLang(sel.value || FALLBACK_LANG));
const connectBtn = document.getElementById("board-connect");
if (connectBtn) connectBtn.addEventListener("click", connectExtension);
const nip46Toggle = document.getElementById("board-connect-nip46-toggle");
if (nip46Toggle) nip46Toggle.addEventListener("click", toggleNip46Row);
const nip46Submit = document.getElementById("board-nip46-submit");
if (nip46Submit) nip46Submit.addEventListener("click", () => { connectNip46().catch(() => {}); });
const nip46Input = document.getElementById("board-nip46-input");
if (nip46Input) nip46Input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); connectNip46().catch(() => {}); }
});
const nip46ScanBtn = document.getElementById("board-nip46-scan");
if (nip46ScanBtn) nip46ScanBtn.addEventListener("click", startNip46Scan);
const nip46CopyBtn = document.getElementById("board-nip46-uri-copy");
if (nip46CopyBtn) nip46CopyBtn.addEventListener("click", () => {
const u = document.getElementById("board-nip46-uri");
if (u && u.value) navigator.clipboard.writeText(u.value).catch(() => u.select());
});
const nsecToggle = document.getElementById("board-connect-nsec-toggle");
if (nsecToggle) nsecToggle.addEventListener("click", toggleNsecRow);
const nsecSubmit = document.getElementById("board-nsec-submit");
@@ -917,11 +1369,28 @@
if (allRows.length) applyFilter();
else updateFilterControls();
});
const mineChk = document.getElementById("board-mine-only");
if (mineChk) mineChk.addEventListener("change", () => {
mineOnly = mineChk.checked;
if (allRows.length) applyFilter();
else updateFilterControls();
});
// Delegated copy handler for the per-row "copy event ID" buttons (rows are
// re-rendered on sort, so a single listener on the tbody is simplest).
const rowsBody = document.getElementById("board-rows");
if (rowsBody) rowsBody.addEventListener("click", async (event) => {
// Mobile per-row "Details" disclosure.
const detailsBtn = event.target.closest(".board-details-toggle");
if (detailsBtn) {
const row = detailsBtn.closest("tr");
if (row) {
const open = row.classList.toggle("expanded");
detailsBtn.setAttribute("aria-expanded", open ? "true" : "false");
detailsBtn.textContent = `${t("details_label")} ${open ? "▴" : "▾"}`;
}
return;
}
// ⋯ menu toggle, then the per-row actions inside it (edit links navigate).
const menuBtn = event.target.closest("[data-menu]");
if (menuBtn) { toggleRowMenu(menuBtn); return; }
@@ -952,9 +1421,15 @@
if (delConfirm) delConfirm.addEventListener("click", () => { confirmDelete().catch(() => {}); });
const delOverlay = document.getElementById("board-delete-modal");
if (delOverlay) delOverlay.addEventListener("click", (e) => { if (e.target === delOverlay) closeDeleteModal(); });
const repCancel = document.getElementById("board-report-cancel");
if (repCancel) repCancel.addEventListener("click", closeReportModal);
const repConfirm = document.getElementById("board-report-confirm");
if (repConfirm) repConfirm.addEventListener("click", () => { confirmReport().catch(() => {}); });
const repOverlay = document.getElementById("board-report-modal");
if (repOverlay) repOverlay.addEventListener("click", (e) => { if (e.target === repOverlay) closeReportModal(); });
const unblock = document.getElementById("board-unblock-all");
if (unblock) unblock.addEventListener("click", clearBlocked);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDeleteModal(); closeRowMenus(null); } });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDeleteModal(); closeReportModal(); closeRowMenus(null); } });
// Close any open row menu when clicking outside of one.
document.addEventListener("click", (e) => {
if (!e.target.closest(".board-menu-wrap")) closeRowMenus(null);
+51 -2
View File
@@ -6,6 +6,20 @@
<title>VoxVera Nostr Client</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="nostr-client.css">
<meta name="description" content="Create printable flyers backed by decentralized Nostr events. Anonymous by default, and no single host can quietly take them down.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="VoxVera">
<meta property="og:title" content="VoxVera — printable, censorship-resistant flyers">
<meta property="og:description" content="Create printable flyers backed by decentralized Nostr events. Anonymous by default, and no single host can quietly take them down.">
<meta property="og:url" content="https://voxvera.org/">
<meta property="og:image" content="https://voxvera.org/og-card.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="VoxVera — say something. voxvera.org">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="VoxVera — printable, censorship-resistant flyers">
<meta name="twitter:description" content="Create printable flyers backed by decentralized Nostr events. Anonymous by default, and no single host can quietly take them down.">
<meta name="twitter:image" content="https://voxvera.org/og-card.jpg">
</head>
<body>
<header class="topbar">
@@ -51,6 +65,7 @@
<button type="button" id="generate-anon-identity" class="secondary-button">Generate anonymous npub</button>
<button type="button" id="connect-nip07" class="secondary-button">Connect extension</button>
<button type="button" id="use-nsec-toggle" class="secondary-button">Use a private key (nsec)</button>
<button type="button" id="use-nip46-toggle" class="secondary-button">Remote signer (NIP-46)</button>
</div>
<!-- Import a private key. The secret signs in-page; it is only written
to storage (encrypted with the PIN) when "Remember" is checked. -->
@@ -63,6 +78,24 @@
<input id="nsec-pin" type="password" inputmode="numeric" autocomplete="off" placeholder="PIN (4+ digits)" hidden>
<button type="button" id="nsec-submit" class="secondary-button">Use this key</button>
</div>
<!-- Connect a NIP-46 remote signer (bunker). The local key is
ephemeral and in-memory only; the user's secret stays in their
signer app. Session-only: not persisted across reloads. -->
<div id="nip46-import" class="identity-import" hidden>
<span id="nip46-hint" class="board-key-note" data-i18n-key="nip46_hint">Paste a bunker:// link from your signer app.</span>
<input id="nip46-input" type="text" autocomplete="off" spellcheck="false" placeholder="bunker://…">
<button type="button" id="nip46-submit" class="secondary-button">Connect</button>
<span id="nip46-status" class="board-key-note" role="status"></span>
<button type="button" id="nip46-scan" class="secondary-button">Scan with your signer app</button>
<!-- nostrconnect:// QR: the client generates the link; the signer app
scans it and connects back. The local key is ephemeral. -->
<div id="nip46-qr-wrap" hidden>
<div id="nip46-qr" class="nip46-qr"></div>
<input id="nip46-uri" readonly>
<button type="button" id="nip46-uri-copy" class="secondary-button">Copy link</button>
<span id="nip46-scan-status" class="board-key-note" role="status"></span>
</div>
</div>
<!-- Shown on load when a PIN-encrypted identity is stored. -->
<div id="identity-locked-row" class="identity-import" hidden>
<span id="identity-locked-note" data-i18n-key="identity_locked">Saved identity is locked. Enter your PIN to unlock it.</span>
@@ -132,9 +165,13 @@
</label>
<div class="actions">
<button type="button" id="preview-editor">Preview</button>
<button type="button" id="export-event">Export event JSON</button>
<button type="button" id="import-design">Import design</button>
<button type="button" id="export-design">Export design</button>
<button type="submit" id="publish-event">Sign and publish</button>
</div>
<!-- Hidden picker for "Import design"; the design file is content +
relays only (no identity/naddr), so it is portable across keys. -->
<input type="file" id="import-design-input" accept="application/json,.json" hidden>
<!-- Shown only when a flyer you authored is loaded from an event. -->
<div id="loaded-flyer-actions" class="actions" hidden>
<button type="button" id="republish-flyer" class="secondary-button" data-i18n-key="republish">Re-publish to relays</button>
@@ -197,7 +234,7 @@
<button type="button" id="print-preview">Print preview</button>
</div>
<div id="flyer-preview" class="flyer-preview"></div>
<p class="board-link"><a href="board.html" data-i18n-key="bulletin_board">Bulletin board</a></p>
<p class="board-link"><a href="board.html" data-i18n-key="bulletin_board">Bulletin board</a> · <a href="/safety.html" data-i18n-key="safety_link">Safety &amp; privacy</a></p>
</section>
</main>
@@ -209,6 +246,7 @@
<input id="publish-modal-url" type="text" readonly aria-label="Flyer URL">
<button type="button" id="publish-modal-copy">Copy</button>
</div>
<p id="publish-modal-relays" class="modal-relays"></p>
<button type="button" id="publish-modal-close" class="modal-close" data-i18n-key="close">Close</button>
</div>
</div>
@@ -224,6 +262,17 @@
</div>
</div>
<div id="import-modal" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<h2 id="import-modal-title" data-i18n-key="import_confirm_title">Import this design?</h2>
<p id="import-modal-message" data-i18n-key="import_confirm_message">This replaces the flyer currently in the editor.</p>
<div class="modal-actions">
<button type="button" id="import-modal-cancel" class="secondary-button" data-i18n-key="cancel">Cancel</button>
<button type="button" id="import-modal-confirm" class="secondary-button" data-i18n-key="import_button">Import</button>
</div>
</div>
</div>
<script src="locales.js"></script>
<script src="vendor/qrcode-generator/qrcode.js"></script>
<script src="vendor/nostr-tools/nostr.bundle.js"></script>
+1 -1
View File
@@ -1 +1 @@
window.VoxVeraLocales = {"ar":{"meta":{"language_name":"العربية","flag":"🇸🇦","direction":"rtl"},"labels":{"relays":"الروابط","title":"العنوان","subtitle":"العنوان الفرعي","headline":"العنوان الرئيسي","content":"نص الرسالة","url_message":"رسالة الـ URL","poster_url":"URL (رابط المحتوى - مورد خارجي)","footer_message":"رسالة التذييل"},"landing":{"title":"سري للغاية","subtitle":"~~لا~~ للنشر","headline":"عملية VOX VERA","content":"تحرر من الرقابة مع VoxVera. أداة تسويق عصابات ونشر رسائل مدعومة ببروتوكول Nostr. سواء عبر الإنترنت أو في العالم المادي، تساعدك على نشر محتوى المنشورات إلى مرحلات لامركزية ونشر أفكارك بجرأة من دون الاعتماد على خادم مركزي.<br><br>استخدم القوة الميمية لمشاركة أفكارك في مدرستك أو مكان عملك أو مجتمعاتك عبر الإنترنت أو حتى عالمياً. يضمن VoxVera أن يتردد صدى رسالتك على نطاق واسع، مع أقسام قابلة للنزع تحتوي على روابط فريدة وأكواد QR لإعادة الطباعة بسهولة.<br><br>تأتي المرونة من Nostr. يمكن نسخ أحداث مصدر المنشور عبر عدة مرحلات، ويمكن لأي عميل ثابت أو غير متصل جلب الملصق والتحقق منه وعرضه مرة أخرى.<br><br>انضم إلينا في ثورة تقدر الحقيقة والشفافية. معاً، يمكننا بناء شبكة من المواطنين المستنيرين الذين لا يخشون التحدث.","url_message":"احصل على VoxVera وابدأ بنشر رسالتك.","name":"منشورات Vox Vera القابلة للطباعة"}},"de":{"meta":{"language_name":"Deutsch","flag":"🇩🇪"},"labels":{"relays":"Links","title":"Titel","subtitle":"Untertitel","headline":"Schlagzeile","content":"Haupttext","url_message":"URL-Nachricht","poster_url":"URL (Inhaltslink - externe Ressource)","footer_message":"Fußzeilen-Nachricht"},"landing":{"title":"STRENG GEHEIM","subtitle":"~~NICHT~~ VERBREITEN","headline":"OPERATION VOX VERA","content":"Befreien Sie sich von Zensur mit VoxVera. Ein Nostr-gestütztes Werkzeug für Guerilla-Marketing und Nachrichtenverbreitung. Ob online oder in der physischen Welt: Es hilft Ihnen, Flyer-Inhalte auf dezentralen Relays zu veröffentlichen und Ihre Ideen mutig zu verbreiten, ohne von einem zentralen Server abhängig zu sein.<br><br>Nutzen Sie memetische Kraft, um Ihre Ideen in Ihrer Schule, an Ihrem Arbeitsplatz, in Online-Communities oder sogar weltweit zu teilen. VoxVera sorgt dafür, dass Ihre Botschaft weit und breit Resonanz findet, mit Abreißabschnitten mit eindeutigen URLs und QR-Codes zum einfachen Nachdrucken.<br><br>Widerstandsfähigkeit entsteht durch Nostr. Flyer-Quellereignisse können über mehrere Relays gespiegelt werden, während jeder statische oder Offline-Client den Poster erneut abrufen, prüfen und rendern kann.<br><br>Schließen Sie sich uns in einer Revolution an, die Wahrheit und Transparenz schätzt. Gemeinsam können wir ein Netzwerk informierter Bürger aufbauen, die keine Angst haben, ihre Meinung zu sagen.","url_message":"Holen Sie sich VoxVera und beginnen Sie, Ihre Nachricht zu verbreiten.","name":"Vox Vera Druckflyer"}},"en":{"meta":{"language_name":"English","flag":"🇺🇸"},"labels":{"relays":"Links","title":"Title","subtitle":"Subtitle","headline":"Headline","content":"Body text","url_message":"URL message","poster_url":"URL (content link - external resource)","footer_message":"Footer message"},"landing":{"title":"TOP SECRET","subtitle":"DO ~~NOT~~ DISTRIBUTE","headline":"OPERATION VOX VERA","content":"Break free from censorship with VoxVera. A Nostr-powered guerrilla marketing and message-spreading tool. Whether online or in the physical world, it helps you publish flyer content to decentralized relays and spread your ideas boldly without relying on a central server.<br><br>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.<br><br>Resilience comes from Nostr. Flyer source events can be mirrored across relays, while any static or offline client can fetch, verify, and render the poster again.<br><br>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":"Get VoxVera and start spreading your message.","name":"Vox Vera Printable Flyers"}},"es":{"meta":{"language_name":"Español","flag":"🇪🇸"},"labels":{"relays":"Enlaces","title":"Título","subtitle":"Subtítulo","headliLine truncated
window.VoxVeraLocales = {"ar":{"meta":{"language_name":"العربية","flag":"🇸🇦","direction":"rtl"},"labels":{"relays":"الروابط","title":"العنوان","subtitle":"العنوان الفرعي","headline":"العنوان الرئيسي","content":"نص الرسالة","url_message":"رسالة الـ URL","poster_url":"URL (رابط المحتوى - مورد خارجي)","footer_message":"رسالة التذييل"},"landing":{"title":"عملية VOX VERA","subtitle":"~~لا~~ للنشر","headline":"سري للغاية","content":"تحرر من الرقابة مع VoxVera. أداة تسويق عصابات ونشر رسائل مدعومة ببروتوكول Nostr. سواء عبر الإنترنت أو في العالم المادي، تساعدك على نشر محتوى المنشورات إلى مرحلات لامركزية ونشر أفكارك بجرأة من دون الاعتماد على خادم مركزي.<br><br>استخدم القوة الميمية لمشاركة أفكارك في مدرستك أو مكان عملك أو مجتمعاتك عبر الإنترنت أو حتى عالمياً. يضمن VoxVera أن يتردد صدى رسالتك على نطاق واسع، مع أقسام قابلة للنزع تحتوي على روابط فريدة وأكواد QR لإعادة الطباعة بسهولة.<br><br>تأتي المرونة من Nostr. يمكن نسخ أحداث مصدر المنشور عبر عدة مرحلات، ويمكن لأي عميل ثابت أو غير متصل جلب الملصق والتحقق منه وعرضه مرة أخرى.<br><br>انضم إلينا في ثورة تقدر الحقيقة والشفافية. معاً، يمكننا بناء شبكة من المواطنين المستنيرين الذين لا يخشون التحدث.","url_message":"احصل على VoxVera وابدأ بنشر رسالتك.","name":"منشورات Vox Vera القابلة للطباعة"}},"de":{"meta":{"language_name":"Deutsch","flag":"🇩🇪"},"labels":{"relays":"Links","title":"Titel","subtitle":"Untertitel","headline":"Schlagzeile","content":"Haupttext","url_message":"URL-Nachricht","poster_url":"URL (Inhaltslink - externe Ressource)","footer_message":"Fußzeilen-Nachricht"},"landing":{"title":"OPERATION VOX VERA","subtitle":"~~NICHT~~ VERBREITEN","headline":"STRENG GEHEIM","content":"Befreien Sie sich von Zensur mit VoxVera. Ein Nostr-gestütztes Werkzeug für Guerilla-Marketing und Nachrichtenverbreitung. Ob online oder in der physischen Welt: Es hilft Ihnen, Flyer-Inhalte auf dezentralen Relays zu veröffentlichen und Ihre Ideen mutig zu verbreiten, ohne von einem zentralen Server abhängig zu sein.<br><br>Nutzen Sie memetische Kraft, um Ihre Ideen in Ihrer Schule, an Ihrem Arbeitsplatz, in Online-Communities oder sogar weltweit zu teilen. VoxVera sorgt dafür, dass Ihre Botschaft weit und breit Resonanz findet, mit Abreißabschnitten mit eindeutigen URLs und QR-Codes zum einfachen Nachdrucken.<br><br>Widerstandsfähigkeit entsteht durch Nostr. Flyer-Quellereignisse können über mehrere Relays gespiegelt werden, während jeder statische oder Offline-Client den Poster erneut abrufen, prüfen und rendern kann.<br><br>Schließen Sie sich uns in einer Revolution an, die Wahrheit und Transparenz schätzt. Gemeinsam können wir ein Netzwerk informierter Bürger aufbauen, die keine Angst haben, ihre Meinung zu sagen.","url_message":"Holen Sie sich VoxVera und beginnen Sie, Ihre Nachricht zu verbreiten.","name":"Vox Vera Druckflyer"}},"en":{"meta":{"language_name":"English","flag":"🇺🇸"},"labels":{"relays":"Links","title":"Title","subtitle":"Subtitle","headline":"Headline","content":"Body text","url_message":"URL message","poster_url":"URL (content link - external resource)","footer_message":"Footer message"},"landing":{"title":"OPERATION VOX VERA","subtitle":"DO ~~NOT~~ DISTRIBUTE","headline":"TOP SECRET","content":"Break free from censorship with VoxVera. A Nostr-powered guerrilla marketing and message-spreading tool. Whether online or in the physical world, it helps you publish flyer content to decentralized relays and spread your ideas boldly without relying on a central server.<br><br>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.<br><br>Resilience comes from Nostr. Flyer source events can be mirrored across relays, while any static or offline client can fetch, verify, and render the poster again.<br><br>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":"Get VoxVera and start spreading your message.","name":"Vox Vera Printable Flyers"}},"es":{"meta":{"language_name":"Español","flag":"🇪🇸"},"labels":{"relays":"Enlaces","title":"Título","subtitle":"Subtítulo","headliLine truncated
+245 -1
View File
@@ -207,6 +207,12 @@ main {
/* Vertical stack so the input, the "remember" checkbox, the PIN field, and the
action button each get their own line (a wrapping row collided the label and
button). */
/* The `hidden` attribute must win over the display rule below (otherwise the
nsec-import box and the locked-identity row show on every load). */
.identity-import[hidden] {
display: none;
}
.identity-import {
display: flex;
flex-direction: column;
@@ -219,7 +225,8 @@ main {
background: var(--soft);
}
.identity-import input[type="password"] {
.identity-import input[type="password"],
.identity-import input[type="text"] {
width: 100%;
box-sizing: border-box;
font: inherit;
@@ -249,6 +256,43 @@ main {
margin: 0;
}
#nip46-qr-wrap[hidden],
#board-nip46-qr-wrap[hidden] {
display: none;
}
#nip46-qr-wrap,
#board-nip46-qr-wrap {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
margin-top: 8px;
}
.nip46-qr {
width: 200px;
max-width: 100%;
align-self: center;
background: #fff;
padding: 8px;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: 6px;
}
.nip46-qr svg {
display: block;
width: 100%;
height: auto;
}
#nip46-uri,
#board-nip46-uri {
font-family: "Courier New", monospace;
font-size: 11px;
}
/* The locked row keeps the note on its own line, then PIN + buttons below. */
#identity-locked-row {
flex-direction: row;
@@ -548,6 +592,27 @@ a {
flex-direction: column;
}
/* Tear-off title labels each tab. Its font-size is set by fitTabTitles() in JS
to be as large as fits; this caps the height so a long title shrinks (and at
worst clips) instead of pushing the link/event-id out of the 1in tab. */
.tear-off-title {
font-weight: 700;
line-height: 1.05;
text-align: center;
letter-spacing: 0;
max-height: 0.42in;
overflow: hidden;
margin-bottom: 2px;
}
/* The clickable action line under the title (also clickable in a printed PDF). */
.tear-off-link {
display: block;
font-size: 10px;
line-height: 1.15;
text-align: center;
}
.tear-off-event-id {
margin-top: auto;
padding-top: 4px;
@@ -737,6 +802,13 @@ hr {
color: var(--accent);
}
/* Footer "Safety & privacy" link on the board page (mirrors the home page). */
.board-page .board-link {
text-align: center;
font-size: 13px;
margin: 16px 0 28px;
}
body.mode-viewer {
background: #fff;
}
@@ -948,6 +1020,11 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
.flyer-sheet,
.container {
/* Keep the sheet's true print width so the scale math (availableWidth /
sheetWidth) is right. In viewer mode .flyer-preview is a flexbox, and
without flex:none the sheet shrinks to the viewport, making the scale
compute to 1 and the full-width content clip instead of shrinking. */
flex: 0 0 auto;
transform: scale(var(--mobile-flyer-scale));
transform-origin: top left;
margin-bottom: var(--flyer-preview-collapse, calc((1056px * var(--mobile-flyer-scale)) - 1056px));
@@ -1123,6 +1200,11 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
white-space: nowrap;
}
.board-table .board-author-name {
font-weight: 700;
overflow-wrap: anywhere;
}
.board-table .board-date {
white-space: nowrap;
color: var(--muted);
@@ -1242,6 +1324,89 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
color: var(--accent);
}
/* The mobile "Details" disclosure toggle is hidden on desktop (the secondary
fields are their own columns there). */
.board-details-toggle {
display: none;
}
/* Narrow screens: the 7-column table becomes a stacked card per flyer. Title +
the ⋯ menu are always shown; the rest sits behind a per-row "Details" toggle. */
@media (max-width: 640px) {
.board-table thead { display: none; }
.board-table,
.board-table tbody,
.board-table tr,
.board-table td { display: block; width: auto; }
.board-table tr {
position: relative;
border: 1px solid var(--line);
border-radius: 8px;
margin: 0 0 10px;
padding: 10px 44px 10px 12px;
}
.board-table td {
border: none;
padding: 2px 0;
white-space: normal;
}
.board-table td[data-col="title"] { padding-top: 0; }
.board-table td[data-col="title"] .board-title { font-size: 15px; }
.board-table td[data-col="actions"] {
position: absolute;
top: 8px;
inset-inline-end: 8px;
padding: 0;
}
.board-details-toggle {
display: block;
width: fit-content;
margin-top: 4px;
padding: 2px 0;
font: inherit;
font-size: 12px;
background: transparent;
border: none;
color: var(--accent);
cursor: pointer;
user-select: none;
}
/* Secondary fields collapsed until the row is expanded. */
.board-table td[data-col="link"],
.board-table td[data-col="author"],
.board-table td[data-col="language"],
.board-table td[data-col="date"],
.board-table td[data-col="id"] {
display: none;
font-size: 13px;
}
.board-table tr.expanded td[data-col="link"],
.board-table tr.expanded td[data-col="author"],
.board-table tr.expanded td[data-col="language"],
.board-table tr.expanded td[data-col="date"],
.board-table tr.expanded td[data-col="id"] {
display: block;
}
/* Label each revealed field with its column name. */
.board-table tr.expanded td[data-col="link"]::before,
.board-table tr.expanded td[data-col="author"]::before,
.board-table tr.expanded td[data-col="language"]::before,
.board-table tr.expanded td[data-col="date"]::before,
.board-table tr.expanded td[data-col="id"]::before {
content: attr(data-label) ": ";
color: var(--muted);
font-weight: 700;
}
}
.board-table a {
color: var(--accent);
}
@@ -1468,6 +1633,29 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
white-space: nowrap;
}
.board-report-category,
#board-report-reason {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0 0 12px;
padding: 8px 10px;
font: inherit;
border: 1px solid var(--line);
border-radius: 6px;
}
.modal-relays {
margin: 10px 0 0;
font-size: 13px;
color: var(--muted);
}
.modal-relays.modal-relays-warn {
color: var(--accent);
font-weight: 700;
}
.modal-close {
display: block;
margin-left: auto;
@@ -1494,3 +1682,59 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
#loaded-flyer-status {
color: var(--muted);
}
/* --- Safety & privacy page ------------------------------------------------ */
.safety-main {
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 64px;
line-height: 1.55;
}
.safety-intro {
font-size: 16px;
margin: 0 0 28px;
}
.safety-section {
margin: 0 0 28px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.safety-section h2 {
margin: 0 0 12px;
font-size: 18px;
}
.safety-section ul {
margin: 0;
padding-inline-start: 22px;
}
.safety-section li {
margin: 0 0 10px;
}
.safety-section li:last-child {
margin-bottom: 0;
}
/* The "what it does NOT protect" block gets a warning accent so it reads as
the most important section on the page. */
.safety-exposes {
border-color: #d9a300;
background: #fff8e6;
}
.safety-closing {
font-style: italic;
color: var(--muted);
margin: 0;
}
.safety-page .topbar-controls {
gap: 10px;
}
+901 -27
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VoxVera — Safety &amp; privacy</title>
<!-- Root-absolute asset paths: with Vercel cleanUrls + trailingSlash this
page is served at /safety/, so relative paths would 404. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/nostr-client.css">
<meta name="description" content="What VoxVera protects, what it does not, and how to stay safer when publishing flyers.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="VoxVera">
<meta property="og:title" content="VoxVera — Safety & privacy">
<meta property="og:description" content="What VoxVera protects, what it does not, and how to stay safer when publishing flyers.">
<meta property="og:url" content="https://voxvera.org/safety">
<meta property="og:image" content="https://voxvera.org/og-card.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="VoxVera — say something. voxvera.org">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="VoxVera — Safety & privacy">
<meta name="twitter:description" content="What VoxVera protects, what it does not, and how to stay safer when publishing flyers.">
<meta name="twitter:image" content="https://voxvera.org/og-card.jpg">
</head>
<body class="safety-page">
<header class="topbar">
<div>
<h1 id="safety-title">Safety &amp; privacy</h1>
</div>
<div class="topbar-controls">
<a id="safety-back" class="board-back" href="/">← Back to VoxVera</a>
<select id="safety-lang" aria-label="Language"></select>
</div>
</header>
<main class="safety-main">
<p id="safety-intro" class="safety-intro"></p>
<section class="safety-section safety-protects">
<h2 id="safety-protects-h"></h2>
<ul id="safety-protects"></ul>
</section>
<section class="safety-section safety-exposes">
<h2 id="safety-exposes-h"></h2>
<ul id="safety-exposes"></ul>
</section>
<section class="safety-section safety-tips">
<h2 id="safety-tips-h"></h2>
<ul id="safety-tips"></ul>
</section>
<p id="safety-closing" class="safety-closing"></p>
</main>
<script src="/locales.js"></script>
<script src="/safety.js"></script>
</body>
</html>
+545
View File
@@ -0,0 +1,545 @@
(function () {
"use strict";
// Standalone, fully static "Safety & privacy" page. It shares the locale meta
// (direction, language names) from locales.js but keeps its own strings here,
// localized across all 14 languages. Sentences are kept short and plain on
// purpose: this is security-critical content, so it must translate cleanly.
const FALLBACK_LANG = "en";
// Same key the editor and board use, so a language choice carries across pages.
const UI_LANG_STORAGE_KEY = "voxvera_nostr_lang";
const LOCALES = window.VoxVeraLocales || {};
const SAFETY_UI = {
en: {
title: "Safety & privacy",
intro: "VoxVera helps you publish flyers that are hard to censor. On its own it does not keep you anonymous. Please read what it does and does not protect before you rely on it.",
protects_h: "What VoxVera protects",
protects: [
"Flyers are stored across many independent Nostr relays, not on one website, so no single host can quietly remove them.",
"Writing and printing a flyer happens in your browser. Nothing is sent anywhere until you choose to publish.",
"You can publish anonymously. By default VoxVera signs with a throwaway key created on your device, not your real name.",
"Your secret key can stay out of this web page entirely — use a browser extension (NIP-07) or a remote signer app (NIP-46)."
],
exposes_h: "What it does not protect",
exposes: [
"When you publish a flyer or open the bulletin board, the relays can see your IP address. Use Tor or a VPN if your location must stay private.",
"The anonymous key is stored unencrypted in your browser. Anyone who can use your device can read it.",
"An optional PIN only slows down casual snooping. It will not stop someone who copies your stored data and attacks it offline.",
"Anything you publish is public and effectively permanent. Assume it can never be fully deleted, even after a delete request.",
"Pasting a private key (nsec) into any website is risky. Prefer a browser extension or a remote signer instead.",
"Reports you send are public and signed by your key. Blocking, by contrast, stays private on your device.",
"VoxVera has no accounts and does not track you, but the website's host and the relays still see your requests."
],
tips_h: "Staying safer",
tips: [
"If your location must stay private, use Tor Browser or a trusted VPN whenever you publish or browse the board.",
"Treat the anonymous key as disposable. Generate a fresh one when you want a clean separation.",
"Do not sign sensitive flyers with a key that is already linked to your real identity.",
"Keep secret keys in a signer app or extension — never paste them into a web page on a shared or untrusted device.",
"Remember that a printed flyer is permanent. Once it is distributed, you cannot edit or recall it."
],
closing: "VoxVera is a tool, not a guarantee. When your safety depends on it, combine it with Tor or a VPN and careful habits.",
back: "← Back to VoxVera",
link: "Safety & privacy"
},
es: {
title: "Seguridad y privacidad",
intro: "VoxVera te ayuda a publicar carteles difíciles de censurar. Por sí solo no te mantiene anónimo. Lee qué protege y qué no antes de confiar en él.",
protects_h: "Qué protege VoxVera",
protects: [
"Los carteles se guardan en muchos relés Nostr independientes, no en un solo sitio web, así que ningún servidor puede eliminarlos en silencio.",
"Escribir e imprimir un cartel ocurre en tu navegador. No se envía nada hasta que decides publicar.",
"Puedes publicar de forma anónima. Por defecto VoxVera firma con una clave desechable creada en tu dispositivo, no con tu nombre real.",
"Tu clave secreta puede quedar fuera de esta página por completo: usa una extensión de navegador (NIP-07) o una app de firma remota (NIP-46)."
],
exposes_h: "Qué no protege",
exposes: [
"Cuando publicas un cartel o abres el tablón, los relés pueden ver tu dirección IP. Usa Tor o una VPN si tu ubicación debe ser privada.",
"La clave anónima se guarda sin cifrar en tu navegador. Cualquiera que pueda usar tu dispositivo puede leerla.",
"Un PIN opcional solo frena el fisgoneo casual. No detendrá a quien copie tus datos guardados y los ataque sin conexión.",
"Todo lo que publicas es público y prácticamente permanente. Da por hecho que nunca podrá borrarse del todo, ni con una solicitud de borrado.",
"Pegar una clave privada (nsec) en cualquier sitio web es arriesgado. Mejor usa una extensión de navegador o un firmante remoto.",
"Las denuncias que envías son públicas y firmadas con tu clave. El bloqueo, en cambio, queda privado en tu dispositivo.",
"VoxVera no tiene cuentas ni te rastrea, pero el alojamiento del sitio y los relés siguen viendo tus solicitudes."
],
tips_h: "Mantente más seguro",
tips: [
"Si tu ubicación debe ser privada, usa el navegador Tor o una VPN de confianza siempre que publiques o navegues el tablón.",
"Trata la clave anónima como desechable. Genera una nueva cuando quieras una separación limpia.",
"No firmes carteles delicados con una clave que ya esté vinculada a tu identidad real.",
"Guarda las claves secretas en una app de firma o extensión; nunca las pegues en una página web en un dispositivo compartido o no confiable.",
"Recuerda que un cartel impreso es permanente. Una vez distribuido, no puedes editarlo ni retirarlo."
],
closing: "VoxVera es una herramienta, no una garantía. Cuando tu seguridad dependa de ella, combínala con Tor o una VPN y buenos hábitos.",
back: "← Volver a VoxVera",
link: "Seguridad y privacidad"
},
de: {
title: "Sicherheit & Privatsphäre",
intro: "VoxVera hilft dir, Flyer zu veröffentlichen, die schwer zu zensieren sind. Anonym macht es dich allein nicht. Lies bitte, was es schützt und was nicht, bevor du dich darauf verlässt.",
protects_h: "Was VoxVera schützt",
protects: [
"Flyer liegen auf vielen unabhängigen Nostr-Relays, nicht auf einer einzigen Website, sodass kein einzelner Anbieter sie heimlich entfernen kann.",
"Das Schreiben und Drucken eines Flyers passiert in deinem Browser. Es wird nichts gesendet, bis du dich zum Veröffentlichen entscheidest.",
"Du kannst anonym veröffentlichen. Standardmäßig signiert VoxVera mit einem Wegwerf-Schlüssel auf deinem Gerät, nicht mit deinem echten Namen.",
"Dein geheimer Schlüssel kann ganz außerhalb dieser Seite bleiben — nutze eine Browser-Erweiterung (NIP-07) oder eine Remote-Signier-App (NIP-46)."
],
exposes_h: "Was es nicht schützt",
exposes: [
"Wenn du einen Flyer veröffentlichst oder das Schwarze Brett öffnest, können die Relays deine IP-Adresse sehen. Nutze Tor oder ein VPN, wenn dein Standort privat bleiben muss.",
"Der anonyme Schlüssel wird unverschlüsselt in deinem Browser gespeichert. Jeder mit Zugriff auf dein Gerät kann ihn lesen.",
"Eine optionale PIN bremst nur beiläufiges Schnüffeln. Sie stoppt niemanden, der deine gespeicherten Daten kopiert und offline angreift.",
"Alles, was du veröffentlichst, ist öffentlich und praktisch dauerhaft. Geh davon aus, dass es nie vollständig gelöscht werden kann, auch nicht nach einer Löschanfrage.",
"Einen privaten Schlüssel (nsec) in eine Website einzufügen ist riskant. Nutze lieber eine Browser-Erweiterung oder einen Remote-Signierer.",
"Meldungen, die du sendest, sind öffentlich und mit deinem Schlüssel signiert. Das Blockieren bleibt dagegen privat auf deinem Gerät.",
"VoxVera hat keine Konten und verfolgt dich nicht, aber der Hoster der Website und die Relays sehen deine Anfragen weiterhin."
],
tips_h: "Sicherer bleiben",
tips: [
"Wenn dein Standort privat bleiben muss, nutze den Tor-Browser oder ein vertrauenswürdiges VPN, wann immer du veröffentlichst oder das Brett ansiehst.",
"Behandle den anonymen Schlüssel als Wegwerfartikel. Erzeuge einen neuen, wenn du eine saubere Trennung willst.",
"Signiere heikle Flyer nicht mit einem Schlüssel, der bereits mit deiner echten Identität verknüpft ist.",
"Bewahre geheime Schlüssel in einer Signier-App oder Erweiterung auf — füge sie nie auf einem geteilten oder nicht vertrauenswürdigen Gerät in eine Webseite ein.",
"Denk daran, dass ein gedruckter Flyer dauerhaft ist. Einmal verteilt, kannst du ihn weder bearbeiten noch zurückrufen."
],
closing: "VoxVera ist ein Werkzeug, keine Garantie. Wenn deine Sicherheit davon abhängt, kombiniere es mit Tor oder einem VPN und umsichtigem Verhalten.",
back: "← Zurück zu VoxVera",
link: "Sicherheit & Privatsphäre"
},
fr: {
title: "Sécurité et confidentialité",
intro: "VoxVera vous aide à publier des tracts difficiles à censurer. À lui seul, il ne vous rend pas anonyme. Lisez ce qu'il protège et ce qu'il ne protège pas avant de vous y fier.",
protects_h: "Ce que VoxVera protège",
protects: [
"Les tracts sont stockés sur de nombreux relais Nostr indépendants, pas sur un seul site web, donc aucun hébergeur ne peut les supprimer discrètement.",
"La rédaction et l'impression d'un tract se font dans votre navigateur. Rien n'est envoyé tant que vous ne choisissez pas de publier.",
"Vous pouvez publier de façon anonyme. Par défaut, VoxVera signe avec une clé jetable créée sur votre appareil, pas avec votre vrai nom.",
"Votre clé secrète peut rester entièrement hors de cette page : utilisez une extension de navigateur (NIP-07) ou une application de signature distante (NIP-46)."
],
exposes_h: "Ce qu'il ne protège pas",
exposes: [
"Quand vous publiez un tract ou ouvrez le tableau d'affichage, les relais peuvent voir votre adresse IP. Utilisez Tor ou un VPN si votre localisation doit rester privée.",
"La clé anonyme est stockée non chiffrée dans votre navigateur. Toute personne ayant accès à votre appareil peut la lire.",
"Un code PIN facultatif ne fait que ralentir une curiosité occasionnelle. Il n'arrêtera pas quelqu'un qui copie vos données et les attaque hors ligne.",
"Tout ce que vous publiez est public et de fait permanent. Considérez que cela ne pourra jamais être totalement supprimé, même après une demande de suppression.",
"Coller une clé privée (nsec) dans un site web est risqué. Préférez une extension de navigateur ou un signataire distant.",
"Les signalements que vous envoyez sont publics et signés par votre clé. Le blocage, lui, reste privé sur votre appareil.",
"VoxVera n'a pas de comptes et ne vous piste pas, mais l'hébergeur du site et les relais voient toujours vos requêtes."
],
tips_h: "Rester plus en sécurité",
tips: [
"Si votre localisation doit rester privée, utilisez le navigateur Tor ou un VPN de confiance chaque fois que vous publiez ou consultez le tableau.",
"Considérez la clé anonyme comme jetable. Générez-en une nouvelle quand vous voulez une séparation nette.",
"Ne signez pas de tracts sensibles avec une clé déjà liée à votre identité réelle.",
"Gardez les clés secrètes dans une application de signature ou une extension — ne les collez jamais dans une page web sur un appareil partagé ou non fiable.",
"N'oubliez pas qu'un tract imprimé est permanent. Une fois distribué, vous ne pouvez ni le modifier ni le rappeler."
],
closing: "VoxVera est un outil, pas une garantie. Quand votre sécurité en dépend, associez-le à Tor ou un VPN et à de bonnes habitudes.",
back: "← Retour à VoxVera",
link: "Sécurité et confidentialité"
},
ru: {
title: "Безопасность и конфиденциальность",
intro: "VoxVera помогает публиковать листовки, которые трудно подвергнуть цензуре. Сам по себе он не обеспечивает анонимность. Прочитайте, что он защищает, а что нет, прежде чем на него полагаться.",
protects_h: "Что защищает VoxVera",
protects: [
"Листовки хранятся на множестве независимых реле Nostr, а не на одном сайте, поэтому ни один хостер не может тихо их удалить.",
"Создание и печать листовки происходят в вашем браузере. Ничего не отправляется, пока вы не решите опубликовать.",
"Можно публиковать анонимно. По умолчанию VoxVera подписывает одноразовым ключом, созданным на вашем устройстве, а не вашим настоящим именем.",
"Ваш секретный ключ может вообще не попадать на эту страницу — используйте расширение браузера (NIP-07) или приложение удалённой подписи (NIP-46)."
],
exposes_h: "Что он не защищает",
exposes: [
"Когда вы публикуете листовку или открываете доску, реле видят ваш IP-адрес. Используйте Tor или VPN, если ваше местоположение должно оставаться тайным.",
"Анонимный ключ хранится в браузере без шифрования. Любой, кто имеет доступ к вашему устройству, может его прочитать.",
"Необязательный PIN лишь замедляет случайное любопытство. Он не остановит того, кто скопирует ваши данные и будет взламывать их офлайн.",
"Всё, что вы публикуете, публично и фактически вечно. Считайте, что это нельзя полностью удалить, даже после запроса на удаление.",
"Вставлять приватный ключ (nsec) на любой сайт рискованно. Лучше используйте расширение браузера или удалённую подпись.",
"Жалобы, которые вы отправляете, публичны и подписаны вашим ключом. Блокировка же остаётся приватной на вашем устройстве.",
"У VoxVera нет аккаунтов и он вас не отслеживает, но хостинг сайта и реле всё равно видят ваши запросы."
],
tips_h: "Как быть безопаснее",
tips: [
"Если ваше местоположение должно оставаться тайным, используйте браузер Tor или надёжный VPN при публикации и просмотре доски.",
"Относитесь к анонимному ключу как к одноразовому. Создавайте новый, когда нужно чёткое разделение.",
"Не подписывайте чувствительные листовки ключом, который уже связан с вашей настоящей личностью.",
"Храните секретные ключи в приложении подписи или расширении — никогда не вставляйте их на веб-странице на чужом или ненадёжном устройстве.",
"Помните, что напечатанная листовка вечна. После распространения её нельзя ни изменить, ни отозвать."
],
closing: "VoxVera — это инструмент, а не гарантия. Когда от него зависит ваша безопасность, сочетайте его с Tor или VPN и осторожными привычками.",
back: "← Назад к VoxVera",
link: "Безопасность и конфиденциальность"
},
he: {
title: "בטיחות ופרטיות",
intro: "VoxVera עוזר לך לפרסם פליירים שקשה לצנזר. לבדו הוא אינו שומר עליך אנונימי. אנא קרא מה הוא מגן ומה לא לפני שאתה מסתמך עליו.",
protects_h: "על מה VoxVera מגן",
protects: [
"הפליירים נשמרים בריבוי ממסרי Nostr עצמאיים, לא באתר אחד, כך שאף מארח בודד אינו יכול להסיר אותם בשקט.",
"כתיבת הפלייר והדפסתו מתרחשות בדפדפן שלך. שום דבר אינו נשלח עד שתבחר לפרסם.",
"אפשר לפרסם באופן אנונימי. כברירת מחדל VoxVera חותם במפתח חד-פעמי שנוצר במכשירך, לא בשמך האמיתי.",
"המפתח הסודי שלך יכול להישאר מחוץ לדף הזה לחלוטין — השתמש בתוסף דפדפן (NIP-07) או באפליקציית חתימה מרוחקת (NIP-46)."
],
exposes_h: "על מה הוא אינו מגן",
exposes: [
"כאשר אתה מפרסם פלייר או פותח את לוח המודעות, הממסרים יכולים לראות את כתובת ה-IP שלך. השתמש ב-Tor או ב-VPN אם המיקום שלך חייב להישאר פרטי.",
"המפתח האנונימי נשמר ללא הצפנה בדפדפן שלך. כל מי שיכול להשתמש במכשירך יכול לקרוא אותו.",
"קוד PIN אופציונלי רק מאט הצצה מזדמנת. הוא לא יעצור מישהו שמעתיק את הנתונים השמורים שלך ותוקף אותם במצב לא מקוון.",
"כל מה שאתה מפרסם הוא ציבורי וקבוע למעשה. הנח שלעולם לא ניתן יהיה למחוק אותו לחלוטין, גם לאחר בקשת מחיקה.",
"הדבקת מפתח פרטי (nsec) בכל אתר היא מסוכנת. עדיף תוסף דפדפן או חותם מרוחק.",
"דיווחים שאתה שולח הם ציבוריים וחתומים במפתח שלך. חסימה, לעומת זאת, נשארת פרטית במכשירך.",
"ל-VoxVera אין חשבונות והוא אינו עוקב אחריך, אך מארח האתר והממסרים עדיין רואים את הבקשות שלך."
],
tips_h: "להישאר בטוח יותר",
tips: [
"אם המיקום שלך חייב להישאר פרטי, השתמש בדפדפן Tor או ב-VPN מהימן בכל פעם שאתה מפרסם או גולש בלוח.",
"התייחס למפתח האנונימי כאל חד-פעמי. צור חדש כאשר אתה רוצה הפרדה נקייה.",
"אל תחתום על פליירים רגישים במפתח שכבר מקושר לזהותך האמיתית.",
"שמור מפתחות סודיים באפליקציית חתימה או בתוסף — לעולם אל תדביק אותם בדף אינטרנט במכשיר משותף או לא מהימן.",
"זכור שפלייר מודפס הוא קבוע. לאחר שהופץ, אינך יכול לערוך אותו או להחזירו."
],
closing: "VoxVera הוא כלי, לא ערובה. כשבטיחותך תלויה בו, שלב אותו עם Tor או VPN והרגלים זהירים.",
back: "← חזרה ל-VoxVera",
link: "בטיחות ופרטיות"
},
ar: {
title: "الأمان والخصوصية",
intro: "يساعدك VoxVera على نشر ملصقات يصعب فرض الرقابة عليها. لكنه وحده لا يبقيك مجهولاً. يُرجى قراءة ما يحميه وما لا يحميه قبل الاعتماد عليه.",
protects_h: "ما الذي يحميه VoxVera",
protects: [
"تُخزَّن الملصقات على العديد من مرحلات Nostr المستقلة، لا على موقع واحد، فلا يمكن لأي مضيف منفرد إزالتها بهدوء.",
"تتم كتابة الملصق وطباعته في متصفحك. لا يُرسَل أي شيء حتى تختار النشر.",
"يمكنك النشر بشكل مجهول. افتراضياً يوقّع VoxVera بمفتاح يُستخدم لمرة واحدة يُنشأ على جهازك، لا باسمك الحقيقي.",
"يمكن أن يبقى مفتاحك السري خارج هذه الصفحة تماماً — استخدم إضافة متصفح (NIP-07) أو تطبيق توقيع عن بُعد (NIP-46)."
],
exposes_h: "ما الذي لا يحميه",
exposes: [
"عند نشر ملصق أو فتح لوحة الإعلانات، يمكن للمرحلات رؤية عنوان IP الخاص بك. استخدم Tor أو VPN إذا كان يجب أن يبقى موقعك خاصاً.",
"يُخزَّن المفتاح المجهول دون تشفير في متصفحك. يمكن لأي شخص يستطيع استخدام جهازك قراءته.",
"رمز PIN اختياري يبطئ فقط التطفل العابر. لن يوقف من ينسخ بياناتك المخزَّنة ويهاجمها دون اتصال.",
"كل ما تنشره عام ودائم فعلياً. افترض أنه لا يمكن حذفه نهائياً أبداً، حتى بعد طلب الحذف.",
"لصق مفتاح خاص (nsec) في أي موقع أمر خطر. فضّل إضافة متصفح أو موقّعاً عن بُعد.",
"البلاغات التي ترسلها عامة وموقَّعة بمفتاحك. أما الحظر فيبقى خاصاً على جهازك.",
"ليس لدى VoxVera حسابات ولا يتتبعك، لكن مضيف الموقع والمرحلات ما زالوا يرون طلباتك."
],
tips_h: "للبقاء أكثر أماناً",
tips: [
"إذا كان يجب أن يبقى موقعك خاصاً، استخدم متصفح Tor أو VPN موثوقاً كلما نشرت أو تصفحت اللوحة.",
"تعامل مع المفتاح المجهول كأنه للاستخدام مرة واحدة. أنشئ مفتاحاً جديداً عندما تريد فصلاً نظيفاً.",
"لا توقّع الملصقات الحساسة بمفتاح مرتبط بالفعل بهويتك الحقيقية.",
"احتفظ بالمفاتيح السرية في تطبيق توقيع أو إضافة — ولا تلصقها أبداً في صفحة ويب على جهاز مشترك أو غير موثوق.",
"تذكّر أن الملصق المطبوع دائم. بمجرد توزيعه، لا يمكنك تعديله أو سحبه."
],
closing: "VoxVera أداة وليس ضماناً. عندما تعتمد سلامتك عليه، اجمعه مع Tor أو VPN وعادات حذرة.",
back: "← العودة إلى VoxVera",
link: "الأمان والخصوصية"
},
ja: {
title: "安全とプライバシー",
intro: "VoxVera は検閲されにくいちらしの公開を助けます。ただしそれだけで匿名性を保証するものではありません。頼る前に、何を守り何を守らないかをお読みください。",
protects_h: "VoxVera が守るもの",
protects: [
"ちらしは一つのウェブサイトではなく、多数の独立した Nostr リレーに保存されるため、単一のホストがひそかに削除することはできません。",
"ちらしの作成と印刷はブラウザー内で行われます。あなたが公開を選ぶまで、何も送信されません。",
"匿名で公開できます。既定では VoxVera は本名ではなく、端末上で作られた使い捨ての鍵で署名します。",
"秘密鍵はこのページの外に置いたままにできます — ブラウザー拡張機能(NIP-07)またはリモート署名アプリ(NIP-46)を使ってください。"
],
exposes_h: "守らないもの",
exposes: [
"ちらしを公開したり掲示板を開いたりすると、リレーはあなたの IP アドレスを見られます。所在地を秘密にする必要があるなら Tor か VPN を使ってください。",
"匿名の鍵は暗号化されずにブラウザーに保存されます。あなたの端末を使える人なら誰でも読めます。",
"任意の PIN は軽い覗き見を遅らせるだけです。保存データをコピーしてオフラインで攻撃する相手は止められません。",
"公開したものはすべて公開され、事実上恒久的です。削除要求の後でも完全には消せないものと考えてください。",
"秘密鍵(nsec)をどのサイトにも貼り付けるのは危険です。ブラウザー拡張機能かリモート署名を使ってください。",
"あなたが送る報告は公開され、あなたの鍵で署名されます。一方、ブロックは端末内に留まり非公開です。",
"VoxVera にはアカウントがなく追跡もしませんが、サイトのホストとリレーは依然としてあなたのリクエストを見られます。"
],
tips_h: "より安全でいるために",
tips: [
"所在地を秘密にする必要があるなら、公開や掲示板の閲覧のたびに Tor ブラウザーか信頼できる VPN を使ってください。",
"匿名の鍵は使い捨てとして扱ってください。きれいに切り離したいときは新しい鍵を作りましょう。",
"すでに本人の身元に結びついた鍵で、機微なちらしに署名しないでください。",
"秘密鍵は署名アプリか拡張機能に保管し、共有または信頼できない端末のウェブページには決して貼り付けないでください。",
"印刷したちらしは恒久的だと覚えておいてください。配布した後は編集も回収もできません。"
],
closing: "VoxVera は道具であって保証ではありません。安全がそれに懸かるときは、Tor か VPN と慎重な習慣を併用してください。",
back: "← VoxVera に戻る",
link: "安全とプライバシー"
},
hi: {
title: "सुरक्षा और निजता",
intro: "VoxVera आपको ऐसे फ़्लायर प्रकाशित करने में मदद करता है जिन्हें सेंसर करना कठिन है। यह अकेले आपको गुमनाम नहीं रखता। भरोसा करने से पहले पढ़ें कि यह क्या बचाता है और क्या नहीं।",
protects_h: "VoxVera क्या बचाता है",
protects: [
"फ़्लायर एक वेबसाइट पर नहीं, बल्कि कई स्वतंत्र Nostr रिले पर संग्रहीत होते हैं, इसलिए कोई एक होस्ट उन्हें चुपचाप नहीं हटा सकता।",
"फ़्लायर लिखना और छापना आपके ब्राउज़र में होता है। जब तक आप प्रकाशित करना न चुनें, कुछ भी नहीं भेजा जाता।",
"आप गुमनाम रूप से प्रकाशित कर सकते हैं। डिफ़ॉल्ट रूप से VoxVera आपके असली नाम से नहीं, बल्कि आपके डिवाइस पर बनी एक उपयोग-और-फेंक कुंजी से हस्ताक्षर करता है।",
"आपकी गुप्त कुंजी पूरी तरह इस पेज से बाहर रह सकती है — ब्राउज़र एक्सटेंशन (NIP-07) या रिमोट साइनर ऐप (NIP-46) का उपयोग करें।"
],
exposes_h: "यह क्या नहीं बचाता",
exposes: [
"जब आप फ़्लायर प्रकाशित करते हैं या बुलेटिन बोर्ड खोलते हैं, तो रिले आपका IP पता देख सकते हैं। यदि आपका स्थान निजी रहना चाहिए तो Tor या VPN उपयोग करें।",
"गुमनाम कुंजी आपके ब्राउज़र में बिना एन्क्रिप्शन के संग्रहीत होती है। जो भी आपके डिवाइस का उपयोग कर सकता है, वह इसे पढ़ सकता है।",
"एक वैकल्पिक PIN केवल आम ताक-झाँक को धीमा करता है। यह उसे नहीं रोकेगा जो आपके संग्रहीत डेटा की नकल करके उसे ऑफ़लाइन तोड़ता है।",
"आप जो भी प्रकाशित करते हैं वह सार्वजनिक और व्यावहारिक रूप से स्थायी है। मान लें कि इसे कभी पूरी तरह नहीं हटाया जा सकता, हटाने के अनुरोध के बाद भी नहीं।",
"किसी भी वेबसाइट में निजी कुंजी (nsec) चिपकाना जोखिम भरा है। इसके बजाय ब्राउज़र एक्सटेंशन या रिमोट साइनर को प्राथमिकता दें।",
"आपके भेजे गए रिपोर्ट सार्वजनिक होते हैं और आपकी कुंजी से हस्ताक्षरित होते हैं। इसके विपरीत, ब्लॉक करना आपके डिवाइस पर निजी रहता है।",
"VoxVera के कोई खाते नहीं हैं और यह आपको ट्रैक नहीं करता, पर वेबसाइट का होस्ट और रिले फिर भी आपके अनुरोध देखते हैं।"
],
tips_h: "अधिक सुरक्षित रहना",
tips: [
"यदि आपका स्थान निजी रहना चाहिए, तो जब भी प्रकाशित करें या बोर्ड देखें, Tor ब्राउज़र या भरोसेमंद VPN उपयोग करें।",
"गुमनाम कुंजी को उपयोग-और-फेंक मानें। जब साफ़ अलगाव चाहिए तब नई कुंजी बनाएँ।",
"संवेदनशील फ़्लायर को ऐसी कुंजी से हस्ताक्षरित न करें जो पहले से आपकी असली पहचान से जुड़ी हो।",
"गुप्त कुंजियाँ साइनर ऐप या एक्सटेंशन में रखें — साझा या अविश्वसनीय डिवाइस पर किसी वेब पेज में कभी न चिपकाएँ।",
"याद रखें कि छपा हुआ फ़्लायर स्थायी है। एक बार बँट जाने पर आप उसे न संपादित कर सकते हैं न वापस ले सकते हैं।"
],
closing: "VoxVera एक उपकरण है, गारंटी नहीं। जब आपकी सुरक्षा इस पर निर्भर हो, तो इसे Tor या VPN और सावधान आदतों के साथ मिलाएँ।",
back: "← VoxVera पर वापस",
link: "सुरक्षा और निजता"
},
pt: {
title: "Segurança e privacidade",
intro: "O VoxVera ajuda você a publicar panfletos difíceis de censurar. Sozinho, ele não mantém você anônimo. Leia o que ele protege e o que não protege antes de confiar nele.",
protects_h: "O que o VoxVera protege",
protects: [
"Os panfletos ficam guardados em muitos relés Nostr independentes, não em um único site, então nenhum host sozinho pode removê-los em silêncio.",
"Escrever e imprimir um panfleto acontece no seu navegador. Nada é enviado até você escolher publicar.",
"Você pode publicar de forma anônima. Por padrão o VoxVera assina com uma chave descartável criada no seu dispositivo, não com seu nome real.",
"Sua chave secreta pode ficar totalmente fora desta página — use uma extensão de navegador (NIP-07) ou um app de assinatura remota (NIP-46)."
],
exposes_h: "O que ele não protege",
exposes: [
"Quando você publica um panfleto ou abre o quadro de avisos, os relés podem ver seu endereço IP. Use Tor ou uma VPN se sua localização precisa permanecer privada.",
"A chave anônima é guardada sem criptografia no seu navegador. Qualquer um que possa usar seu dispositivo pode lê-la.",
"Um PIN opcional só atrasa a bisbilhotice casual. Não vai deter alguém que copie seus dados guardados e os ataque offline.",
"Tudo o que você publica é público e praticamente permanente. Suponha que nunca poderá ser totalmente apagado, mesmo após um pedido de exclusão.",
"Colar uma chave privada (nsec) em qualquer site é arriscado. Prefira uma extensão de navegador ou um assinador remoto.",
"As denúncias que você envia são públicas e assinadas pela sua chave. O bloqueio, por outro lado, fica privado no seu dispositivo.",
"O VoxVera não tem contas e não rastreia você, mas o host do site e os relés ainda veem suas requisições."
],
tips_h: "Ficar mais seguro",
tips: [
"Se sua localização precisa permanecer privada, use o navegador Tor ou uma VPN confiável sempre que publicar ou navegar pelo quadro.",
"Trate a chave anônima como descartável. Gere uma nova quando quiser uma separação limpa.",
"Não assine panfletos sensíveis com uma chave que já esteja ligada à sua identidade real.",
"Mantenha chaves secretas em um app de assinatura ou extensão — nunca as cole em uma página web em um dispositivo compartilhado ou não confiável.",
"Lembre-se de que um panfleto impresso é permanente. Depois de distribuído, você não pode editá-lo nem recolhê-lo."
],
closing: "O VoxVera é uma ferramenta, não uma garantia. Quando sua segurança depender dele, combine-o com Tor ou uma VPN e hábitos cuidadosos.",
back: "← Voltar ao VoxVera",
link: "Segurança e privacidade"
},
sw: {
title: "Usalama na faragha",
intro: "VoxVera hukusaidia kuchapisha vipeperushi vigumu kudhibitiwa. Peke yake haukufichi utambulisho wako. Tafadhali soma kile kinachokinga na kisichokinga kabla ya kukitegemea.",
protects_h: "Kile VoxVera inakinga",
protects: [
"Vipeperushi huhifadhiwa kwenye relay nyingi huru za Nostr, si tovuti moja, hivyo hakuna mwenyeji mmoja anayeweza kuviondoa kimyakimya.",
"Kuandika na kuchapisha kipeperushi hufanyika kwenye kivinjari chako. Hakuna kinachotumwa hadi uchague kuchapisha.",
"Unaweza kuchapisha bila kujulikana. Kwa kawaida VoxVera hutia saini kwa ufunguo wa matumizi-mara-moja ulioundwa kwenye kifaa chako, si jina lako halisi.",
"Ufunguo wako wa siri unaweza kubaki nje ya ukurasa huu kabisa — tumia kiendelezi cha kivinjari (NIP-07) au programu ya kutia saini kwa mbali (NIP-46)."
],
exposes_h: "Kile kisichokinga",
exposes: [
"Unapochapisha kipeperushi au kufungua ubao wa matangazo, relay zinaweza kuona anwani yako ya IP. Tumia Tor au VPN ikiwa eneo lako lazima libaki la faragha.",
"Ufunguo usiojulikana huhifadhiwa bila usimbaji kwenye kivinjari chako. Yeyote anayeweza kutumia kifaa chako anaweza kuusoma.",
"PIN ya hiari hupunguza tu udadisi wa kawaida. Haitamzuia mtu anayenakili data yako iliyohifadhiwa na kuishambulia nje ya mtandao.",
"Chochote unachochapisha ni cha umma na cha kudumu kivitendo. Dhani kwamba hakiwezi kufutwa kabisa, hata baada ya ombi la kufuta.",
"Kubandika ufunguo wa siri (nsec) kwenye tovuti yoyote ni hatari. Pendelea kiendelezi cha kivinjari au mtia-saini wa mbali.",
"Ripoti unazotuma ni za umma na zimetiwa saini kwa ufunguo wako. Kuzuia, kinyume chake, hubaki faragha kwenye kifaa chako.",
"VoxVera haina akaunti na haikufuatilii, lakini mwenyeji wa tovuti na relay bado wanaona maombi yako."
],
tips_h: "Kubaki salama zaidi",
tips: [
"Ikiwa eneo lako lazima libaki la faragha, tumia kivinjari cha Tor au VPN inayoaminika kila unapochapisha au kuvinjari ubao.",
"Chukulia ufunguo usiojulikana kama wa matumizi-mara-moja. Tengeneza mpya unapotaka utengano safi.",
"Usitie saini vipeperushi nyeti kwa ufunguo ambao tayari umeunganishwa na utambulisho wako halisi.",
"Weka funguo za siri katika programu ya kutia saini au kiendelezi — usizibandike kamwe kwenye ukurasa wa wavuti kwenye kifaa kinachoshirikiwa au kisichoaminika.",
"Kumbuka kwamba kipeperushi kilichochapishwa ni cha kudumu. Mara kikishasambazwa, huwezi kukihariri wala kukirudisha."
],
closing: "VoxVera ni chombo, si dhamana. Usalama wako unapokitegemea, kichanganye na Tor au VPN na tabia za uangalifu.",
back: "← Rudi VoxVera",
link: "Usalama na faragha"
},
tr: {
title: "Güvenlik ve gizlilik",
intro: "VoxVera, sansürlenmesi zor el ilanları yayımlamana yardımcı olur. Tek başına seni anonim tutmaz. Ona güvenmeden önce neyi koruyup neyi korumadığını lütfen oku.",
protects_h: "VoxVera neyi korur",
protects: [
"El ilanları tek bir web sitesinde değil, birçok bağımsız Nostr rölesinde saklanır; böylece tek bir barındırıcı onları sessizce kaldıramaz.",
"El ilanı yazmak ve yazdırmak tarayıcında olur. Sen yayımlamayı seçene dek hiçbir şey gönderilmez.",
"Anonim olarak yayımlayabilirsin. VoxVera varsayılan olarak gerçek adınla değil, cihazında oluşturulan tek kullanımlık bir anahtarla imzalar.",
"Gizli anahtarın bu sayfanın tamamen dışında kalabilir — bir tarayıcı uzantısı (NIP-07) ya da uzaktan imzalama uygulaması (NIP-46) kullan."
],
exposes_h: "Neyi korumaz",
exposes: [
"Bir el ilanı yayımladığında ya da ilan panosunu açtığında röleler IP adresini görebilir. Konumun gizli kalmalıysa Tor veya VPN kullan.",
"Anonim anahtar tarayıcında şifrelenmeden saklanır. Cihazını kullanabilen herkes onu okuyabilir.",
"İsteğe bağlı bir PIN yalnızca gelişigüzel meraklıları yavaşlatır. Saklı verini kopyalayıp çevrimdışı saldıran birini durdurmaz.",
"Yayımladığın her şey herkese açıktır ve fiilen kalıcıdır. Bir silme isteğinden sonra bile asla tümüyle silinemeyeceğini varsay.",
"Özel bir anahtarı (nsec) herhangi bir web sitesine yapıştırmak risklidir. Bunun yerine tarayıcı uzantısı veya uzaktan imzalayıcı tercih et.",
"Gönderdiğin bildirimler herkese açıktır ve anahtarınla imzalanır. Engelleme ise cihazında gizli kalır.",
"VoxVera'nın hesabı yoktur ve seni izlemez, ama sitenin barındırıcısı ve röleler isteklerini yine de görür."
],
tips_h: "Daha güvende kalmak",
tips: [
"Konumun gizli kalmalıysa, yayımladığın ya da panoya göz attığın her seferde Tor Tarayıcı veya güvenilir bir VPN kullan.",
"Anonim anahtarı tek kullanımlık say. Temiz bir ayrım istediğinde yenisini oluştur.",
"Hassas el ilanlarını gerçek kimliğinle zaten bağlantılı bir anahtarla imzalama.",
"Gizli anahtarları bir imzalama uygulamasında ya da uzantıda tut — paylaşılan ya da güvenilmeyen bir cihazda asla bir web sayfasına yapıştırma.",
"Basılı bir el ilanının kalıcı olduğunu unutma. Bir kez dağıtıldığında onu düzenleyemez ya da geri çağıramazsın."
],
closing: "VoxVera bir araçtır, bir garanti değil. Güvenliğin ona bağlı olduğunda onu Tor veya VPN ve dikkatli alışkanlıklarla birlikte kullan.",
back: "← VoxVera'ya dön",
link: "Güvenlik ve gizlilik"
},
zh: {
title: "安全与隐私",
intro: "VoxVera 帮助你发布难以审查的传单。它本身并不能让你保持匿名。在依赖它之前,请阅读它能保护什么、不能保护什么。",
protects_h: "VoxVera 能保护什么",
protects: [
"传单存储在许多独立的 Nostr 中继上,而非单一网站,因此没有任何单一主机能悄悄删除它们。",
"撰写和打印传单都在你的浏览器中进行。在你选择发布之前,不会向任何地方发送任何内容。",
"你可以匿名发布。默认情况下,VoxVera 使用在你设备上生成的一次性密钥签名,而不是你的真实姓名。",
"你的私钥可以完全不进入此页面——使用浏览器扩展(NIP-07)或远程签名应用(NIP-46)。"
],
exposes_h: "它不能保护什么",
exposes: [
"当你发布传单或打开公告栏时,中继可以看到你的 IP 地址。如果你的位置必须保密,请使用 Tor 或 VPN。",
"匿名密钥以未加密形式存储在你的浏览器中。任何能使用你设备的人都能读取它。",
"可选的 PIN 只能减缓随意窥探。它无法阻止复制你存储数据并离线破解的人。",
"你发布的任何内容都是公开且实际上永久的。请假定它永远无法被彻底删除,即使提交了删除请求。",
"把私钥(nsec)粘贴到任何网站都有风险。请优先使用浏览器扩展或远程签名。",
"你提交的举报是公开的,并用你的密钥签名。相比之下,屏蔽只保留在你的设备上、保持私密。",
"VoxVera 没有账户,也不追踪你,但网站主机和中继仍能看到你的请求。"
],
tips_h: "更安全地使用",
tips: [
"如果你的位置必须保密,每次发布或浏览公告栏时都使用 Tor 浏览器或可信的 VPN。",
"把匿名密钥当作一次性的。当你需要彻底分隔时,生成一个新的。",
"不要用已经与你真实身份关联的密钥来签署敏感传单。",
"把私钥保存在签名应用或扩展中——切勿在共享或不可信的设备上把它们粘贴到网页里。",
"请记住,打印出来的传单是永久的。一旦散发出去,你就无法编辑或收回。"
],
closing: "VoxVera 是一个工具,而非保证。当你的安全依赖于它时,请将它与 Tor 或 VPN 以及谨慎的习惯结合使用。",
back: "← 返回 VoxVera",
link: "安全与隐私"
},
fa: {
title: "ایمنی و حریم خصوصی",
intro: "VoxVera به شما کمک می‌کند پوسترهایی منتشر کنید که سانسور آن‌ها دشوار است. به‌تنهایی شما را ناشناس نگه نمی‌دارد. لطفاً پیش از اتکا به آن بخوانید چه چیزی را محافظت می‌کند و چه چیزی را نه.",
protects_h: "VoxVera از چه چیزی محافظت می‌کند",
protects: [
"پوسترها روی رله‌های مستقل و متعدد Nostr ذخیره می‌شوند، نه روی یک وب‌سایت، بنابراین هیچ میزبان واحدی نمی‌تواند آن‌ها را بی‌سروصدا حذف کند.",
"نوشتن و چاپ پوستر در مرورگر شما انجام می‌شود. تا زمانی که انتشار را انتخاب نکنید، چیزی ارسال نمی‌شود.",
"می‌توانید به‌صورت ناشناس منتشر کنید. به‌طور پیش‌فرض VoxVera با یک کلید یک‌بارمصرف که روی دستگاه شما ساخته می‌شود امضا می‌کند، نه با نام واقعی شما.",
"کلید مخفی شما می‌تواند کاملاً بیرون از این صفحه بماند — از افزونه مرورگر (NIP-07) یا برنامه امضای از راه دور (NIP-46) استفاده کنید."
],
exposes_h: "از چه چیزی محافظت نمی‌کند",
exposes: [
"وقتی پوستری منتشر می‌کنید یا تابلوی اعلانات را باز می‌کنید، رله‌ها می‌توانند نشانی IP شما را ببینند. اگر موقعیت شما باید خصوصی بماند از Tor یا VPN استفاده کنید.",
"کلید ناشناس بدون رمزگذاری در مرورگر شما ذخیره می‌شود. هرکس بتواند از دستگاه شما استفاده کند می‌تواند آن را بخواند.",
"یک PIN اختیاری فقط فضولی اتفاقی را کند می‌کند. کسی را که داده‌های ذخیره‌شده شما را کپی و آفلاین حمله می‌کند متوقف نمی‌کند.",
"هرچه منتشر می‌کنید عمومی و عملاً دائمی است. فرض کنید هرگز نمی‌توان آن را به‌طور کامل حذف کرد، حتی پس از درخواست حذف.",
"چسباندن کلید خصوصی (nsec) در هر وب‌سایتی پرخطر است. به‌جای آن افزونه مرورگر یا امضاکننده از راه دور را ترجیح دهید.",
"گزارش‌هایی که می‌فرستید عمومی و با کلید شما امضا شده‌اند. در مقابل، مسدودسازی روی دستگاه شما خصوصی می‌ماند.",
"VoxVera حساب کاربری ندارد و شما را ردیابی نمی‌کند، اما میزبان وب‌سایت و رله‌ها همچنان درخواست‌های شما را می‌بینند."
],
tips_h: "ایمن‌تر ماندن",
tips: [
"اگر موقعیت شما باید خصوصی بماند، هر بار که منتشر می‌کنید یا تابلو را مرور می‌کنید از مرورگر Tor یا یک VPN معتبر استفاده کنید.",
"با کلید ناشناس مانند یک کلید یک‌بارمصرف رفتار کنید. وقتی جداسازی تمیز می‌خواهید کلید تازه‌ای بسازید.",
"پوسترهای حساس را با کلیدی که از پیش به هویت واقعی شما گره خورده امضا نکنید.",
"کلیدهای مخفی را در یک برنامه امضا یا افزونه نگه دارید — هرگز آن‌ها را در یک صفحه وب روی دستگاه مشترک یا نامعتبر نچسبانید.",
"به یاد داشته باشید که پوستر چاپ‌شده دائمی است. پس از توزیع، نمی‌توانید آن را ویرایش یا بازپس‌گیری کنید."
],
closing: "VoxVera یک ابزار است، نه یک تضمین. وقتی ایمنی شما به آن وابسته است، آن را با Tor یا VPN و عادت‌های محتاطانه ترکیب کنید.",
back: "← بازگشت به VoxVera",
link: "ایمنی و حریم خصوصی"
}
};
function pickLang() {
try {
const stored = window.localStorage.getItem(UI_LANG_STORAGE_KEY);
if (stored && SAFETY_UI[stored]) return stored;
} catch (_) {}
const list = (navigator.languages && navigator.languages.length)
? navigator.languages : [navigator.language || ""];
for (const tag of list) {
const base = String(tag || "").split("-")[0];
if (SAFETY_UI[base]) return base;
}
return FALLBACK_LANG;
}
let currentLang = pickLang();
function ui() { return SAFETY_UI[currentLang] || SAFETY_UI[FALLBACK_LANG]; }
function langLabel(code) {
const meta = LOCALES[code] && LOCALES[code].meta;
if (meta && meta.language_name) return (meta.flag ? meta.flag + " " : "") + meta.language_name;
return code || "—";
}
function setText(id, value) {
const node = document.getElementById(id);
if (node) node.textContent = value;
}
function fillList(id, items) {
const ul = document.getElementById(id);
if (!ul) return;
ul.innerHTML = "";
(items || []).forEach((text) => {
const li = document.createElement("li");
li.textContent = text; // textContent: content is trusted, but never inject HTML
ul.appendChild(li);
});
}
function populateLangSelect() {
const sel = document.getElementById("safety-lang");
if (!sel || sel.options.length) return;
Object.keys(SAFETY_UI)
.sort((a, b) => langLabel(a).localeCompare(langLabel(b)))
.forEach((code) => {
const opt = document.createElement("option");
opt.value = code;
opt.textContent = langLabel(code);
sel.appendChild(opt);
});
}
function applyLang(lang) {
currentLang = SAFETY_UI[lang] ? lang : FALLBACK_LANG;
try { window.localStorage.setItem(UI_LANG_STORAGE_KEY, currentLang); } catch (_) {}
const dir = (LOCALES[currentLang] && LOCALES[currentLang].meta && LOCALES[currentLang].meta.direction) || "ltr";
document.documentElement.lang = currentLang;
document.documentElement.dir = dir;
const u = ui();
document.title = "VoxVera — " + u.title;
setText("safety-title", u.title);
setText("safety-intro", u.intro);
setText("safety-protects-h", u.protects_h);
fillList("safety-protects", u.protects);
setText("safety-exposes-h", u.exposes_h);
fillList("safety-exposes", u.exposes);
setText("safety-tips-h", u.tips_h);
fillList("safety-tips", u.tips);
setText("safety-closing", u.closing);
setText("safety-back", u.back);
const sel = document.getElementById("safety-lang");
if (sel) sel.value = currentLang;
}
document.addEventListener("DOMContentLoaded", () => {
populateLangSelect();
const sel = document.getElementById("safety-lang");
if (sel) sel.addEventListener("change", () => applyLang(sel.value || FALLBACK_LANG));
applyLang(currentLang);
});
})();