From 73c28f31ec2d60f2b23bc02421b7bca93df6cfee Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:06:07 -0400 Subject: [PATCH] Bulletin board: per-row manage (edit / re-broadcast / delete) + local Block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add inline management to the board for the connected viewer: - On your own flyers: Edit (links to /?edit=1#, which the main client now honors by loading the flyer straight into the editor), Re-broadcast (re-sends the already-signed event to all relays — no signing), and Delete (replaceable tombstone + NIP-09, signed via the device key or NIP-07; a pasted-nsec connection is pointed to the editor where the key is available). - On flyers from others: Block — a per-device blocklist of author pubkeys (voxvera_blocked_pubkeys) that always hides them, with a "Clear blocked" control. This is roadmap #1's local blocklist direction. The board hides tombstoned flyers (newest-version-wins so a delete can't be undone by an older copy). New non-sortable actions column; a board delete- confirm modal; 12 new strings × 14 languages. Adds e2e for block (hide/persist/ clear), own-vs-other actions, and board delete (tombstone + NIP-09 published), plus a ?edit=1 deep link. 234 e2e across all six engines; docs updated. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- docs/nostr-static-client-spec.md | 9 ++ docs/roadmap.md | 10 +- e2e/README.md | 5 + e2e/flyer.spec.js | 94 +++++++++++- site/board.html | 15 ++ site/board.js | 250 ++++++++++++++++++++++++++++--- site/nostr-client.css | 27 ++++ site/nostr-client.js | 7 +- 9 files changed, 394 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index af4b463..02c8966 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - **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. 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 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. - **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`. ## What Is In `main` diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 6aecd0d..bbd082c 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -276,6 +276,15 @@ de-duplicated per author+`d` identifier, keeping the latest. link). - The npub column is forward-looking — anonymous keys today, but a stable identity per author maps cleanly onto this view later. +- **Per-row actions.** On flyers the connected viewer authored, the board shows + **Edit** (a link to `/?edit=1#`, which loads the flyer in the editor), + **Re-broadcast** (re-sends the already-signed event to all relays — no signing + needed), and **Delete** (tombstone + NIP-09; the board signs via the device + key or NIP-07, and for a pasted-nsec connection it points to the editor). On + flyers from others it shows **Block** — a per-device blocklist of author + 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). 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index fea249c..51ab24c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,7 +25,12 @@ The trust model (#1–#2) has moved from undefined to implemented: loaded flyer (re-publish replaces in place via the `d` tag), re-publish as a keep-alive, or delete it. Delete is belt-and-suspenders: a replaceable **tombstone** (so the board/viewer hide it even on relays that ignore NIP-09) - plus a **NIP-09** deletion request. Best-effort, author-only. + plus a **NIP-09** deletion request. Best-effort, author-only. These actions are + available in the editor and inline on the bulletin board (Edit / Re-broadcast / + Delete on your own rows). +- **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. 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 @@ -64,7 +69,8 @@ the single biggest liability for promoting the board publicly. - Client-side curation: an allowlist of npubs (a "follows"/featured set) so the default view shows only vetted authors. - A local, user-controlled blocklist / "hide this" with `localStorage` - persistence (per-device, no server needed). + persistence (per-device, no server needed). **✅ Shipped** — the board's + per-row **Block** (`voxvera_blocked_pubkeys`). - A dedicated VoxVera relay (or relay set) that applies its own admission policy, with the public relays as fallback. - A "report" affordance that records to a relay/list a curator can act on. diff --git a/e2e/README.md b/e2e/README.md index 84b594b..391d8d3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -40,6 +40,11 @@ engine-sensitive surfaces: reveals the board, leaves no secret in the DOM or storage, and restores on reload from the pubkey alone; an invalid `nsec` errors without connecting; and "Create a new key" reveals the npub/nsec, stores the device key, then connects +- flyer management: editor and board **delete** publish a tombstone + a NIP-09 + deletion (the board/viewer then hide it); a NIP-07 publish builds the naddr + from the signer even before the pubkey loads; the board's **Block** hides an + author locally, persists, and clears; own-flyer rows show Edit/Re-broadcast/ + Delete while others show Block - the editor's publishing identity: defaults to anonymous; connecting NIP-07 (stubbed) signs/publishes under the extension key; importing an `nsec` signs for the session without persisting the secret; an invalid `nsec` errors; a diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index 1c86ded..b474603 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -338,7 +338,7 @@ test.describe("VoxVera static client", () => { await page.locator("#board-connect").click(); await expect(page.locator("#board-content")).toBeVisible(); const headers = page.locator("#board-head th"); - await expect(headers).toHaveCount(6); + await expect(headers).toHaveCount(7); // + a non-sortable actions column await expect(headers.nth(0)).toContainText("Title"); await expect(headers.nth(1)).toContainText("Link"); await expect(headers.nth(2)).toContainText("Posted by"); @@ -718,6 +718,98 @@ test.describe("VoxVera static client", () => { await expect(page.locator(".content h1")).toHaveCount(0); }); + test("blocking an author hides their flyers, persists, and clears", async ({ page }) => { + const OTHER = "b".repeat(64); + await stubNip07(page); // viewer = 1*64 + await stubRelays(page, [flyerEvent("other", OTHER, "Someone Else's 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("Someone Else's Flyer"); + // force: sticky header can overlap the button after auto-scroll on mobile. + await page.locator('.board-action[data-act="block"]').first().click({ force: true }); + await expect(page.locator("#board-rows")).not.toContainText("Someone Else's Flyer"); + await expect(page.locator("#board-blocked-note")).toBeVisible(); + // Persists across a reload… + await page.reload(); + await expect(page.locator("#board-content")).toBeVisible(); + await expect(page.locator("#board-rows")).not.toContainText("Someone Else's Flyer"); + // …and "Clear blocked" restores it. + await page.locator("#board-unblock-all").click(); + await expect(page.locator("#board-rows")).toContainText("Someone Else's Flyer"); + }); + + 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); + await stubNip07(page); // viewer = ME + await stubRelays(page, [flyerEvent("mine", ME, "My Flyer"), flyerEvent("theirs", OTHER, "Their Flyer")]); + await page.goto("/board.html"); + await page.locator("#board-connect").click(); + await expect(page.locator("#board-content")).toBeVisible(); + const myRow = page.locator("#board-rows tr", { hasText: "My Flyer" }); + await expect(myRow.locator('.board-action[data-act="rebroadcast"]')).toBeVisible(); + await expect(myRow.locator('.board-action[data-act="delete"]')).toBeVisible(); + await expect(myRow.locator('.board-action[data-act="block"]')).toHaveCount(0); + const theirRow = page.locator("#board-rows tr", { hasText: "Their Flyer" }); + await expect(theirRow.locator('.board-action[data-act="block"]')).toBeVisible(); + await expect(theirRow.locator('.board-action[data-act="delete"]')).toHaveCount(0); + }); + + test("deleting your own flyer from the board publishes a tombstone + NIP-09", async ({ page }) => { + const ME = "a".repeat(64); + await page.addInitScript((me) => { + let n = 0; + window.nostr = { + getPublicKey: async () => me, + signEvent: async (e) => ({ ...e, pubkey: me, id: (++n).toString(16).padStart(64, "0"), sig: "0".repeat(128) }) + }; + window.__published = []; + const flyer = { + id: "e".repeat(64), kind: 30078, pubkey: me, created_at: 2000, + tags: [["d", "voxvera:mine"], ["t", "voxvera"], ["t", "flyer"], ["language", "en"]], + content: JSON.stringify({ type: "voxvera_flyer", version: 1, folder_name: "mine", lang: "en", title: "My Flyer", subtitle: "s", headline: "h", content: "c", url_message: "m", url: "https://example.com/m", footer_message: "f", tear_off_link: "https://voxvera.org/#naddr1m", qr_target: "flyer_url" }) + }; + const matches = (ev, f) => { + if (f.kinds && f.kinds.indexOf(ev.kind) === -1) return false; + if (f.authors && f.authors.indexOf(ev.pubkey) === -1) return false; + if (f["#t"]) { const tv = (ev.tags || []).filter((x) => x[0] === "t").map((x) => x[1]); if (!f["#t"].some((v) => tv.indexOf(v) !== -1)) return false; } + return true; + }; + class FakeWS { + constructor() { this.readyState = 1; setTimeout(() => this.onopen && this.onopen(), 1); } + send(data) { + let m; try { m = JSON.parse(data); } catch (_) { return; } + if (m[0] === "REQ") { + const sub = m[1], f = m[2] || {}; + setTimeout(() => { + if (matches(flyer, f)) 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); + await page.goto("/board.html"); + await page.locator("#board-connect").click(); + await expect(page.locator("#board-content")).toBeVisible(); + const myRow = page.locator("#board-rows tr", { hasText: "My Flyer" }); + // force: on a narrow mobile viewport Playwright scrolls the button under the + // sticky table header, which then intercepts the click (harness artifact). + await myRow.locator('.board-action[data-act="delete"]').click({ force: true }); + await expect(page.locator("#board-delete-modal")).toBeVisible(); + await page.locator("#board-delete-confirm").click({ force: true }); + await expect(page.locator("#board-rows")).not.toContainText("My Flyer"); + const pub = await page.evaluate(() => window.__published); + expect(pub.some((e) => e.kind === 30078 && (e.tags || []).some((t) => t[0] === "deleted"))).toBe(true); + expect(pub.some((e) => e.kind === 5 && (e.tags || []).some((t) => t[0] === "a" && t[1] === `30078:${ME}:voxvera:mine`))).toBe(true); + }); + test("editor imports an nsec for the session without storing the secret", async ({ page }) => { await page.goto("/#editor"); const { nsec, npub } = await page.evaluate(() => { diff --git a/site/board.html b/site/board.html index 1fe6604..a00a112 100644 --- a/site/board.html +++ b/site/board.html @@ -64,6 +64,9 @@