From b74488b43644461f087611c12cae9eebb84a85d5 Mon Sep 17 00:00:00 2001
From: Nogringo
Date: Mon, 31 Aug 2026 01:19:03 +0200
Subject: [PATCH 01/10] feat: build reports and read mute lists, and keep muted
keys out of the news
---
packages/nostr/src/index.ts | 20 ++
packages/nostr/src/nip51.ts | 158 +++++++++++++++
packages/nostr/src/nip56.ts | 84 ++++++++
packages/nostr/src/notifications.ts | 16 +-
packages/nostr/test/nip51.test.ts | 232 ++++++++++++++++++++++
packages/nostr/test/nip56.test.ts | 98 +++++++++
packages/nostr/test/notifications.test.ts | 56 ++++++
7 files changed, 663 insertions(+), 1 deletion(-)
create mode 100644 packages/nostr/src/nip51.ts
create mode 100644 packages/nostr/src/nip56.ts
create mode 100644 packages/nostr/test/nip51.test.ts
create mode 100644 packages/nostr/test/nip56.test.ts
diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts
index 900bbc7..03b37c2 100644
--- a/packages/nostr/src/index.ts
+++ b/packages/nostr/src/index.ts
@@ -132,6 +132,26 @@ export {
WalletError,
type WalletInfo,
} from "./nip47";
+export {
+ editMuteList,
+ fetchMuteList,
+ MUTE_LIST_KIND,
+ type MuteChange,
+ type MuteList,
+ type MuteListRead,
+ type MuteTarget,
+ type MuteType,
+ parseMuteList,
+} from "./nip51";
+export {
+ buildReport,
+ parseReport,
+ REPORT_KIND,
+ REPORT_TYPES,
+ type Report,
+ type ReportTarget,
+ type ReportType,
+} from "./nip56";
export {
buildZapRequest,
parseZapReceipt,
diff --git a/packages/nostr/src/nip51.ts b/packages/nostr/src/nip51.ts
new file mode 100644
index 0000000..8f21b33
--- /dev/null
+++ b/packages/nostr/src/nip51.ts
@@ -0,0 +1,158 @@
+import type { Filter } from "nostr-tools/filter";
+import { parseCoordinate, toCoordinate } from "./address";
+import { type EventDraft, type NostrEvent, newestEvent, nostrEventSchema } from "./event";
+import { CLIENT_NAME } from "./nip22";
+import { type RelayOptions, relayPool, relaySet } from "./pool";
+
+export const MUTE_LIST_KIND = 10000;
+
+export type MuteType = "p" | "e" | "a";
+
+export type MuteTarget = { type: MuteType; value: string };
+
+/** The public half of a mute list, as far as this site reads one. */
+export type MuteList = {
+ pubkeys: string[];
+ eventIds: string[];
+ /** Only coordinates that address a specification. Anything else stays in the event and is ignored here. */
+ coordinates: string[];
+ /** `content` is not empty: encrypted items exist that this client cannot read. */
+ hasPrivate: boolean;
+ updatedAt: number;
+};
+
+const HEX_64 = /^[0-9a-f]{64}$/;
+
+export const parseMuteList = (input: unknown): MuteList | null => {
+ const parsed = nostrEventSchema.safeParse(input);
+ if (!parsed.success || parsed.data.kind !== MUTE_LIST_KIND) return null;
+
+ const event = parsed.data;
+ const pubkeys = new Set();
+ const eventIds = new Set();
+ const coordinates = new Set();
+ for (const tag of event.tags) {
+ const value = (tag[1] ?? "").trim();
+ if (tag[0] === "p" && HEX_64.test(value)) pubkeys.add(value);
+ else if (tag[0] === "e" && HEX_64.test(value)) eventIds.add(value);
+ else if (tag[0] === "a") {
+ const pointer = parseCoordinate(value);
+ if (pointer !== null) coordinates.add(toCoordinate(pointer));
+ }
+ }
+
+ return {
+ pubkeys: [...pubkeys],
+ eventIds: [...eventIds],
+ coordinates: [...coordinates],
+ hasPrivate: event.content !== "",
+ updatedAt: event.created_at,
+ };
+};
+
+export type MuteChange = { add?: MuteTarget[]; remove?: MuteTarget[] };
+
+const names = (tag: string[], target: MuteTarget): boolean =>
+ tag[0] === target.type && tag[1] === target.value;
+
+/**
+ * A kind 10000 replaces the whole list, so an edit starts from the live one and
+ * hands back everything it held: the `t` and `word` items this site never
+ * shows, tags it has never heard of, and `content`, byte for byte, since that is
+ * where NIP-51 keeps the private items and only the owner's key can open it.
+ */
+export const editMuteList = (live: NostrEvent | null, change: MuteChange): EventDraft => {
+ const base = live?.kind === MUTE_LIST_KIND ? live : null;
+ const removed = change.remove ?? [];
+ const tags = (base?.tags ?? []).filter(
+ (tag) => tag[0] !== "client" && !removed.some((target) => names(tag, target)),
+ );
+
+ // NIP-51 lists `p`, `t`, `word` and `e` for this kind. `a` is this site's
+ // addition: it is the only way to name a document, and other clients keep the
+ // tags they do not read.
+ for (const target of change.add ?? []) {
+ if (!tags.some((tag) => names(tag, target))) tags.push([target.type, target.value]);
+ }
+
+ return {
+ kind: MUTE_LIST_KIND,
+ content: base?.content ?? "",
+ tags: [...tags, ["client", CLIENT_NAME]],
+ };
+};
+
+export type MuteListRead = {
+ event: NostrEvent | null;
+ /** The relays that said they had finished sending. Empty means nobody answered. */
+ answered: string[];
+};
+
+const MUTE_LIST_TIMEOUT_MS = 5000;
+
+/**
+ * The live kind 10000, and which relays finished answering. A replaceable event
+ * written back overwrites whatever a relay holds, so whoever writes one has to
+ * tell "nothing there" from "nobody answered", and `querySync` folds the two
+ * into one empty array. A relay counts as answered on its own EOSE only: one
+ * that could not be reached, or had sent nothing by the deadline, is not one
+ * whose silence a list can be built on.
+ */
+export const fetchMuteList = async (
+ pubkey: string,
+ options: RelayOptions = {},
+): Promise => {
+ const relays = relaySet(options.relays ?? []);
+ if (relays.length === 0) return { event: null, answered: [] };
+
+ const pool = options.pool ?? relayPool();
+ const timeoutMs = options.timeoutMs ?? MUTE_LIST_TIMEOUT_MS;
+ const filter: Filter = { kinds: [MUTE_LIST_KIND], authors: [pubkey] };
+ const events: NostrEvent[] = [];
+ const answered: string[] = [];
+
+ await new Promise((resolve) => {
+ const open = new Map void }>();
+ let pending = relays.length;
+ let finished = false;
+
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+ clearTimeout(deadline);
+ for (const subscription of open.values()) subscription.close();
+ resolve();
+ };
+ const deadline = setTimeout(finish, timeoutMs);
+
+ const settle = (url: string) => {
+ if (!open.delete(url)) return;
+ pending -= 1;
+ if (pending === 0) finish();
+ };
+
+ for (const url of relays) {
+ open.set(url, { close: () => {} });
+ pool
+ .ensureRelay(url, { connectionTimeout: timeoutMs })
+ .then((relay) => {
+ if (finished) return;
+ const subscription = relay.subscribe([filter], {
+ onevent: (event) => events.push(event as NostrEvent),
+ oneose: () => {
+ answered.push(url);
+ subscription.close();
+ },
+ onclose: () => settle(url),
+ // The pool fakes an EOSE at this timeout. Kept past the deadline,
+ // so the only EOSE that arrives in time is the relay's own.
+ eoseTimeout: timeoutMs * 2,
+ });
+ open.set(url, subscription);
+ })
+ .catch(() => settle(url));
+ }
+ });
+
+ return { event: newestEvent(events, pubkey, MUTE_LIST_KIND), answered };
+};
diff --git a/packages/nostr/src/nip56.ts b/packages/nostr/src/nip56.ts
new file mode 100644
index 0000000..283284a
--- /dev/null
+++ b/packages/nostr/src/nip56.ts
@@ -0,0 +1,84 @@
+import { type EventDraft, firstTag, nostrEventSchema, tagValue } from "./event";
+import { CLIENT_NAME } from "./nip22";
+
+export const REPORT_KIND = 1984;
+
+/** NIP-56's own words for what is wrong, in the order a form offers them. */
+export const REPORT_TYPES = [
+ "spam",
+ "illegal",
+ "nudity",
+ "profanity",
+ "malware",
+ "impersonation",
+ "other",
+] as const;
+
+export type ReportType = (typeof REPORT_TYPES)[number];
+
+export type ReportTarget = {
+ pubkey: string;
+ /** The event, when the report is about one rather than about the key itself. */
+ id?: string | null;
+ /** A document's address, which outlives the revision `id` names. */
+ coordinate?: string | null;
+};
+
+/**
+ * The type sits on the tag naming what is reported, as NIP-56 asks: on the `e`
+ * of a note, on the `p` of an account. No `k` tag: NIP-56 defines none, and a
+ * document's kind is already in its `a`.
+ */
+export const buildReport = (target: ReportTarget, type: ReportType, content = ""): EventDraft => {
+ const id = target.id?.trim();
+ const coordinate = target.coordinate?.trim();
+
+ return {
+ kind: REPORT_KIND,
+ content: content.trim(),
+ tags: id
+ ? [
+ ["e", id, type],
+ ...(coordinate ? [["a", coordinate]] : []),
+ ["p", target.pubkey],
+ ["client", CLIENT_NAME],
+ ]
+ : [
+ ["p", target.pubkey, type],
+ ["client", CLIENT_NAME],
+ ],
+ };
+};
+
+export type Report = {
+ id: string;
+ pubkey: string;
+ createdAt: number;
+ /** Whatever the third field said, kept even when it is not one of `REPORT_TYPES`. */
+ type: string;
+ reportedPubkey: string;
+ targetId: string | null;
+ targetCoordinate: string | null;
+ content: string;
+};
+
+export const parseReport = (input: unknown): Report | null => {
+ const parsed = nostrEventSchema.safeParse(input);
+ if (!parsed.success || parsed.data.kind !== REPORT_KIND) return null;
+
+ const event = parsed.data;
+ const reportedPubkey = tagValue(event, "p");
+ if (reportedPubkey === "") return null;
+
+ const target = firstTag(event, "e");
+ return {
+ id: event.id,
+ pubkey: event.pubkey,
+ createdAt: event.created_at,
+ type: (target?.[2] ?? firstTag(event, "p")?.[2] ?? "").trim(),
+ reportedPubkey,
+ targetId: (target?.[1] ?? "").trim() || null,
+ targetCoordinate: tagValue(event, "a") || null,
+ content: event.content.trim(),
+ };
+};
diff --git a/packages/nostr/src/notifications.ts b/packages/nostr/src/notifications.ts
index dd41a97..a9113ca 100644
--- a/packages/nostr/src/notifications.ts
+++ b/packages/nostr/src/notifications.ts
@@ -119,6 +119,12 @@ export type NoticeScope = {
* revision per coordinate by whoever collected them.
*/
copies?: Spec[];
+ /** What the reader muted. Nothing from, about or under any of it is news. */
+ muted?: {
+ pubkeys: ReadonlySet;
+ eventIds: ReadonlySet;
+ coordinates: ReadonlySet;
+ };
};
/** Rebuilt rather than carried through, so one document has one spelling here. */
@@ -272,6 +278,13 @@ const collapsed = (notices: Notice[]): Notice[] => {
return [...kept.values()];
};
+/** Dropped before the collapse, so a muted like is not the one a collapse keeps. */
+const muted = (scope: NoticeScope["muted"]) => (notice: Notice) =>
+ scope !== undefined &&
+ (scope.pubkeys.has(notice.pubkey) ||
+ scope.eventIds.has(notice.id) ||
+ scope.coordinates.has(notice.document.coordinate));
+
const retracted = (deletions: Deletion[]) => {
const taken = retractions(deletions);
return (notice: Notice): boolean => taken.get(notice.id)?.has(notice.pubkey) === true;
@@ -328,7 +341,8 @@ export const sortNotices = (events: NostrEvent[], scope: NoticeScope): Notice[]
}
const taken = retracted(deletions);
- return collapsed(notices)
+ const hidden = muted(scope.muted);
+ return collapsed(notices.filter((notice) => !hidden(notice)))
.filter((notice) => !taken(notice))
.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
.slice(0, MAX_NOTICES);
diff --git a/packages/nostr/test/nip51.test.ts b/packages/nostr/test/nip51.test.ts
new file mode 100644
index 0000000..d082236
--- /dev/null
+++ b/packages/nostr/test/nip51.test.ts
@@ -0,0 +1,232 @@
+import { createHash } from "node:crypto";
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import type { Duplex } from "node:stream";
+import { createMockRelay, type MockRelay } from "nostr-mock-relay";
+import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { type NostrEvent, SPEC_KIND } from "../src/event";
+import { editMuteList, fetchMuteList, MUTE_LIST_KIND, parseMuteList } from "../src/nip51";
+
+const secret = generateSecretKey();
+const me = getPublicKey(secret);
+
+const alice = "1".repeat(64);
+const bob = "2".repeat(64);
+const NOTE = "e".repeat(64);
+const COORDINATE = `${SPEC_KIND}:${alice}:custom-xyz`;
+
+const list = (tags: string[][], options: { content?: string; at?: number } = {}) =>
+ finalizeEvent(
+ {
+ kind: MUTE_LIST_KIND,
+ content: options.content ?? "",
+ tags,
+ created_at: options.at ?? 1_700_000_000,
+ },
+ secret,
+ ) as NostrEvent;
+
+describe("parseMuteList", () => {
+ it("reads the keys, the notes and the documents, and ignores the rest", () => {
+ const parsed = parseMuteList(
+ list([
+ ["p", alice],
+ ["e", NOTE],
+ ["a", COORDINATE],
+ ["t", "politics"],
+ ["word", "spoiler"],
+ ["a", `30023:${bob}:an-article`],
+ ["p", "not a key"],
+ ["e", NOTE.slice(1)],
+ ]),
+ );
+
+ expect(parsed).toMatchObject({
+ pubkeys: [alice],
+ eventIds: [NOTE],
+ coordinates: [COORDINATE],
+ hasPrivate: false,
+ updatedAt: 1_700_000_000,
+ });
+ });
+
+ it("says when the list holds private items it cannot open", () => {
+ expect(parseMuteList(list([], { content: "AbCd==" }))?.hasPrivate).toBe(true);
+ });
+
+ it("names each thing once, however many times the list repeats it", () => {
+ expect(
+ parseMuteList(
+ list([
+ ["p", alice],
+ ["p", alice],
+ ]),
+ )?.pubkeys,
+ ).toEqual([alice]);
+ });
+
+ it("refuses another kind", () => {
+ expect(parseMuteList({ ...list([["p", alice]]), kind: 10001 })).toBeNull();
+ });
+});
+
+describe("editMuteList", () => {
+ const live = list(
+ [
+ ["p", alice],
+ ["t", "politics"],
+ ["word", "spoiler"],
+ ["client", "Somebody Else"],
+ ["e", NOTE],
+ ],
+ { content: "AbCd==" },
+ );
+
+ it("keeps the content byte for byte, since only the owner's key can read it", () => {
+ expect(editMuteList(live, { add: [{ type: "p", value: bob }] }).content).toBe("AbCd==");
+ });
+
+ it("keeps every tag it does not read, in the order they came, and names itself last", () => {
+ expect(editMuteList(live, { add: [{ type: "a", value: COORDINATE }] }).tags).toEqual([
+ ["p", alice],
+ ["t", "politics"],
+ ["word", "spoiler"],
+ ["e", NOTE],
+ ["a", COORDINATE],
+ ["client", "Open Specs"],
+ ]);
+ });
+
+ it("adds a thing once", () => {
+ const tags = editMuteList(live, { add: [{ type: "p", value: alice }] }).tags;
+ expect(tags.filter((tag) => tag[0] === "p")).toEqual([["p", alice]]);
+ });
+
+ it("removes only the tag named", () => {
+ const tags = editMuteList(live, { remove: [{ type: "p", value: alice }] }).tags;
+ expect(tags).toEqual([
+ ["t", "politics"],
+ ["word", "spoiler"],
+ ["e", NOTE],
+ ["client", "Open Specs"],
+ ]);
+ });
+
+ it("starts a fresh list for a key that never published one", () => {
+ expect(editMuteList(null, { add: [{ type: "p", value: bob }] })).toEqual({
+ kind: MUTE_LIST_KIND,
+ content: "",
+ tags: [
+ ["p", bob],
+ ["client", "Open Specs"],
+ ],
+ });
+ });
+
+ it("takes nothing from an event of another kind handed to it by mistake", () => {
+ const wrong = { ...live, kind: 3 };
+ expect(editMuteList(wrong, {})).toEqual({
+ kind: MUTE_LIST_KIND,
+ content: "",
+ tags: [["client", "Open Specs"]],
+ });
+ });
+});
+
+/**
+ * A server that completes the WebSocket handshake and then says nothing at
+ * all: a relay that is up, takes the REQ, and never sends an EOSE.
+ */
+const silentRelay = (): Promise<{ url: string; stop: () => Promise }> =>
+ new Promise((resolve) => {
+ const server: Server = createServer();
+ const sockets = new Set();
+ server.on("upgrade", (request, socket) => {
+ const key = String(request.headers["sec-websocket-key"]);
+ const accept = createHash("sha1")
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
+ .digest("base64");
+ socket.write(
+ [
+ "HTTP/1.1 101 Switching Protocols",
+ "Upgrade: websocket",
+ "Connection: Upgrade",
+ `Sec-WebSocket-Accept: ${accept}`,
+ "",
+ "",
+ ].join("\r\n"),
+ );
+ sockets.add(socket);
+ socket.on("close", () => sockets.delete(socket));
+ });
+ server.listen(0, "127.0.0.1", () => {
+ const { port } = server.address() as AddressInfo;
+ resolve({
+ url: `ws://127.0.0.1:${port}/`,
+ stop: () =>
+ new Promise((done) => {
+ for (const socket of sockets) socket.destroy();
+ server.close(() => done());
+ }),
+ });
+ });
+ });
+
+describe("fetchMuteList", () => {
+ let relay: MockRelay;
+ let silent: Awaited>;
+
+ beforeAll(async () => {
+ relay = createMockRelay();
+ await relay.start();
+ relay.seed([
+ list([["p", alice]], { at: 1_700_000_000 }),
+ list([["p", bob]], { at: 1_700_000_100 }),
+ ]);
+ silent = await silentRelay();
+ });
+
+ afterAll(async () => {
+ await relay.stop();
+ await silent.stop();
+ });
+
+ it("returns the newest revision and names the relay that finished", async () => {
+ const read = await fetchMuteList(me, { relays: [relay.url ?? ""] });
+
+ expect(parseMuteList(read.event)?.pubkeys).toEqual([bob]);
+ expect(read.answered).toEqual([relay.url]);
+ });
+
+ it("names a relay that answered with nothing, which is an answer", async () => {
+ const stranger = getPublicKey(generateSecretKey());
+ const read = await fetchMuteList(stranger, { relays: [relay.url ?? ""] });
+
+ expect(read.event).toBeNull();
+ expect(read.answered).toEqual([relay.url]);
+ });
+
+ it("does not name a relay it could not reach, and still comes back", async () => {
+ const read = await fetchMuteList(me, { relays: ["ws://127.0.0.1:1/"], timeoutMs: 1000 });
+
+ expect(read.event).toBeNull();
+ expect(read.answered).toEqual([]);
+ });
+
+ it("gives up on a relay that took the question and never finished, without counting it", async () => {
+ const started = Date.now();
+ const read = await fetchMuteList(me, {
+ relays: [silent.url, relay.url ?? ""],
+ timeoutMs: 500,
+ });
+
+ expect(Date.now() - started).toBeGreaterThanOrEqual(450);
+ expect(read.answered).toEqual([relay.url]);
+ expect(parseMuteList(read.event)?.pubkeys).toEqual([bob]);
+ });
+
+ it("asks nobody when given no relays", async () => {
+ expect(await fetchMuteList(me, { relays: [] })).toEqual({ event: null, answered: [] });
+ });
+});
diff --git a/packages/nostr/test/nip56.test.ts b/packages/nostr/test/nip56.test.ts
new file mode 100644
index 0000000..bf02b7e
--- /dev/null
+++ b/packages/nostr/test/nip56.test.ts
@@ -0,0 +1,98 @@
+import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";
+import { describe, expect, it } from "vitest";
+import { type NostrEvent, SPEC_KIND } from "../src/event";
+import { buildReport, parseReport, REPORT_KIND } from "../src/nip56";
+
+const AUTHOR = "b22b06b051fd5232966a9344a634d956c3dc33a7f5ecdcad9ed11ddc4120a7f2";
+const COORDINATE = `${SPEC_KIND}:${AUTHOR}:replaceable-event-snapshots`;
+const DOCUMENT = { pubkey: AUTHOR, id: "d".repeat(64), coordinate: COORDINATE };
+const COMMENT = { pubkey: "f".repeat(64), id: "e".repeat(64) };
+
+const secret = generateSecretKey();
+const reporter = getPublicKey(secret);
+
+const signed = (draft: { kind: number; content: string; tags: string[][] }) =>
+ finalizeEvent({ ...draft, created_at: 1_700_000_000 }, secret) as NostrEvent;
+
+describe("buildReport", () => {
+ it("puts the type on the note and names its author and its address on a document", () => {
+ expect(buildReport(DOCUMENT, "spam").tags).toEqual([
+ ["e", DOCUMENT.id, "spam"],
+ ["a", COORDINATE],
+ ["p", AUTHOR],
+ ["client", "Open Specs"],
+ ]);
+ });
+
+ it("carries no address on a comment, which has none", () => {
+ expect(buildReport(COMMENT, "profanity").tags).toEqual([
+ ["e", COMMENT.id, "profanity"],
+ ["p", COMMENT.pubkey],
+ ["client", "Open Specs"],
+ ]);
+ });
+
+ it("puts the type on the key when the report is about the account", () => {
+ expect(buildReport({ pubkey: AUTHOR }, "impersonation").tags).toEqual([
+ ["p", AUTHOR, "impersonation"],
+ ["client", "Open Specs"],
+ ]);
+ });
+
+ it("passes the words through trimmed, and sends none by default", () => {
+ expect(buildReport(COMMENT, "other").content).toBe("");
+ expect(buildReport(COMMENT, "other", " says it is a wallet, is not ").content).toBe(
+ "says it is a wallet, is not",
+ );
+ });
+
+ it("carries no k tag, since NIP-56 defines none", () => {
+ expect(buildReport(DOCUMENT, "spam").tags.some((tag) => tag[0] === "k")).toBe(false);
+ });
+});
+
+describe("parseReport", () => {
+ it("reads back each shape it writes", () => {
+ const document = parseReport(signed(buildReport(DOCUMENT, "illegal", "why")));
+ expect(document).toMatchObject({
+ pubkey: reporter,
+ type: "illegal",
+ reportedPubkey: AUTHOR,
+ targetId: DOCUMENT.id,
+ targetCoordinate: COORDINATE,
+ content: "why",
+ });
+
+ const comment = parseReport(signed(buildReport(COMMENT, "spam")));
+ expect(comment).toMatchObject({
+ type: "spam",
+ reportedPubkey: COMMENT.pubkey,
+ targetId: COMMENT.id,
+ targetCoordinate: null,
+ });
+
+ const account = parseReport(signed(buildReport({ pubkey: AUTHOR }, "impersonation")));
+ expect(account).toMatchObject({
+ type: "impersonation",
+ reportedPubkey: AUTHOR,
+ targetId: null,
+ targetCoordinate: null,
+ });
+ });
+
+ it("keeps a type it has never heard of, which is the reporter's word", () => {
+ const odd = signed({
+ kind: REPORT_KIND,
+ content: "",
+ tags: [["p", AUTHOR, "plagiarism"]],
+ });
+ expect(parseReport(odd)?.type).toBe("plagiarism");
+ });
+
+ it("refuses another kind, and a report naming nobody", () => {
+ expect(parseReport(signed({ kind: 1, content: "", tags: [["p", AUTHOR, "spam"]] }))).toBeNull();
+ expect(
+ parseReport(signed({ kind: REPORT_KIND, content: "", tags: [["e", COMMENT.id, "spam"]] })),
+ ).toBeNull();
+ });
+});
diff --git a/packages/nostr/test/notifications.test.ts b/packages/nostr/test/notifications.test.ts
index bd019a2..01d073a 100644
--- a/packages/nostr/test/notifications.test.ts
+++ b/packages/nostr/test/notifications.test.ts
@@ -317,6 +317,62 @@ describe("sortNotices, copies", () => {
});
});
+describe("sortNotices, muted", () => {
+ const NONE = new Set();
+ const muted = (
+ partial: Partial<{ pubkeys: Set; eventIds: Set; coordinates: Set }>,
+ ) => ({
+ pubkeys: NONE,
+ eventIds: NONE,
+ coordinates: NONE,
+ ...partial,
+ });
+
+ it("drops what a muted key did, and keeps the rest", () => {
+ const theirs = comment(MINE, theirSecret, { content: "theirs" });
+ const thirds = comment(MINE, thirdSecret, { content: "thirds" });
+ const notices = sortNotices([theirs, thirds], {
+ me: ME,
+ muted: muted({ pubkeys: new Set([THEM]) }),
+ });
+ expect(notices.map((notice) => notice.id)).toEqual([thirds.id]);
+ });
+
+ it("drops a muted event by its id", () => {
+ const one = comment(MINE, theirSecret);
+ expect(sortNotices([one], { me: ME, muted: muted({ eventIds: new Set([one.id]) }) })).toEqual(
+ [],
+ );
+ });
+
+ it("drops everything under a muted document", () => {
+ const under = comment(MINE, theirSecret);
+ const scope = { me: ME, muted: muted({ coordinates: new Set([MINE.coordinate]) }) };
+ expect(sortNotices([under], scope)).toEqual([]);
+ });
+
+ it("drops a muted like before the collapse, so it is not the one kept", () => {
+ const target = { id: "a".repeat(64), pubkey: ME, kind: SPEC_KIND, coordinate: MINE.coordinate };
+ const kept = reaction(target, thirdSecret, { at: 100 });
+ const scope = { me: ME, muted: muted({ pubkeys: new Set([THEM]) }) };
+ const notices = sortNotices([kept, reaction(target, theirSecret, { at: 200 })], scope);
+ expect(notices.map((notice) => notice.id)).toEqual([kept.id]);
+ });
+
+ it("still stops at one page after the muted rows are gone", () => {
+ const many = Array.from({ length: MAX_NOTICES + 10 }, (_, index) =>
+ comment(MINE, index % 2 === 0 ? theirSecret : thirdSecret, {
+ at: 100 + index,
+ content: `note ${index}`,
+ }),
+ );
+ const scope = { me: ME, muted: muted({ pubkeys: new Set([THEM]) }) };
+ const notices = sortNotices(many, scope);
+ expect(notices).toHaveLength((MAX_NOTICES + 10) / 2);
+ expect(notices.every((notice) => notice.pubkey === THIRD)).toBe(true);
+ });
+});
+
describe("sortNotices, the whole list", () => {
it("takes the same event served by three relays as one row", () => {
const one = comment(MINE, theirSecret);
From 846eaa6f61aa2ce15e95798f6e63eedbb763eb48 Mon Sep 17 00:00:00 2001
From: Nogringo
Date: Mon, 31 Aug 2026 01:45:41 +0200
Subject: [PATCH 02/10] feat(web): hide what a reader blocked, everywhere the
browser draws it
A block lives on the device first: `openspecs:blocked` holds the keys, the
comment ids and the document addresses a reader chose not to see, and the
changes no signed list carries yet. Listings, search, the variants under a
name, the tallies, the row counts and the bell all read it; a blocked comment
folds to one line and keeps its replies; a blocked document or account page
stands behind a notice with Unblock and Show anyway. A Settings tab lists it
all. Nothing offers the block yet, and nothing publishes it: both follow.
---
.../web/app/components/discussion/comment.tsx | 64 +++-
.../components/moderation/blocked-notice.tsx | 32 ++
apps/web/app/components/search-results.tsx | 9 +-
.../app/components/settings/blocked-list.tsx | 142 +++++++++
apps/web/app/lib/blocked.test.ts | 252 ++++++++++++++++
apps/web/app/lib/blocked.ts | 276 ++++++++++++++++++
apps/web/app/lib/discussion.test.ts | 31 +-
apps/web/app/lib/discussion.ts | 17 +-
apps/web/app/lib/likes.test.ts | 31 +-
apps/web/app/lib/likes.ts | 57 +++-
apps/web/app/lib/notifications.test.ts | 2 +-
apps/web/app/lib/notifications.ts | 11 +-
apps/web/app/lib/paths.ts | 8 +-
apps/web/app/routes.ts | 1 +
apps/web/app/routes/author.tsx | 24 +-
apps/web/app/routes/home.tsx | 4 +-
apps/web/app/routes/settings.tsx | 12 +-
apps/web/app/routes/settings/blocked.tsx | 21 ++
apps/web/app/routes/spec.tsx | 37 ++-
apps/web/app/routes/specs.tsx | 4 +-
20 files changed, 991 insertions(+), 44 deletions(-)
create mode 100644 apps/web/app/components/moderation/blocked-notice.tsx
create mode 100644 apps/web/app/components/settings/blocked-list.tsx
create mode 100644 apps/web/app/lib/blocked.test.ts
create mode 100644 apps/web/app/lib/blocked.ts
create mode 100644 apps/web/app/routes/settings/blocked.tsx
diff --git a/apps/web/app/components/discussion/comment.tsx b/apps/web/app/components/discussion/comment.tsx
index aac581f..1de368e 100644
--- a/apps/web/app/components/discussion/comment.tsx
+++ b/apps/web/app/components/discussion/comment.tsx
@@ -4,6 +4,8 @@ import { authorPath, COMMENT_KIND, toNpub } from "@openspecs/nostr";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { AuthorAvatar } from "~/components/author-avatar";
+import { CHROME } from "~/components/chrome";
+import { hidesComment, useBlocked } from "~/lib/blocked";
import { keyTextColor } from "~/lib/color";
import { NO_RESPONSE, type Response } from "~/lib/discussion";
import { mentionedKeys, mentionResolver } from "~/lib/mention";
@@ -78,6 +80,52 @@ export const CommentThread = ({
const npub = toNpub(comment.pubkey);
const author = authors[comment.pubkey] ?? null;
+ const blocked = useBlocked();
+ /** Opened by hand, for this visit: a block is not a lock. */
+ const [shownAnyway, setShownAnyway] = useState(false);
+
+ const replies = node.replies.length > 0 && (
+
+ {node.replies.map((reply) => (
+
+ ))}
+
+ );
+
+ // Collapsed to a line in its place rather than taken out, so what was said
+ // in answer keeps its thread. The blank on the left is where the mark of the
+ // key would be: the replies below stay in the column they had.
+ if (hidesComment(blocked, comment) && !shownAnyway) {
+ return (
+
+
+
+
+
+
+ {blocked.pubkeys.has(comment.pubkey)
+ ? "From an account you blocked."
+ : "A comment you blocked."}
+
+
+
+ {replies}
+
+
+
+ );
+ }
+
return (
// Addressable, so a notification can land on the comment it is about rather
// than on the conversation holding it. The margin is what keeps the header
@@ -142,21 +190,7 @@ export const CommentThread = ({
)}
- {node.replies.length > 0 && (
-
- {node.replies.map((reply) => (
-
- ))}
-
- )}
+ {replies}
diff --git a/apps/web/app/components/moderation/blocked-notice.tsx b/apps/web/app/components/moderation/blocked-notice.tsx
new file mode 100644
index 0000000..74f269b
--- /dev/null
+++ b/apps/web/app/components/moderation/blocked-notice.tsx
@@ -0,0 +1,32 @@
+import { CHROME } from "~/components/chrome";
+
+/**
+ * What stands where a blocked page would be. Dashed, like everything on this
+ * site that is on offer rather than settled: the page is one click away either
+ * way, and the sentence says whose choice put it there.
+ */
+export const BlockedNotice = ({
+ line,
+ detail,
+ onUnblock,
+ onShow,
+}: {
+ line: string;
+ /** Which page this is, in the address's own words, since the title is not shown. */
+ detail: string;
+ onUnblock: () => void;
+ onShow: () => void;
+}) => (
+
+
{line}
+
{detail}
+
+
+
+
+
+);
diff --git a/apps/web/app/components/search-results.tsx b/apps/web/app/components/search-results.tsx
index 3986c18..2770cc6 100644
--- a/apps/web/app/components/search-results.tsx
+++ b/apps/web/app/components/search-results.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router";
+import { hidesSpec, useBlocked } from "~/lib/blocked";
import { corpusState, serverCorpusState, startCorpus, subscribeCorpus } from "~/lib/corpus";
import { parsePage } from "~/lib/filter";
import { likeKey, useLikes } from "~/lib/likes";
@@ -39,14 +40,18 @@ export const SearchResults = ({
// only the page changed, so its idea of which page this is would be stale.
const [params] = useSearchParams();
+ // Filtered here rather than out of the corpus: what is on disk stays whole,
+ // so an unblock shows the rows again without reading the relays.
+ const blocked = useBlocked();
const scoped = useMemo(
() =>
docs.filter(
(doc) =>
(topic === null || doc.topics.includes(topic)) &&
- (kind === null || doc.kinds.some((ref) => ref.kind === kind)),
+ (kind === null || doc.kinds.some((ref) => ref.kind === kind)) &&
+ !hidesSpec(blocked, doc),
),
- [docs, topic, kind],
+ [docs, topic, kind, blocked],
);
const hits = useMemo(() => searchDocs(scoped, query), [scoped, query]);
const terms = useMemo(() => searchTerms(query), [query]);
diff --git a/apps/web/app/components/settings/blocked-list.tsx b/apps/web/app/components/settings/blocked-list.tsx
new file mode 100644
index 0000000..a7e1fc5
--- /dev/null
+++ b/apps/web/app/components/settings/blocked-list.tsx
@@ -0,0 +1,142 @@
+import { authorPath, parseCoordinate, specPath, toNpub } from "@openspecs/nostr";
+import { useEffect, useMemo, useSyncExternalStore } from "react";
+import { Link } from "react-router";
+import { AuthorAvatar } from "~/components/author-avatar";
+import { type MuteTarget, unblock, useBlocked } from "~/lib/blocked";
+import { keyTextColor } from "~/lib/color";
+import { authorName, shortNpub } from "~/lib/profile";
+import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles";
+
+const HEADING = "font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted";
+
+const NOTE = "font-serif text-[0.8125rem] leading-snug text-muted";
+
+const LINK = "min-w-0 truncate font-mono text-xs hover:underline";
+
+const REMOVE =
+ "shrink-0 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted hover:text-signal-closed";
+
+const shorten = (id: string): string => `${id.slice(0, 10)}...${id.slice(-4)}`;
+
+const Rows = ({
+ title,
+ count,
+ children,
+}: {
+ title: string;
+ count: number;
+ children: React.ReactNode;
+}) => (
+
+
+ );
+};
diff --git a/apps/web/app/lib/blocked.test.ts b/apps/web/app/lib/blocked.test.ts
new file mode 100644
index 0000000..8f0e9f1
--- /dev/null
+++ b/apps/web/app/lib/blocked.test.ts
@@ -0,0 +1,252 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ applyLive,
+ block,
+ blockedState,
+ clearBlocked,
+ dropOwed,
+ hidesComment,
+ hidesSpec,
+ isBlocked,
+ markUnpublished,
+ parseBlocked,
+ requeueAll,
+ settleOwed,
+ subscribeBlocked,
+ unblock,
+} from "./blocked";
+
+const ALICE = "1".repeat(64);
+const BOB = "2".repeat(64);
+const NOTE = "e".repeat(64);
+const COORDINATE = `30817:${ALICE}:custom-xyz`;
+
+const fakeStorage = (broken = false): Storage => {
+ const held = new Map();
+ return {
+ getItem: (key: string) => {
+ if (broken) throw new Error("this browser is in a private window");
+ return held.get(key) ?? null;
+ },
+ setItem: (key: string, value: string) => {
+ if (broken) throw new Error("this browser is in a private window");
+ held.set(key, value);
+ },
+ removeItem: (key: string) => {
+ if (broken) throw new Error("this browser is in a private window");
+ held.delete(key);
+ },
+ clear: () => held.clear(),
+ key: () => null,
+ length: 0,
+ } as unknown as Storage;
+};
+
+const onDisk = () => parseBlocked(JSON.parse(localStorage.getItem("openspecs:blocked") ?? "null"));
+
+beforeEach(() => {
+ vi.stubGlobal("localStorage", fakeStorage());
+ clearBlocked();
+});
+
+afterEach(() => {
+ clearBlocked();
+ vi.unstubAllGlobals();
+});
+
+describe("block and unblock", () => {
+ it("hides at once, and tells whoever is watching", () => {
+ const heard = vi.fn();
+ subscribeBlocked(heard);
+
+ block({ type: "p", value: ALICE });
+ expect(blockedState().pubkeys.has(ALICE)).toBe(true);
+ expect(heard).toHaveBeenCalledTimes(1);
+ });
+
+ it("owes the relays what it changed, once per thing", () => {
+ block({ type: "p", value: ALICE });
+ block({ type: "p", value: ALICE });
+ block({ type: "a", value: COORDINATE });
+ expect(blockedState().owed).toEqual([
+ { op: "add", type: "p", value: ALICE },
+ { op: "add", type: "a", value: COORDINATE },
+ ]);
+ });
+
+ it("owes nothing after a change of mind", () => {
+ block({ type: "p", value: ALICE });
+ unblock({ type: "p", value: ALICE });
+ expect(blockedState().pubkeys.has(ALICE)).toBe(false);
+ expect(blockedState().owed).toEqual([]);
+ });
+
+ it("owes a removal for something the live list gave it", () => {
+ applyLive({ pubkeys: [ALICE], eventIds: [], coordinates: [] });
+ unblock({ type: "p", value: ALICE });
+ expect(blockedState().owed).toEqual([{ op: "remove", type: "p", value: ALICE }]);
+ });
+
+ it("refuses what is not a key, an id or a document", () => {
+ block({ type: "p", value: "alice" });
+ block({ type: "a", value: `30023:${ALICE}:an-article` });
+ expect(blockedState()).toMatchObject({ owed: [] });
+ expect(blockedState().pubkeys.size + blockedState().coordinates.size).toBe(0);
+ });
+
+ it("hands back the same snapshot until something changes", () => {
+ const before = blockedState();
+ expect(blockedState()).toBe(before);
+ block({ type: "e", value: NOTE });
+ expect(blockedState()).not.toBe(before);
+ });
+});
+
+describe("what a block hides", () => {
+ it("hides a document by its author and by its address", () => {
+ block({ type: "p", value: ALICE });
+ block({ type: "a", value: `30817:${BOB}:theirs` });
+ const blocked = blockedState();
+
+ expect(hidesSpec(blocked, { pubkey: ALICE, identifier: "anything" })).toBe(true);
+ expect(hidesSpec(blocked, { pubkey: BOB, identifier: "theirs" })).toBe(true);
+ expect(hidesSpec(blocked, { pubkey: BOB, identifier: "another" })).toBe(false);
+ });
+
+ it("hides a comment by its author and by its id", () => {
+ block({ type: "p", value: ALICE });
+ block({ type: "e", value: NOTE });
+ const blocked = blockedState();
+
+ expect(hidesComment(blocked, { id: "f".repeat(64), pubkey: ALICE })).toBe(true);
+ expect(hidesComment(blocked, { id: NOTE, pubkey: BOB })).toBe(true);
+ expect(hidesComment(blocked, { id: "f".repeat(64), pubkey: BOB })).toBe(false);
+ });
+
+ it("answers for one thing at a time", () => {
+ block({ type: "e", value: NOTE });
+ expect(isBlocked(blockedState(), { type: "e", value: NOTE })).toBe(true);
+ expect(isBlocked(blockedState(), { type: "p", value: NOTE })).toBe(false);
+ });
+});
+
+describe("the live list", () => {
+ it("is added to what is here, never put in its place", () => {
+ block({ type: "p", value: ALICE });
+ applyLive({ pubkeys: [BOB], eventIds: [NOTE], coordinates: [COORDINATE] });
+ const blocked = blockedState();
+
+ expect([...blocked.pubkeys]).toEqual([ALICE, BOB]);
+ expect([...blocked.eventIds]).toEqual([NOTE]);
+ expect([...blocked.coordinates]).toEqual([COORDINATE]);
+ expect(blocked.published).toBe(true);
+ expect(blocked.owed).toEqual([{ op: "add", type: "p", value: ALICE }]);
+ });
+
+ it("does not put back what this device took out and has not yet said so", () => {
+ applyLive({ pubkeys: [ALICE], eventIds: [], coordinates: [] });
+ unblock({ type: "p", value: ALICE });
+ applyLive({ pubkeys: [ALICE], eventIds: [], coordinates: [] });
+ expect(blockedState().pubkeys.has(ALICE)).toBe(false);
+ });
+
+ it("drops what in it is not a key, an id or a document", () => {
+ applyLive({ pubkeys: ["alice"], eventIds: [], coordinates: [`30023:${ALICE}:x`] });
+ expect(blockedState().pubkeys.size + blockedState().coordinates.size).toBe(0);
+ });
+
+ it("is forgotten as published when the key changes", () => {
+ applyLive({ pubkeys: [ALICE], eventIds: [], coordinates: [] });
+ markUnpublished();
+ expect(blockedState().published).toBe(false);
+ expect(blockedState().pubkeys.has(ALICE)).toBe(true);
+ });
+});
+
+describe("the debt", () => {
+ it("is settled by what a signature carried, and only that", () => {
+ block({ type: "p", value: ALICE });
+ block({ type: "p", value: BOB });
+ settleOwed([{ op: "add", type: "p", value: ALICE }]);
+ expect(blockedState().owed).toEqual([{ op: "add", type: "p", value: BOB }]);
+ });
+
+ it("is dropped when the signer refuses, and the blocks stay", () => {
+ block({ type: "p", value: ALICE });
+ dropOwed();
+ expect(blockedState().owed).toEqual([]);
+ expect(blockedState().pubkeys.has(ALICE)).toBe(true);
+ });
+
+ it("can be owed again in full, keeping a removal in flight", () => {
+ applyLive({ pubkeys: [BOB], eventIds: [], coordinates: [] });
+ unblock({ type: "p", value: BOB });
+ block({ type: "p", value: ALICE });
+ block({ type: "a", value: COORDINATE });
+ dropOwed();
+
+ requeueAll();
+ expect(blockedState().owed).toEqual([
+ { op: "add", type: "p", value: ALICE },
+ { op: "add", type: "a", value: COORDINATE },
+ ]);
+
+ unblock({ type: "p", value: BOB });
+ expect(blockedState().owed).toEqual([
+ { op: "add", type: "p", value: ALICE },
+ { op: "add", type: "a", value: COORDINATE },
+ ]);
+ });
+});
+
+describe("the record on disk", () => {
+ it("survives a reload, which is the whole of what it is for", () => {
+ block({ type: "p", value: ALICE });
+ block({ type: "e", value: NOTE });
+ expect(onDisk()).toEqual({
+ v: 1,
+ p: [ALICE],
+ e: [NOTE],
+ a: [],
+ owed: [
+ { op: "add", type: "p", value: ALICE },
+ { op: "add", type: "e", value: NOTE },
+ ],
+ });
+ });
+
+ it("reads a record written by something else as nothing blocked", () => {
+ localStorage.setItem("openspecs:blocked", JSON.stringify({ v: 2, p: [ALICE] }));
+ expect(blockedState().pubkeys.size).toBe(0);
+ });
+
+ it("reads junk as nothing blocked rather than throwing", () => {
+ localStorage.setItem("openspecs:blocked", "not json");
+ expect(blockedState().pubkeys.size).toBe(0);
+ });
+
+ it("drops an entry that is not what its list holds", () => {
+ localStorage.setItem(
+ "openspecs:blocked",
+ JSON.stringify({
+ v: 1,
+ p: [ALICE, "not-a-key", 3],
+ e: "not a list",
+ a: [COORDINATE, `30023:${ALICE}:x`],
+ owed: [{ op: "add", type: "p", value: ALICE }, { op: "drop", type: "p", value: BOB }, null],
+ }),
+ );
+ const blocked = blockedState();
+ expect([...blocked.pubkeys]).toEqual([ALICE]);
+ expect([...blocked.eventIds]).toEqual([]);
+ expect([...blocked.coordinates]).toEqual([COORDINATE]);
+ expect(blocked.owed).toEqual([{ op: "add", type: "p", value: ALICE }]);
+ });
+
+ it("still hides in memory where storage is refused", () => {
+ vi.stubGlobal("localStorage", fakeStorage(true));
+ clearBlocked();
+ expect(() => block({ type: "p", value: ALICE })).not.toThrow();
+ expect(blockedState().pubkeys.has(ALICE)).toBe(true);
+ });
+});
diff --git a/apps/web/app/lib/blocked.ts b/apps/web/app/lib/blocked.ts
new file mode 100644
index 0000000..9d40682
--- /dev/null
+++ b/apps/web/app/lib/blocked.ts
@@ -0,0 +1,276 @@
+import { SPEC_KIND, toCoordinate } from "@openspecs/nostr";
+import { useMemo, useSyncExternalStore } from "react";
+
+const BLOCKED_KEY = "openspecs:blocked";
+
+export const BLOCKED_VERSION = 1;
+
+export type MuteType = "p" | "e" | "a";
+
+export type MuteTarget = { type: MuteType; value: string };
+
+/** A change made on this device that no signed list carries yet. */
+export type Owed = MuteTarget & { op: "add" | "remove" };
+
+/** On disk. The three lists are what is hidden; `owed` is what the relays have not been told. */
+type Stored = { v: 1; p: string[]; e: string[]; a: string[]; owed: Owed[] };
+
+/** In memory, what every consumer reads. */
+export type Blocked = {
+ pubkeys: ReadonlySet;
+ eventIds: ReadonlySet;
+ coordinates: ReadonlySet;
+ owed: readonly Owed[];
+ /** The connected key's signed list has been read this session, so the sets include it. */
+ published: boolean;
+};
+
+const EMPTY: Stored = { v: 1, p: [], e: [], a: [], owed: [] };
+
+export const NO_BLOCKS: Blocked = Object.freeze({
+ pubkeys: new Set(),
+ eventIds: new Set(),
+ coordinates: new Set(),
+ owed: [],
+ published: false,
+});
+
+const HEX_64 = /^[0-9a-f]{64}$/;
+const COORDINATE = new RegExp(`^${SPEC_KIND}:[0-9a-f]{64}:.+$`);
+
+const valid = (type: MuteType, value: unknown): value is string =>
+ typeof value === "string" && (type === "a" ? COORDINATE : HEX_64).test(value);
+
+const isType = (value: unknown): value is MuteType =>
+ value === "p" || value === "e" || value === "a";
+
+/**
+ * Written by hand rather than with zod, the way `parseSeen` is, and the same
+ * parser guards the write: a record nothing can read is nothing blocked.
+ */
+export const parseBlocked = (input: unknown): Stored | null => {
+ if (typeof input !== "object" || input === null) return null;
+ const record = input as Record;
+ if (record.v !== BLOCKED_VERSION) return null;
+
+ const list = (type: MuteType): string[] => {
+ const held = record[type];
+ return Array.isArray(held) ? [...new Set(held.filter((value) => valid(type, value)))] : [];
+ };
+
+ const owed: Owed[] = [];
+ if (Array.isArray(record.owed)) {
+ for (const item of record.owed) {
+ if (typeof item !== "object" || item === null) continue;
+ const { op, type, value } = item as Record;
+ if ((op !== "add" && op !== "remove") || !isType(type) || !valid(type, value)) continue;
+ owed.push({ op, type, value });
+ }
+ }
+
+ return { v: BLOCKED_VERSION, p: list("p"), e: list("e"), a: list("a"), owed };
+};
+
+const store = (): Storage | undefined =>
+ typeof localStorage === "undefined" ? undefined : localStorage;
+
+let held: Stored | null = null;
+let published = false;
+let snapshot: Blocked = NO_BLOCKS;
+
+const listeners = new Set<() => void>();
+
+const load = (): Stored => {
+ try {
+ const raw = store()?.getItem(BLOCKED_KEY);
+ return (raw === null || raw === undefined ? null : parseBlocked(JSON.parse(raw))) ?? EMPTY;
+ } catch {
+ return EMPTY;
+ }
+};
+
+const read = (): Stored => {
+ held ??= load();
+ return held;
+};
+
+const asSnapshot = (stored: Stored): Blocked => ({
+ pubkeys: new Set(stored.p),
+ eventIds: new Set(stored.e),
+ coordinates: new Set(stored.a),
+ owed: stored.owed,
+ published,
+});
+
+/** Kept in memory whether or not the disk took it: storage is an accelerator, never a dependency. */
+const write = (next: Stored): void => {
+ held = next;
+ snapshot = asSnapshot(next);
+ try {
+ store()?.setItem(BLOCKED_KEY, JSON.stringify(next));
+ } catch {}
+ for (const listener of listeners) listener();
+};
+
+/** Another tab wrote the record. Both are the same reader, so this one reads it back. */
+const onStorage = (event: StorageEvent): void => {
+ if (event.key !== null && event.key !== BLOCKED_KEY) return;
+ held = load();
+ snapshot = asSnapshot(held);
+ for (const listener of listeners) listener();
+};
+
+export const subscribeBlocked = (listener: () => void): (() => void) => {
+ if (listeners.size === 0 && typeof window !== "undefined") {
+ window.addEventListener("storage", onStorage);
+ }
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && typeof window !== "undefined") {
+ window.removeEventListener("storage", onStorage);
+ }
+ };
+};
+
+export const blockedState = (): Blocked => {
+ if (held === null) snapshot = asSnapshot(read());
+ return snapshot;
+};
+
+/** The server knows nobody's blocks: every row is served, the browser hides its own. */
+export const serverBlockedState = (): Blocked => NO_BLOCKS;
+
+const same = (a: MuteTarget, b: MuteTarget): boolean => a.type === b.type && a.value === b.value;
+
+/**
+ * The sets say what is hidden; `owed` says how that differs from the last list
+ * the relays were given. A click that undoes an unsent one cancels the debt
+ * rather than adding a second entry, so five changes of mind owe nothing.
+ */
+const change = (target: MuteTarget, op: "add" | "remove"): void => {
+ if (!valid(target.type, target.value)) return;
+ const current = read();
+ const has = current[target.type].includes(target.value);
+ if (op === "add" ? has : !has) return;
+
+ const opposite = current.owed.find((entry) => entry.op !== op && same(entry, target));
+ const owed = opposite
+ ? current.owed.filter((entry) => entry !== opposite)
+ : [...current.owed, { ...target, op }];
+
+ write({
+ ...current,
+ [target.type]:
+ op === "add"
+ ? [...current[target.type], target.value]
+ : current[target.type].filter((value) => value !== target.value),
+ owed,
+ });
+};
+
+/** The click. The screen moves now; whatever publishes the list is told through `owed`. */
+export const block = (target: MuteTarget): void => change(target, "add");
+
+export const unblock = (target: MuteTarget): void => change(target, "remove");
+
+export const isBlocked = (blocked: Blocked, target: MuteTarget): boolean =>
+ (target.type === "p"
+ ? blocked.pubkeys
+ : target.type === "e"
+ ? blocked.eventIds
+ : blocked.coordinates
+ ).has(target.value);
+
+export const hidesSpec = (
+ blocked: Blocked,
+ spec: { pubkey: string; identifier: string },
+): boolean => blocked.pubkeys.has(spec.pubkey) || blocked.coordinates.has(toCoordinate(spec));
+
+export const hidesComment = (blocked: Blocked, comment: { id: string; pubkey: string }): boolean =>
+ blocked.pubkeys.has(comment.pubkey) || blocked.eventIds.has(comment.id);
+
+/**
+ * The reader's live list, merged in: a union with what is here, minus what is
+ * owed as a removal, since a relay still serving the revision before that
+ * removal must not put the item back. Nothing is taken out on the strength of
+ * its absence from the live list: an unblock made elsewhere waits for one made
+ * here.
+ */
+export const applyLive = (live: {
+ pubkeys: string[];
+ eventIds: string[];
+ coordinates: string[];
+}): void => {
+ const current = read();
+ const removing = (type: MuteType, value: string) =>
+ current.owed.some((entry) => entry.op === "remove" && same(entry, { type, value }));
+ const merge = (type: MuteType, mine: string[], theirs: string[]) => [
+ ...new Set([
+ ...mine,
+ ...theirs.filter((value) => valid(type, value) && !removing(type, value)),
+ ]),
+ ];
+
+ published = true;
+ write({
+ ...current,
+ p: merge("p", current.p, live.pubkeys),
+ e: merge("e", current.e, live.eventIds),
+ a: merge("a", current.a, live.coordinates),
+ });
+};
+
+/** These changes reached a signed event, so they are no longer owed. */
+export const settleOwed = (done: readonly Owed[]): void => {
+ const current = read();
+ write({
+ ...current,
+ owed: current.owed.filter(
+ (entry) => !done.some((settled) => settled.op === entry.op && same(settled, entry)),
+ ),
+ });
+};
+
+/** The signer refused. The blocks stay on this device; the debt is forgotten. */
+export const dropOwed = (): void => write({ ...read(), owed: [] });
+
+/** Everything here, owed again as additions. What "Publish now" does. */
+export const requeueAll = (): void => {
+ const current = read();
+ const adds = (type: MuteType) =>
+ current[type].map((value) => ({ op: "add" as const, type, value }));
+ write({
+ ...current,
+ owed: [
+ ...current.owed.filter((entry) => entry.op === "remove"),
+ ...adds("p"),
+ ...adds("e"),
+ ...adds("a"),
+ ],
+ });
+};
+
+/** A different key connected, or none: whatever list was read belonged to the last one. */
+export const markUnpublished = (): void => {
+ published = false;
+ write(read());
+};
+
+export const clearBlocked = (): void => {
+ held = null;
+ published = false;
+ snapshot = NO_BLOCKS;
+ try {
+ store()?.removeItem(BLOCKED_KEY);
+ } catch {}
+};
+
+export const useBlocked = (): Blocked =>
+ useSyncExternalStore(subscribeBlocked, blockedState, serverBlockedState);
+
+/** The rows a reader has not blocked. Stable while nothing changes, since `useLikes` keys an effect on it. */
+export const useShown = (rows: T[]): T[] => {
+ const blocked = useBlocked();
+ return useMemo(() => rows.filter((row) => !hidesSpec(blocked, row)), [rows, blocked]);
+};
diff --git a/apps/web/app/lib/discussion.test.ts b/apps/web/app/lib/discussion.test.ts
index abf9994..692f890 100644
--- a/apps/web/app/lib/discussion.test.ts
+++ b/apps/web/app/lib/discussion.test.ts
@@ -12,6 +12,7 @@ vi.mock("@openspecs/nostr", async (importOriginal) => ({
import { buildComment, buildReaction, type NostrEvent, SPEC_KIND } from "@openspecs/nostr";
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";
+import { block, clearBlocked } from "./blocked";
import {
clearDiscussion,
discussionState,
@@ -52,9 +53,10 @@ let main: Channel;
let references: Channel[];
beforeEach(() => {
- vi.stubGlobal("window", {});
+ vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
vi.useFakeTimers();
clearDiscussion();
+ clearBlocked();
references = [];
nostr.subscribeDiscussion.mockImplementation((_pointer, onEvent, options) => {
@@ -257,6 +259,33 @@ describe("what it counts", () => {
expect(discussionState().document.reactions[0]).toMatchObject({ symbol: "+", count: 1 });
});
+ it("takes a blocked key's reaction out of the tally, and keeps their comment in the record", async () => {
+ startDiscussion(POINTER);
+ main.send(
+ finalizeEvent(
+ {
+ ...buildReaction({
+ id: SPEC_EVENT_ID,
+ pubkey: author,
+ kind: SPEC_KIND,
+ coordinate: ROOT.coordinate,
+ }),
+ created_at: 10,
+ },
+ readerKey,
+ ),
+ );
+ main.send(comment("still here, folded", 11));
+ main.eose();
+ await vi.advanceTimersByTimeAsync(600);
+
+ block({ type: "p", value: getPublicKey(readerKey) });
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(discussionState().document.reactions).toEqual([]);
+ expect(discussionState().count).toBe(1);
+ });
+
it("keeps a reply under the comment it answers", async () => {
startDiscussion(POINTER);
const parent = comment("the parent", 10);
diff --git a/apps/web/app/lib/discussion.ts b/apps/web/app/lib/discussion.ts
index 370c6bf..2b2a704 100644
--- a/apps/web/app/lib/discussion.ts
+++ b/apps/web/app/lib/discussion.ts
@@ -15,6 +15,7 @@ import {
threadComments,
totalSats,
} from "@openspecs/nostr";
+import { blockedState, subscribeBlocked } from "./blocked";
export type DiscussionStatus = "idle" | "loading" | "ready";
@@ -82,6 +83,7 @@ let events = new Map();
let asked = new Set();
let subscriptions: DiscussionSubscription[] = [];
let timer: ReturnType | null = null;
+let unwatch: (() => void) | null = null;
const listeners = new Set<() => void>();
@@ -147,11 +149,21 @@ const recompute = (): Recomputed => {
// Sorted twice, because a reply naming no document is only admitted once the
// comments that do name it are known, and the first pass is what finds them.
const named = sortDiscussion(all, current.coordinate, scope);
- const discussion = sortDiscussion(all, current.coordinate, {
+ const sorted = sortDiscussion(all, current.coordinate, {
...scope,
threadIds: new Set(named.comments.map((comment) => comment.id)),
});
+ // A blocked key's comments stay in the record, collapsed where they are drawn
+ // so the replies under them keep their place. Their reactions and zaps have
+ // no place to keep, so they leave the tallies here.
+ const hidden = blockedState().pubkeys;
+ const discussion = {
+ ...sorted,
+ reactions: sorted.reactions.filter((reaction) => !hidden.has(reaction.pubkey)),
+ zaps: sorted.zaps.filter((zap) => zap.zapper === null || !hidden.has(zap.zapper)),
+ };
+
paid = new Set(discussion.zaps.map((zap) => zap.bolt11.trim().toLowerCase()));
const byComment: Record = {};
@@ -292,6 +304,7 @@ const close = (): void => {
*/
export const startDiscussion = (next: DiscussionPointer): void => {
if (typeof window === "undefined") return;
+ unwatch ??= subscribeBlocked(publishSoon);
if (subscriptions.length > 0 && pointer?.coordinate === next.coordinate) return;
close();
@@ -350,6 +363,8 @@ export const stopDiscussion = (): void => close();
/** Test seam, and what a reader leaving the document behind eventually calls. */
export const clearDiscussion = (): void => {
close();
+ unwatch?.();
+ unwatch = null;
events = new Map();
asked = new Set();
paid = new Set();
diff --git a/apps/web/app/lib/likes.test.ts b/apps/web/app/lib/likes.test.ts
index 558d74d..0a9cdf7 100644
--- a/apps/web/app/lib/likes.test.ts
+++ b/apps/web/app/lib/likes.test.ts
@@ -17,6 +17,7 @@ import {
} from "@openspecs/nostr";
import type { Filter } from "nostr-tools/filter";
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";
+import { block, clearBlocked, unblock } from "./blocked";
import { clearLikes, likeKey, likesState, rememberLike, subscribeLikes, wantLikes } from "./likes";
const authorKey = generateSecretKey();
@@ -65,14 +66,17 @@ const filters = () => mocks.queryRelays.mock.calls.map((call) => call[1] as Filt
const settle = () => vi.advanceTimersByTimeAsync(200);
beforeEach(() => {
- vi.stubGlobal("window", {});
+ vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
vi.useFakeTimers();
clearLikes();
+ clearBlocked();
mocks.queryRelays.mockReset();
holding([]);
});
afterEach(() => {
+ clearLikes();
+ clearBlocked();
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -155,6 +159,31 @@ describe("wantLikes", () => {
});
});
+describe("what a block hides", () => {
+ it("leaves out a like from a blocked key, before and after the count arrived", async () => {
+ holding([reaction(aliceKey, A), reaction(bobKey, A)]);
+ wantLikes([A]);
+ await settle();
+ expect(likesState()[A]).toBe(2);
+
+ block({ type: "p", value: alice });
+ expect(likesState()[A]).toBe(1);
+
+ unblock({ type: "p", value: alice });
+ expect(likesState()[A]).toBe(2);
+ });
+
+ it("keeps this browser's own click on top of the count it redraws", async () => {
+ holding([reaction(aliceKey, A)]);
+ wantLikes([A]);
+ await settle();
+ rememberLike(A, 1);
+
+ block({ type: "p", value: alice });
+ expect(likesState()[A]).toBe(1);
+ });
+});
+
describe("rememberLike", () => {
it("moves a count this browser has, and tells whoever is watching", async () => {
holding([reaction(bobKey, A)]);
diff --git a/apps/web/app/lib/likes.ts b/apps/web/app/lib/likes.ts
index e9aaa78..07a85fd 100644
--- a/apps/web/app/lib/likes.ts
+++ b/apps/web/app/lib/likes.ts
@@ -1,5 +1,6 @@
-import { type Reaction, toCoordinate } from "@openspecs/nostr";
+import { type Deletion, type Reaction, toCoordinate } from "@openspecs/nostr";
import { useEffect, useSyncExternalStore } from "react";
+import { blockedState, subscribeBlocked } from "./blocked";
/** How many liked each document, by coordinate. Only what has been asked for is here. */
export type Likes = Record;
@@ -9,10 +10,18 @@ export const NO_LIKES: Likes = Object.freeze({});
/** Long enough that a page of rows asks once, short enough not to be felt. */
const BATCH_MS = 200;
+/** What the relays said about one document, kept whole so the count can be redrawn. */
+type Held = { likes: Reaction[]; deletions: Deletion[] };
+
let state: Likes = NO_LIKES;
+let held = new Map();
+/** What this browser's own clicks moved a count by, until the relays are asked. */
+let adjust: Record = {};
let asked = new Set();
let pending: string[] = [];
let timer: ReturnType | null = null;
+let unwatch: (() => void) | null = null;
+let nostr: typeof import("@openspecs/nostr") | null = null;
const listeners = new Set<() => void>();
@@ -34,6 +43,25 @@ const notify = (): void => {
export const likeKey = (spec: { pubkey: string; identifier: string }): string => toCoordinate(spec);
+/**
+ * Derived rather than stored, from what the relays said and what this browser
+ * did since: a reader who blocks somebody mid-page is owed a count without them,
+ * and the relays are not asked again for that.
+ */
+const recount = (): void => {
+ const hidden = blockedState().pubkeys;
+ const next: Likes = {};
+ for (const [coordinate, { likes, deletions }] of held) {
+ const shown = likes.filter((like) => !hidden.has(like.pubkey));
+ next[coordinate] = nostr === null ? 0 : nostr.likeCount(nostr.tallyReactions(shown, deletions));
+ }
+ for (const [coordinate, delta] of Object.entries(adjust)) {
+ next[coordinate] = Math.max(0, (next[coordinate] ?? 0) + delta);
+ }
+ state = next;
+ notify();
+};
+
/**
* Two passes, the way the discussion reads them: the likes by the documents'
* coordinates, then the retractions by the likes' ids, since nothing indexes a
@@ -45,18 +73,17 @@ export const likeKey = (spec: { pubkey: string; identifier: string }): string =>
*/
const resolve = async (coordinates: string[]): Promise => {
try {
+ nostr ??= await import("@openspecs/nostr");
const {
CONVERSATION_RELAYS,
DELETION_KIND,
inChunks,
LIKE,
- likeCount,
parseDeletion,
parseReaction,
queryRelays,
REACTION_KIND,
- tallyReactions,
- } = await import("@openspecs/nostr");
+ } = nostr;
const wanted = new Set(coordinates);
const found = await Promise.all(
@@ -89,13 +116,16 @@ const resolve = async (coordinates: string[]): Promise => {
.map(parseDeletion)
.filter((deletion) => deletion !== null);
- const next = { ...state };
for (const coordinate of coordinates) {
- const own = likes.filter((like) => like.targetCoordinate === coordinate);
- next[coordinate] = likeCount(tallyReactions(own, deletions));
+ held.set(coordinate, {
+ likes: likes.filter((like) => like.targetCoordinate === coordinate),
+ deletions,
+ });
+ // The relays' answer is the count from here on. A click made while they
+ // were being asked is either in it already or arrives with the next ask.
+ delete adjust[coordinate];
}
- state = next;
- notify();
+ recount();
} catch {
// Rows keep their empty slot, which is what they were drawn with.
}
@@ -104,6 +134,7 @@ const resolve = async (coordinates: string[]): Promise => {
/** Each coordinate is asked for once per session, and the ones that appear together, together. */
export const wantLikes = (coordinates: string[]): void => {
if (typeof window === "undefined") return;
+ unwatch ??= subscribeBlocked(recount);
const fresh = coordinates.filter((coordinate) => !asked.has(coordinate));
if (fresh.length === 0) return;
@@ -125,8 +156,8 @@ export const wantLikes = (coordinates: string[]): void => {
*/
export const rememberLike = (coordinate: string, delta: 1 | -1): void => {
if (!asked.has(coordinate)) return;
- state = { ...state, [coordinate]: Math.max(0, (state[coordinate] ?? 0) + delta) };
- notify();
+ adjust[coordinate] = (adjust[coordinate] ?? 0) + delta;
+ recount();
};
/** The counts for a page of rows, asked for together and filled in as they arrive. */
@@ -140,8 +171,12 @@ export const useLikes = (specs: { pubkey: string; identifier: string }[]): Likes
export const clearLikes = (): void => {
state = NO_LIKES;
+ held = new Map();
+ adjust = {};
asked = new Set();
pending = [];
+ unwatch?.();
+ unwatch = null;
if (timer !== null) clearTimeout(timer);
timer = null;
};
diff --git a/apps/web/app/lib/notifications.test.ts b/apps/web/app/lib/notifications.test.ts
index 5070f64..a2c13c3 100644
--- a/apps/web/app/lib/notifications.test.ts
+++ b/apps/web/app/lib/notifications.test.ts
@@ -123,7 +123,7 @@ const handle = () => {
};
beforeEach(() => {
- vi.stubGlobal("window", {});
+ vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
vi.stubGlobal("localStorage", fakeStorage());
vi.useFakeTimers();
clearNotices();
diff --git a/apps/web/app/lib/notifications.ts b/apps/web/app/lib/notifications.ts
index 6f4b261..3af42cc 100644
--- a/apps/web/app/lib/notifications.ts
+++ b/apps/web/app/lib/notifications.ts
@@ -1,5 +1,6 @@
import type { NostrEvent, Notice, Spec, Subscription } from "@openspecs/nostr";
import { alertPermission, alertsWanted, clearAlerts, showAlert } from "./alerts";
+import { blockedState, subscribeBlocked } from "./blocked";
import { alertLine, noticePath } from "./notice-copy";
import { authorName } from "./profile";
import { authorsState } from "./profiles";
@@ -64,6 +65,7 @@ let weighed = new Set();
let alertsFrom = 0;
let subscriptions: Subscription[] = [];
let timer: ReturnType | null = null;
+let unwatch: (() => void) | null = null;
/**
* The relay client, once it has been fetched. Asked for rather than imported,
@@ -93,7 +95,11 @@ const notify = (): void => {
const recompute = (): NoticesState => {
if (me === null || nostr === null) return NO_NOTICES;
const mark = seenAt(me) ?? 0;
- const notices = nostr.sortNotices([...events.values()], { me, copies: [...copies.values()] });
+ const notices = nostr.sortNotices([...events.values()], {
+ me,
+ copies: [...copies.values()],
+ muted: blockedState(),
+ });
return {
me,
@@ -285,6 +291,7 @@ const open = async (pubkey: string): Promise => {
*/
export const startNotices = (pubkey: string): void => {
if (typeof window === "undefined") return;
+ unwatch ??= subscribeBlocked(publishSoon);
if (subscriptions.length > 0 && me === pubkey) return;
close();
@@ -330,6 +337,8 @@ export const markNoticesSeen = (): void => {
/** Signing out. The mark stays where it is: it belongs to the key, not the session. */
export const clearNotices = (): void => {
close();
+ unwatch?.();
+ unwatch = null;
events = new Map();
copies = new Map();
names = new Set();
diff --git a/apps/web/app/lib/paths.ts b/apps/web/app/lib/paths.ts
index 6f60bb2..3007c03 100644
--- a/apps/web/app/lib/paths.ts
+++ b/apps/web/app/lib/paths.ts
@@ -112,13 +112,15 @@ export const returnTo = (next: string | null): string => {
};
/**
- * What a key says about itself, where its things are kept, and how this browser
- * behaves. Three separate things, so three addresses: the first is the page
- * itself, since a profile is what somebody coming here almost always wants.
+ * What a key says about itself, where its things are kept, how this browser
+ * behaves, and what its reader chose not to see. Four separate things, so four
+ * addresses: the first is the page itself, since a profile is what somebody
+ * coming here almost always wants.
*/
export const settingsPath = (): string => "/settings";
export const relaySettingsPath = (): string => "/settings/relays";
export const browserSettingsPath = (): string => "/settings/browser";
+export const blockedSettingsPath = (): string => "/settings/blocked";
/**
* Writing a document again, under the address it already has. `specPath` is
diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts
index 414a0e7..feea2c7 100644
--- a/apps/web/app/routes.ts
+++ b/apps/web/app/routes.ts
@@ -8,6 +8,7 @@ export default [
index("routes/settings/profile.tsx"),
route("relays", "routes/settings/relays.tsx"),
route("browser", "routes/settings/browser.tsx"),
+ route("blocked", "routes/settings/blocked.tsx"),
]),
route("connect", "routes/connect.tsx"),
route("notifications", "routes/notifications.tsx"),
diff --git a/apps/web/app/routes/author.tsx b/apps/web/app/routes/author.tsx
index 8b93d19..e9d766d 100644
--- a/apps/web/app/routes/author.tsx
+++ b/apps/web/app/routes/author.tsx
@@ -9,13 +9,16 @@ import {
specPath,
toNpub,
} from "@openspecs/nostr";
+import { useState } from "react";
import { data, redirect } from "react-router";
import { AuthorAvatar } from "~/components/author-avatar";
import { CopyButton } from "~/components/copy-button";
import { ErrorPage } from "~/components/error-page";
+import { BlockedNotice } from "~/components/moderation/blocked-notice";
import { Pagination } from "~/components/pagination";
import { Shell } from "~/components/shell";
import { SpecRow } from "~/components/spec-row";
+import { unblock, useBlocked, useShown } from "~/lib/blocked";
import { withDeadline } from "~/lib/cache.server";
import { keyTextColor } from "~/lib/color";
import { parsePage } from "~/lib/filter";
@@ -292,8 +295,27 @@ const Masthead = ({
);
export default function AuthorRoute({ loaderData }: Route.ComponentProps) {
- const { pubkey, npub, author, confirmed, specs, page, pages, total, capped, oldest } = loaderData;
+ const { pubkey, npub, author, confirmed, page, pages, total, capped, oldest } = loaderData;
+ const specs = useShown(loaderData.specs);
const likes = useLikes(specs);
+ const blocked = useBlocked();
+ const [shownAnyway, setShownAnyway] = useState(false);
+
+ // The name and the picture go with the rest: they are this key's words too.
+ if (blocked.pubkeys.has(pubkey) && !shownAnyway) {
+ return (
+
+
+ unblock({ type: "p", value: pubkey })}
+ onShow={() => setShownAnyway(true)}
+ />
+
+
+ );
+ }
return (
diff --git a/apps/web/app/routes/home.tsx b/apps/web/app/routes/home.tsx
index 95984b2..e7c6928 100644
--- a/apps/web/app/routes/home.tsx
+++ b/apps/web/app/routes/home.tsx
@@ -2,6 +2,7 @@ import { Link } from "react-router";
import { SearchBox } from "~/components/search-box";
import { Shell } from "~/components/shell";
import { SpecRow } from "~/components/spec-row";
+import { useShown } from "~/lib/blocked";
import { PAGE_HEADERS } from "~/lib/http";
import { likeKey, useLikes } from "~/lib/likes";
import { publicOrigin } from "~/lib/origin.server";
@@ -65,7 +66,8 @@ export function meta({ loaderData }: Route.MetaArgs) {
}
export default function Home({ loaderData }: Route.ComponentProps) {
- const { specs, authors } = loaderData;
+ const { authors } = loaderData;
+ const specs = useShown(loaderData.specs);
const likes = useLikes(specs);
return (
diff --git a/apps/web/app/routes/settings.tsx b/apps/web/app/routes/settings.tsx
index 8bdf017..0726aa5 100644
--- a/apps/web/app/routes/settings.tsx
+++ b/apps/web/app/routes/settings.tsx
@@ -3,7 +3,12 @@ import { useEffect, useSyncExternalStore } from "react";
import { NavLink, Outlet } from "react-router";
import { TAB_OFF, TAB_ON } from "~/components/chrome";
import { Shell } from "~/components/shell";
-import { browserSettingsPath, relaySettingsPath, settingsPath } from "~/lib/paths";
+import {
+ blockedSettingsPath,
+ browserSettingsPath,
+ relaySettingsPath,
+ settingsPath,
+} from "~/lib/paths";
import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session";
/** Whose settings these are, read once here and handed to whichever tab is open. */
@@ -50,14 +55,15 @@ export default function SettingsRoute() {
)}
- {/* All three whether or not a key is connected: the last one is this
- browser's own and has nothing to do with a key. */}
+ {/* All four whether or not a key is connected: the last two are this
+ browser's own and work without a key. */}
diff --git a/apps/web/app/routes/settings/blocked.tsx b/apps/web/app/routes/settings/blocked.tsx
new file mode 100644
index 0000000..d672b7c
--- /dev/null
+++ b/apps/web/app/routes/settings/blocked.tsx
@@ -0,0 +1,21 @@
+import { BlockedList } from "~/components/settings/blocked-list";
+import { PAGE_HEADERS } from "~/lib/http";
+import type { Route } from "./+types/blocked";
+
+export function meta(_: Route.MetaArgs) {
+ return [{ title: "Blocked | Open Specs" }, { name: "robots", content: "noindex, nofollow" }];
+}
+
+export function headers(_: Route.HeadersArgs) {
+ return PAGE_HEADERS;
+}
+
+/**
+ * What this reader chose not to see. A tab of its own rather than a heading
+ * under the browser's settings: the list is this browser's until a key is
+ * connected and that key's afterwards, so it belongs to neither page, and it
+ * needs neither a key nor an open one to be read.
+ */
+export default function BlockedSettings() {
+ return ;
+}
diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx
index 6339ca7..28b4aff 100644
--- a/apps/web/app/routes/spec.tsx
+++ b/apps/web/app/routes/spec.tsx
@@ -7,6 +7,7 @@ import {
toCoordinate,
toNpub,
} from "@openspecs/nostr";
+import { useState } from "react";
import { data, Link, redirect } from "react-router";
import { AnnotatedDoc } from "~/components/annotated-doc";
import { AuthorAvatar } from "~/components/author-avatar";
@@ -16,10 +17,12 @@ import { LikeButton } from "~/components/discussion/like-button";
import { EditLink } from "~/components/editor/edit-link";
import { Withdraw } from "~/components/editor/withdraw";
import { ErrorPage } from "~/components/error-page";
+import { BlockedNotice } from "~/components/moderation/blocked-notice";
import { Rebroadcast } from "~/components/rebroadcast";
import { Shell } from "~/components/shell";
import { SpecTags } from "~/components/spec-tags";
import { VARIANTS_ID, Variants } from "~/components/variants";
+import { hidesSpec, unblock, useBlocked } from "~/lib/blocked";
import { keyTextColor } from "~/lib/color";
import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http";
import { useLiveRevision } from "~/lib/live-revision";
@@ -27,7 +30,7 @@ import { publicOrigin } from "~/lib/origin.server";
import { DISCUSSION_ID, eventPath, oembedPath, ogImagePath } from "~/lib/paths";
import type { LinkPreview } from "~/lib/preview";
import { loadLinkPreviews } from "~/lib/preview.server";
-import type { Author } from "~/lib/profile";
+import { type Author, shortNpub } from "~/lib/profile";
import { loadAuthor } from "~/lib/profile.server";
import { discussionRelays, rebroadcastRelays } from "~/lib/relays.server";
import type { SpecPage } from "~/lib/spec-page";
@@ -392,12 +395,42 @@ const Article = ({
}) => {
const { shown, fresher, show } = useLiveRevision(served);
// On the shown revision, not the served one: the marks sit on the text on screen.
- const { variants, spots, standings } = useVariants(shown);
+ const { variants: every, spots, standings } = useVariants(shown);
+
+ const blocked = useBlocked();
+ const [shownAnyway, setShownAnyway] = useState(false);
+ const variants = every.filter(
+ (variant) => !hidesSpec(blocked, { pubkey: variant.pubkey, identifier: shown.identifier }),
+ );
// A preview was fetched for the links the served revision cited. One that no
// longer appears in the document has no business under it.
const cited = previews.filter((preview) => shown.links.includes(preview.url));
+ // The title goes with the rest: it is the author's text too. What stays is
+ // the address, which is the reader's own way of knowing where they are.
+ const byAuthor = blocked.pubkeys.has(served.pubkey);
+ const byDocument = blocked.coordinates.has(toCoordinate(served));
+ if ((byAuthor || byDocument) && !shownAnyway) {
+ return (
+
+ {
+ if (byAuthor) unblock({ type: "p", value: served.pubkey });
+ if (byDocument) unblock({ type: "a", value: toCoordinate(served) });
+ }}
+ onShow={() => setShownAnyway(true)}
+ />
+
+ );
+ }
+
return (
{fresher !== null && }
diff --git a/apps/web/app/routes/specs.tsx b/apps/web/app/routes/specs.tsx
index 3d9bbb4..7044fd2 100644
--- a/apps/web/app/routes/specs.tsx
+++ b/apps/web/app/routes/specs.tsx
@@ -5,6 +5,7 @@ import { Pagination } from "~/components/pagination";
import { SearchResults } from "~/components/search-results";
import { Shell } from "~/components/shell";
import { SpecRow } from "~/components/spec-row";
+import { useShown } from "~/lib/blocked";
import { parsePage, parseSearchQuery, parseSpecFilter } from "~/lib/filter";
import { PAGE_HEADERS } from "~/lib/http";
import { likeKey, useLikes } from "~/lib/likes";
@@ -151,7 +152,8 @@ const Chip = ({ to, active, children }: { to: string; active: boolean; children:
);
export default function Specs({ loaderData }: Route.ComponentProps) {
- const { specs, authors, topics, topic, kind, query, filtered, page, pages } = loaderData;
+ const { authors, topics, topic, kind, query, filtered, page, pages } = loaderData;
+ const specs = useShown(loaderData.specs);
// Under a search the rows on screen are the results', which ask for their own.
const likes = useLikes(query === null ? specs : NO_ROWS);
From 5f97c7f66b4e9353e319332a9fc6fde43d07c3fb Mon Sep 17 00:00:00 2001
From: Nogringo
Date: Mon, 31 Aug 2026 02:01:19 +0200
Subject: [PATCH 03/10] feat: report a document, a comment or an account, and
block from the same place
---
.../web/app/components/discussion/comment.tsx | 2 +
apps/web/app/components/moderation/more.tsx | 110 +++++++++++
.../app/components/moderation/report-form.tsx | 177 ++++++++++++++++++
apps/web/app/components/moderation/styles.ts | 15 ++
apps/web/app/lib/relays.server.ts | 16 +-
apps/web/app/lib/relays.test.ts | 43 +++++
apps/web/app/lib/relays.ts | 49 ++++-
apps/web/app/routes/author.tsx | 2 +
apps/web/app/routes/spec.tsx | 10 +
9 files changed, 404 insertions(+), 20 deletions(-)
create mode 100644 apps/web/app/components/moderation/more.tsx
create mode 100644 apps/web/app/components/moderation/report-form.tsx
create mode 100644 apps/web/app/components/moderation/styles.ts
diff --git a/apps/web/app/components/discussion/comment.tsx b/apps/web/app/components/discussion/comment.tsx
index 1de368e..8dc7ff9 100644
--- a/apps/web/app/components/discussion/comment.tsx
+++ b/apps/web/app/components/discussion/comment.tsx
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { AuthorAvatar } from "~/components/author-avatar";
import { CHROME } from "~/components/chrome";
+import { More } from "~/components/moderation/more";
import { hidesComment, useBlocked } from "~/lib/blocked";
import { keyTextColor } from "~/lib/color";
import { NO_RESPONSE, type Response } from "~/lib/discussion";
@@ -175,6 +176,7 @@ export const CommentThread = ({
Reply
)}
+
{me !== null && replying && (
diff --git a/apps/web/app/components/moderation/more.tsx b/apps/web/app/components/moderation/more.tsx
new file mode 100644
index 0000000..80e9e10
--- /dev/null
+++ b/apps/web/app/components/moderation/more.tsx
@@ -0,0 +1,110 @@
+import { useEffect, useState, useSyncExternalStore } from "react";
+import { Link, useLocation } from "react-router";
+import { CHROME } from "~/components/chrome";
+import { block, unblock, useBlocked } from "~/lib/blocked";
+import { connectPath } from "~/lib/paths";
+import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session";
+import { ReportForm } from "./report-form";
+import { SUGGESTION } from "./styles";
+
+export type MoreTarget =
+ | { kind: "document"; pubkey: string; id: string; coordinate: string; identifier: string }
+ | { kind: "comment"; pubkey: string; id: string }
+ | { kind: "account"; pubkey: string };
+
+type Stage = "closed" | "menu" | "report";
+
+/** The same panel a withdrawal and a zap hang off their buttons. */
+const PANEL =
+ "absolute left-0 top-full z-10 mt-1 w-[min(22rem,calc(100vw-3rem))] space-y-3 rounded-sm border border-rule bg-paper p-3 normal-case tracking-normal shadow-sm";
+
+const ROW = `${SUGGESTION} text-left`;
+
+/**
+ * Report or block, behind one word, on a document, a comment and an account.
+ *
+ * Blocking works without a key: it is this browser deciding what it shows, and
+ * the page or the comment flips to the notice that carries the undo, so the
+ * menu closes on the click and says nothing more. Reporting is a signed event,
+ * so without a key it is a door to the sign in rather than a form.
+ *
+ * Nothing is offered on the reader's own words: a report on yourself is a
+ * mistake and a block on yourself is a bug.
+ */
+export const More = ({ target }: { target: MoreTarget }) => {
+ useEffect(restoreSession, []);
+ const session = useSyncExternalStore(subscribeSession, sessionState, serverSessionState);
+ const location = useLocation();
+ const blocked = useBlocked();
+ const [stage, setStage] = useState("closed");
+
+ const me = session.pubkey;
+ if (me !== null && me === target.pubkey) return null;
+
+ const noun = target.kind;
+ const accountBlocked = blocked.pubkeys.has(target.pubkey);
+ const thing =
+ target.kind === "document"
+ ? { type: "a" as const, value: target.coordinate, held: blocked.coordinates }
+ : target.kind === "comment"
+ ? { type: "e" as const, value: target.id, held: blocked.eventIds }
+ : null;
+ const thingBlocked = thing?.held.has(thing.value) === true;
+
+ const toggle = (type: "p" | "e" | "a", value: string, held: boolean) => {
+ (held ? unblock : block)({ type, value });
+ setStage("closed");
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/app/components/moderation/report-form.tsx b/apps/web/app/components/moderation/report-form.tsx
new file mode 100644
index 0000000..7fe2ebf
--- /dev/null
+++ b/apps/web/app/components/moderation/report-form.tsx
@@ -0,0 +1,177 @@
+import { buildReport, REPORT_TYPES, type ReportType } from "@openspecs/nostr";
+import { useId, useState } from "react";
+import { RelayReport } from "~/components/relay-results";
+import { block, useBlocked } from "~/lib/blocked";
+import { type RelayResult, signAndPublish } from "~/lib/publish";
+import { reportRelays } from "~/lib/relays";
+import type { MoreTarget } from "./more";
+import { FIELD, NOTE, SUGGESTION, WRONG } from "./styles";
+
+type Stage = "asking" | "sending" | "sent" | "failed";
+
+/** NIP-56's words, said the way a reader would say them. */
+const LABELS: Record = {
+ spam: "Spam",
+ illegal: "Illegal content",
+ nudity: "Nudity or sexual content",
+ profanity: "Hateful or abusive",
+ malware: "Malware or a scam",
+ impersonation: "Impersonation",
+ other: "Something else",
+};
+
+const said = (reason: unknown): string =>
+ reason instanceof Error && reason.message.trim() !== "" ? reason.message : "that did not work";
+
+/**
+ * One event, signed and sent wide. The two sentences before the form are the
+ * whole of what a reader is agreeing to, and they are there because a report
+ * feels like a private word to a moderator and is nothing of the kind.
+ */
+export const ReportForm = ({
+ me,
+ target,
+ onClose,
+}: {
+ me: string;
+ target: MoreTarget;
+ onClose: () => void;
+}) => {
+ const group = useId();
+ const blocked = useBlocked();
+ const [type, setType] = useState(null);
+ const [words, setWords] = useState("");
+ const [stage, setStage] = useState("asking");
+ const [relays, setRelays] = useState([]);
+ const [results, setResults] = useState([]);
+ const [error, setError] = useState(null);
+
+ const noun = target.kind;
+ const accountBlocked = blocked.pubkeys.has(target.pubkey);
+
+ const send = async () => {
+ if (type === null) return;
+ setStage("sending");
+ setResults([]);
+ setError(null);
+
+ const targets = reportRelays(me, target.pubkey);
+ targets.then(setRelays).catch(() => {});
+
+ const reported =
+ target.kind === "document"
+ ? { pubkey: target.pubkey, id: target.id, coordinate: target.coordinate }
+ : target.kind === "comment"
+ ? { pubkey: target.pubkey, id: target.id }
+ : { pubkey: target.pubkey };
+
+ try {
+ const report = await signAndPublish(buildReport(reported, type, words), targets, (result) =>
+ setResults((answered) => [...answered, result]),
+ );
+ if (report.accepted === 0) {
+ setStage("failed");
+ setError("No relay accepted it. Nothing was published.");
+ return;
+ }
+ setStage("sent");
+ } catch (reason) {
+ setStage("failed");
+ setError(said(reason));
+ }
+ };
+
+ if (stage === "asking") {
+ return (
+ <>
+
+ A report is a signed public event. Anyone can read it, and it names you as the reporter.
+
+
+ It goes to every relay this page knows about, so the people who run them see it. This site
+ hides nothing on the strength of a report; blocking is what does that, for you alone.
+
+
+
+
+
-
Kept on this device.
+
{standing(blocked, me)}
+ {blocked.hasPrivate && (
+
+ Your list also holds private items this site cannot read. They are kept as they are.
+
+ )}
+ {/* The way back after a signer said no: everything here, offered again. */}
+ {me !== null && !empty && (
+
+ )}
{empty &&
Nothing blocked.
}
diff --git a/apps/web/app/components/shell.tsx b/apps/web/app/components/shell.tsx
index d394559..83b818d 100644
--- a/apps/web/app/components/shell.tsx
+++ b/apps/web/app/components/shell.tsx
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Link } from "react-router";
+import { startMuteSync } from "~/lib/mute-list";
import { startOutbox } from "~/lib/outbox";
import { aboutPath, atomPath, newSpecPath, rssPath } from "~/lib/paths";
import { Identity } from "./identity";
@@ -84,8 +85,10 @@ export const Shell = ({
search?: boolean;
query?: string;
}) => {
- // What was signed and not yet taken by every relay is owed from every page.
+ // What was signed and not yet taken by every relay is owed from every page,
+ // and so is what was blocked and not yet signed.
useEffect(startOutbox, []);
+ useEffect(startMuteSync, []);
return (
diff --git a/apps/web/app/lib/blocked.test.ts b/apps/web/app/lib/blocked.test.ts
index 8f0e9f1..3c802a0 100644
--- a/apps/web/app/lib/blocked.test.ts
+++ b/apps/web/app/lib/blocked.test.ts
@@ -156,9 +156,12 @@ describe("the live list", () => {
});
it("is forgotten as published when the key changes", () => {
- applyLive({ pubkeys: [ALICE], eventIds: [], coordinates: [] });
+ applyLive({ pubkeys: [ALICE], eventIds: [], coordinates: [], hasPrivate: true });
+ expect(blockedState().hasPrivate).toBe(true);
+
markUnpublished();
expect(blockedState().published).toBe(false);
+ expect(blockedState().hasPrivate).toBe(false);
expect(blockedState().pubkeys.has(ALICE)).toBe(true);
});
});
diff --git a/apps/web/app/lib/blocked.ts b/apps/web/app/lib/blocked.ts
index 9d40682..261e512 100644
--- a/apps/web/app/lib/blocked.ts
+++ b/apps/web/app/lib/blocked.ts
@@ -23,6 +23,8 @@ export type Blocked = {
owed: readonly Owed[];
/** The connected key's signed list has been read this session, so the sets include it. */
published: boolean;
+ /** That list also holds encrypted items, which this site keeps as they are and cannot show. */
+ hasPrivate: boolean;
};
const EMPTY: Stored = { v: 1, p: [], e: [], a: [], owed: [] };
@@ -33,6 +35,7 @@ export const NO_BLOCKS: Blocked = Object.freeze({
coordinates: new Set(),
owed: [],
published: false,
+ hasPrivate: false,
});
const HEX_64 = /^[0-9a-f]{64}$/;
@@ -76,6 +79,7 @@ const store = (): Storage | undefined =>
let held: Stored | null = null;
let published = false;
+let hasPrivate = false;
let snapshot: Blocked = NO_BLOCKS;
const listeners = new Set<() => void>();
@@ -100,6 +104,7 @@ const asSnapshot = (stored: Stored): Blocked => ({
coordinates: new Set(stored.a),
owed: stored.owed,
published,
+ hasPrivate,
});
/** Kept in memory whether or not the disk took it: storage is an accelerator, never a dependency. */
@@ -201,6 +206,7 @@ export const applyLive = (live: {
pubkeys: string[];
eventIds: string[];
coordinates: string[];
+ hasPrivate?: boolean;
}): void => {
const current = read();
const removing = (type: MuteType, value: string) =>
@@ -213,6 +219,7 @@ export const applyLive = (live: {
];
published = true;
+ hasPrivate = live.hasPrivate === true;
write({
...current,
p: merge("p", current.p, live.pubkeys),
@@ -254,12 +261,14 @@ export const requeueAll = (): void => {
/** A different key connected, or none: whatever list was read belonged to the last one. */
export const markUnpublished = (): void => {
published = false;
+ hasPrivate = false;
write(read());
};
export const clearBlocked = (): void => {
held = null;
published = false;
+ hasPrivate = false;
snapshot = NO_BLOCKS;
try {
store()?.removeItem(BLOCKED_KEY);
diff --git a/apps/web/app/lib/mute-list.test.ts b/apps/web/app/lib/mute-list.test.ts
new file mode 100644
index 0000000..42d7fcd
--- /dev/null
+++ b/apps/web/app/lib/mute-list.test.ts
@@ -0,0 +1,329 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const session = vi.hoisted(() => {
+ const listeners = new Set<() => void>();
+ const box: { state: { pubkey: string | null; status: string } } = {
+ state: { pubkey: null, status: "locked" },
+ };
+ return { listeners, box };
+});
+
+const mocks = vi.hoisted(() => ({
+ fetchMuteList: vi.fn(),
+ muteListRelays: vi.fn(),
+ outboxRelays: vi.fn(),
+ signDraft: vi.fn(),
+ enqueue: vi.fn(),
+}));
+
+vi.mock("@openspecs/nostr", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchMuteList: mocks.fetchMuteList,
+}));
+vi.mock("./relays", () => ({
+ muteListRelays: mocks.muteListRelays,
+ outboxRelays: mocks.outboxRelays,
+}));
+vi.mock("./publish", () => ({ signDraft: mocks.signDraft }));
+vi.mock("./outbox", () => ({ enqueue: mocks.enqueue }));
+vi.mock("./session", () => ({
+ sessionState: () => session.box.state,
+ subscribeSession: (listener: () => void) => {
+ session.listeners.add(listener);
+ return () => session.listeners.delete(listener);
+ },
+}));
+
+import { MUTE_LIST_KIND, type NostrEvent } from "@openspecs/nostr";
+import { applyLive, block, blockedState, clearBlocked, unblock } from "./blocked";
+import { clearMuteSync, startMuteSync, syncMuteList } from "./mute-list";
+import { SessionLocked } from "./signer";
+
+const ME = "1".repeat(64);
+const ALICE = "2".repeat(64);
+const BOB = "3".repeat(64);
+const MINE = ["wss://mine.example/"];
+const DEFAULTS = ["wss://site.example/"];
+
+const fakeStorage = (): Storage => {
+ const held = new Map();
+ return {
+ getItem: (key: string) => held.get(key) ?? null,
+ setItem: (key: string, value: string) => held.set(key, value),
+ removeItem: (key: string) => held.delete(key),
+ clear: () => held.clear(),
+ key: () => null,
+ length: 0,
+ } as unknown as Storage;
+};
+
+const list = (tags: string[][], at: number, content = ""): NostrEvent => ({
+ id: String(at).padStart(64, "0"),
+ pubkey: ME,
+ created_at: at,
+ kind: MUTE_LIST_KIND,
+ tags,
+ content,
+ sig: "c".repeat(128),
+});
+
+/** What the relays answer: the newest list they hold, and who finished saying so. */
+const relaysHold = (event: NostrEvent | null, answered: string[]) =>
+ mocks.fetchMuteList.mockResolvedValue({ event, answered });
+
+const connect = (pubkey: string | null, status = "ready") => {
+ session.box.state = { pubkey, status };
+ for (const listener of session.listeners) listener();
+};
+
+let signed = 0;
+const signedDrafts = () => mocks.signDraft.mock.calls.map((call) => call[0]);
+const tagsSigned = () =>
+ signedDrafts().map((draft) => draft.tags.filter((tag: string[]) => tag[0] !== "client"));
+
+const flush = () => vi.advanceTimersByTimeAsync(0);
+
+beforeEach(() => {
+ vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
+ vi.stubGlobal("localStorage", fakeStorage());
+ vi.useFakeTimers();
+ vi.setSystemTime(1_700_000_000_000);
+ vi.clearAllMocks();
+ clearBlocked();
+ clearMuteSync();
+ session.listeners.clear();
+ session.box.state = { pubkey: null, status: "locked" };
+ signed = 0;
+
+ mocks.muteListRelays.mockResolvedValue([...MINE, ...DEFAULTS]);
+ mocks.outboxRelays.mockResolvedValue(MINE);
+ mocks.signDraft.mockImplementation(async (draft) => ({
+ ...draft,
+ id: `f${String(++signed).padStart(63, "0")}`,
+ pubkey: ME,
+ sig: "",
+ }));
+ relaysHold(null, [...MINE, ...DEFAULTS]);
+});
+
+afterEach(() => {
+ clearMuteSync();
+ clearBlocked();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("what a round waits for", () => {
+ it("signs nothing and merges nothing while no relay has answered", async () => {
+ block({ type: "p", value: ALICE });
+ relaysHold(list([["p", BOB]], 100), []);
+ connect(ME);
+ startMuteSync();
+ await flush();
+
+ expect(mocks.signDraft).not.toHaveBeenCalled();
+ expect(blockedState().pubkeys.has(BOB)).toBe(false);
+ expect(blockedState().owed).toHaveLength(1);
+ });
+
+ it("does not take a default relay's word for what my own relays hold", async () => {
+ block({ type: "p", value: ALICE });
+ relaysHold(null, DEFAULTS);
+ connect(ME);
+ startMuteSync();
+ await flush();
+
+ expect(mocks.signDraft).not.toHaveBeenCalled();
+ expect(blockedState().owed).toHaveLength(1);
+ });
+
+ it("tries again a minute later", async () => {
+ block({ type: "p", value: ALICE });
+ relaysHold(null, []);
+ connect(ME);
+ startMuteSync();
+ await flush();
+ relaysHold(null, MINE);
+ await vi.advanceTimersByTimeAsync(60_000);
+
+ expect(mocks.signDraft).toHaveBeenCalledTimes(1);
+ expect(blockedState().owed).toEqual([]);
+ });
+});
+
+describe("what a round signs", () => {
+ it("starts a fresh list for a key that has none, once its relays have said so", async () => {
+ block({ type: "p", value: ALICE });
+ relaysHold(null, MINE);
+ connect(ME);
+ startMuteSync();
+ await flush();
+
+ expect(tagsSigned()).toEqual([[["p", ALICE]]]);
+ expect(blockedState().owed).toEqual([]);
+ expect(blockedState().published).toBe(true);
+ expect(mocks.enqueue).toHaveBeenCalledWith(expect.objectContaining({ kind: MUTE_LIST_KIND }), [
+ ...MINE,
+ ...DEFAULTS,
+ ]);
+ });
+
+ it("keeps every tag and the content of the live list, and dates the new one after it", async () => {
+ const live = list(
+ [
+ ["p", BOB],
+ ["word", "spoiler"],
+ ],
+ 1_700_000_050,
+ "abc=",
+ );
+ block({ type: "a", value: `30817:${ALICE}:custom` });
+ relaysHold(live, MINE);
+ connect(ME);
+ startMuteSync();
+ await flush();
+
+ const draft = signedDrafts()[0];
+ expect(draft.content).toBe("abc=");
+ expect(draft.tags).toEqual([
+ ["p", BOB],
+ ["word", "spoiler"],
+ ["a", `30817:${ALICE}:custom`],
+ ["client", "Open Specs"],
+ ]);
+ expect(draft.created_at).toBe(1_700_000_051);
+ });
+
+ it("takes the live list in, as a union with what is here", async () => {
+ block({ type: "p", value: ALICE });
+ relaysHold(list([["p", BOB]], 100), MINE);
+ connect(ME);
+ startMuteSync();
+ await flush();
+
+ expect([...blockedState().pubkeys]).toEqual([ALICE, BOB]);
+ });
+
+ it("settles a removal of something the live list never held without signing", async () => {
+ applyLive({ pubkeys: [ALICE], eventIds: [], coordinates: [] });
+ unblock({ type: "p", value: ALICE });
+ relaysHold(list([["p", BOB]], 100), MINE);
+ connect(ME);
+ startMuteSync();
+ await flush();
+
+ expect(mocks.signDraft).not.toHaveBeenCalled();
+ expect(blockedState().owed).toEqual([]);
+ expect(blockedState().pubkeys.has(ALICE)).toBe(false);
+ });
+
+ it("builds on what it signed last when a relay still serves the revision before", async () => {
+ connect(ME);
+ startMuteSync();
+ await flush();
+ block({ type: "p", value: ALICE });
+ await flush();
+ expect(tagsSigned()).toEqual([[["p", ALICE]]]);
+
+ block({ type: "p", value: BOB });
+ await flush();
+ expect(tagsSigned()[1]).toEqual([
+ ["p", ALICE],
+ ["p", BOB],
+ ]);
+ });
+});
+
+describe("what a refused signature does", () => {
+ it("keeps the debt for a key still under its passphrase, and pays it once the key opens", async () => {
+ mocks.signDraft.mockRejectedValueOnce(new SessionLocked());
+ block({ type: "p", value: ALICE });
+ connect(ME, "locked");
+ startMuteSync();
+ await flush();
+ expect(blockedState().owed).toHaveLength(1);
+
+ connect(ME, "ready");
+ await flush();
+ expect(mocks.signDraft).toHaveBeenCalledTimes(2);
+ expect(blockedState().owed).toEqual([]);
+ });
+
+ it("drops the debt when the reader said no, and keeps the blocks on this device", async () => {
+ mocks.signDraft.mockRejectedValueOnce(new Error("user rejected"));
+ block({ type: "p", value: ALICE });
+ connect(ME);
+ startMuteSync();
+ await flush();
+
+ expect(blockedState().owed).toEqual([]);
+ expect(blockedState().pubkeys.has(ALICE)).toBe(true);
+ expect(mocks.enqueue).not.toHaveBeenCalled();
+ });
+});
+
+describe("when a round runs", () => {
+ it("runs nothing while nobody is connected", async () => {
+ block({ type: "p", value: ALICE });
+ startMuteSync();
+ await flush();
+ expect(mocks.fetchMuteList).not.toHaveBeenCalled();
+ });
+
+ it("runs one round at a time, and once more for a click that landed mid-round", async () => {
+ let answer: (read: { event: null; answered: string[] }) => void = () => {};
+ mocks.fetchMuteList.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ connect(ME);
+ startMuteSync();
+ await flush();
+ block({ type: "p", value: ALICE });
+ block({ type: "p", value: BOB });
+ await flush();
+ expect(mocks.fetchMuteList).toHaveBeenCalledTimes(1);
+
+ answer({ event: null, answered: MINE });
+ await flush();
+ expect(mocks.fetchMuteList).toHaveBeenCalledTimes(2);
+ expect(tagsSigned()).toEqual([
+ [
+ ["p", ALICE],
+ ["p", BOB],
+ ],
+ ]);
+ });
+
+ it("forgets the last key's list when another connects", async () => {
+ relaysHold(list([["p", BOB]], 100), MINE);
+ connect(ME);
+ startMuteSync();
+ await flush();
+ expect(blockedState().published).toBe(true);
+
+ connect(null);
+ expect(blockedState().published).toBe(false);
+ });
+
+ it("runs again when the browser comes back online", async () => {
+ connect(ME);
+ startMuteSync();
+ await flush();
+ const online = (window.addEventListener as ReturnType).mock.calls.find(
+ (call) => call[0] === "online",
+ )?.[1] as () => void;
+
+ online();
+ await flush();
+ expect(mocks.fetchMuteList).toHaveBeenCalledTimes(2);
+ });
+
+ it("can be asked for by hand", async () => {
+ connect(ME);
+ await syncMuteList();
+ expect(mocks.fetchMuteList).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/app/lib/mute-list.ts b/apps/web/app/lib/mute-list.ts
new file mode 100644
index 0000000..44db753
--- /dev/null
+++ b/apps/web/app/lib/mute-list.ts
@@ -0,0 +1,186 @@
+import type { NostrEvent } from "@openspecs/nostr";
+import {
+ applyLive,
+ blockedState,
+ dropOwed,
+ markUnpublished,
+ type Owed,
+ settleOwed,
+ subscribeBlocked,
+} from "./blocked";
+import { sessionState, subscribeSession } from "./session";
+import { SessionLocked, SessionMissing } from "./signer";
+
+/**
+ * How long the relays are given to say what list they hold. The same figure as
+ * a like's patience, and the opposite consequence: a like left unsent is a
+ * click lost, so it goes out anyway, while a list built on nothing overwrites a
+ * list kept elsewhere, so it does not.
+ */
+const READ_TIMEOUT_MS = 5000;
+
+const RETRY_MS = 60_000;
+
+let started = false;
+let inFlight: Promise | null = null;
+let again = false;
+let retry: ReturnType | null = null;
+/** What this tab signed last: a relay may still be serving the revision before it. */
+let lastSigned: NostrEvent | null = null;
+let owedSeen = 0;
+
+const NOTHING = { pubkeys: [], eventIds: [], coordinates: [], hasPrivate: false };
+
+const newer = (a: NostrEvent | null, b: NostrEvent | null): NostrEvent | null =>
+ a === null ? b : b === null ? a : b.created_at > a.created_at ? b : a;
+
+const withoutClient = (tags: string[][]): string =>
+ JSON.stringify(tags.filter((tag) => tag[0] !== "client"));
+
+const armRetry = (): void => {
+ retry ??= setTimeout(() => {
+ retry = null;
+ void syncMuteList();
+ }, RETRY_MS);
+};
+
+/**
+ * One round: read the live list, take it in, and sign what this device owes on
+ * top of it.
+ *
+ * Nothing is signed unless at least one of the reader's own write relays said
+ * it had finished answering. A default relay answering "nothing" while the
+ * reader's own relay is down is exactly the case that would publish an empty
+ * list over the one their other clients keep. Until then the debt stays where
+ * it is, on disk, and the next round tries again.
+ */
+const round = async (me: string): Promise => {
+ const [nostr, relays, publish, outbox] = await Promise.all([
+ import("@openspecs/nostr"),
+ import("./relays"),
+ import("./publish"),
+ import("./outbox"),
+ ]);
+ if (sessionState().pubkey !== me) return;
+
+ const [targets, mine] = await Promise.all([
+ relays.muteListRelays(me).catch(() => nostr.relaySet(nostr.DEFAULT_RELAYS)),
+ relays.outboxRelays(me).then(nostr.relaySet, () => nostr.relaySet(nostr.DEFAULT_RELAYS)),
+ ]);
+ const read = await nostr.fetchMuteList(me, { relays: targets, timeoutMs: READ_TIMEOUT_MS });
+ if (sessionState().pubkey !== me) return;
+
+ if (!read.answered.some((relay) => mine.includes(relay))) {
+ armRetry();
+ return;
+ }
+
+ const live = newer(read.event, lastSigned);
+ applyLive(nostr.parseMuteList(live) ?? NOTHING);
+
+ const owed = blockedState().owed;
+ if (owed.length === 0) return;
+
+ const strip = ({ type, value }: Owed) => ({ type, value });
+ const draft = nostr.editMuteList(live, {
+ add: owed.filter((entry) => entry.op === "add").map(strip),
+ remove: owed.filter((entry) => entry.op === "remove").map(strip),
+ });
+ // Another client already did it, or a removal of something the list never
+ // held: nothing to sign, and the debt is paid.
+ if (withoutClient(draft.tags) === withoutClient(live?.tags ?? [])) {
+ settleOwed(owed);
+ return;
+ }
+
+ let event: NostrEvent;
+ try {
+ event = await publish.signDraft({
+ ...draft,
+ // Strictly after what it replaces, however fast the two were signed.
+ created_at: Math.max(Math.floor(Date.now() / 1000), (live?.created_at ?? 0) + 1),
+ });
+ } catch (reason) {
+ // A key still under its passphrase signs later. A reader who said no in
+ // their extension is not asked again on every page: the blocks stay on
+ // this device, and the settings page offers to publish them.
+ if (reason instanceof SessionLocked || reason instanceof SessionMissing) return;
+ dropOwed();
+ return;
+ }
+ if (sessionState().pubkey !== me) return;
+
+ lastSigned = event;
+ settleOwed(owed);
+ outbox.enqueue(event, targets);
+};
+
+/** One round at a time. A change made mid-round follows it rather than waiting for the retry. */
+export const syncMuteList = (): Promise => {
+ const me = sessionState().pubkey;
+ if (me === null || typeof window === "undefined") return Promise.resolve();
+ if (inFlight !== null) {
+ again = true;
+ return inFlight;
+ }
+ if (retry !== null) {
+ clearTimeout(retry);
+ retry = null;
+ }
+
+ inFlight = round(me)
+ .catch(armRetry)
+ .then(() => {
+ inFlight = null;
+ if (again) {
+ again = false;
+ void syncMuteList();
+ }
+ });
+ return inFlight;
+};
+
+/**
+ * Called once per page load, from the frame every page shares. A round runs
+ * when a key connects, when a locked key opens with something owed, when a
+ * block or an unblock adds to the debt, and when the browser comes back online.
+ */
+export const startMuteSync = (): void => {
+ if (started || typeof window === "undefined") return;
+ started = true;
+
+ let key = sessionState().pubkey;
+ let status = sessionState().status;
+ subscribeSession(() => {
+ const session = sessionState();
+ if (session.pubkey !== key) {
+ key = session.pubkey;
+ lastSigned = null;
+ markUnpublished();
+ if (key !== null) void syncMuteList();
+ } else if (session.status === "ready" && status !== "ready" && blockedState().owed.length > 0) {
+ void syncMuteList();
+ }
+ status = session.status;
+ });
+
+ owedSeen = blockedState().owed.length;
+ subscribeBlocked(() => {
+ const owed = blockedState().owed.length;
+ if (owed > owedSeen) void syncMuteList();
+ owedSeen = owed;
+ });
+
+ window.addEventListener("online", () => void syncMuteList());
+ void syncMuteList();
+};
+
+export const clearMuteSync = (): void => {
+ started = false;
+ inFlight = null;
+ again = false;
+ if (retry !== null) clearTimeout(retry);
+ retry = null;
+ lastSigned = null;
+ owedSeen = 0;
+};
diff --git a/apps/web/app/lib/relays.test.ts b/apps/web/app/lib/relays.test.ts
index 2127b30..2934e4b 100644
--- a/apps/web/app/lib/relays.test.ts
+++ b/apps/web/app/lib/relays.test.ts
@@ -23,6 +23,7 @@ import {
identityRelays,
MAX_WRITE_RELAYS,
MAX_ZAP_RELAYS,
+ muteListRelays,
newKeyRelays,
PUBLIC_RELAYS,
relayListRelays,
@@ -51,6 +52,17 @@ beforeEach(() => {
afterEach(() => vi.restoreAllMocks());
+describe("muteListRelays", () => {
+ it("is my own write relays and then the ones this site reads", async () => {
+ expect(await muteListRelays(ME)).toEqual(["wss://mine.example", ...nostr.DEFAULT_RELAYS]);
+ });
+
+ it("falls back to the relays this site reads for a key with no list", async () => {
+ nostr.writeRelaysOf.mockResolvedValue([]);
+ expect(await muteListRelays(ME)).toEqual(nostr.DEFAULT_RELAYS);
+ });
+});
+
describe("reportRelays", () => {
it("starts with mine and the reported key's own, then reaches everywhere else", async () => {
const relays = await reportRelays(ME, AUTHOR);
diff --git a/apps/web/app/lib/relays.ts b/apps/web/app/lib/relays.ts
index 44188c9..bba59c6 100644
--- a/apps/web/app/lib/relays.ts
+++ b/apps/web/app/lib/relays.ts
@@ -62,6 +62,14 @@ export const outboxRelays = async (me: string): Promise => {
export const documentRelays = async (me: string): Promise =>
relaySet(await outboxRelays(me), DEFAULT_RELAYS).slice(0, MAX_WRITE_RELAYS);
+/**
+ * Where a key's mute list is read from and written back to: the same set both
+ * ways, for the reason `identityRelays` gives. Its own write relays, since that
+ * is where its other clients put the list, then the relays this site reads.
+ */
+export const muteListRelays = async (me: string): Promise =>
+ relaySet(await outboxRelays(me), DEFAULT_RELAYS).slice(0, MAX_WRITE_RELAYS);
+
/**
* Where a key announces itself: its profile and its relay list. The indexers
* first, because those two are read from there and nowhere else, then the
diff --git a/apps/web/app/routes/settings/blocked.tsx b/apps/web/app/routes/settings/blocked.tsx
index d672b7c..109ffef 100644
--- a/apps/web/app/routes/settings/blocked.tsx
+++ b/apps/web/app/routes/settings/blocked.tsx
@@ -1,5 +1,7 @@
+import { useOutletContext } from "react-router";
import { BlockedList } from "~/components/settings/blocked-list";
import { PAGE_HEADERS } from "~/lib/http";
+import type { Own } from "../settings";
import type { Route } from "./+types/blocked";
export function meta(_: Route.MetaArgs) {
@@ -17,5 +19,6 @@ export function headers(_: Route.HeadersArgs) {
* needs neither a key nor an open one to be read.
*/
export default function BlockedSettings() {
- return ;
+ const { me } = useOutletContext();
+ return ;
}
From 1abe782bc929b19c63e3d3d5567ca81f969d2bc2 Mon Sep 17 00:00:00 2001
From: Nogringo
Date: Mon, 31 Aug 2026 02:37:35 +0200
Subject: [PATCH 05/10] fix: draw a blocked key's mark, never its picture
Loading the picture would call on a server the blocked key chose, from the
one page a reader visits to stop seeing them, and the picture may be the
reason they are here. The mark is computed from the key alone.
---
apps/web/app/components/settings/blocked-list.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/web/app/components/settings/blocked-list.tsx b/apps/web/app/components/settings/blocked-list.tsx
index 291d0b8..69fb288 100644
--- a/apps/web/app/components/settings/blocked-list.tsx
+++ b/apps/web/app/components/settings/blocked-list.tsx
@@ -131,7 +131,9 @@ export const BlockedList = ({ me }: { me: string | null }) => {
className="flex min-w-0 items-center gap-3"
title={`Everything signed by ${npub}`}
>
-
+ {/* The mark, never the picture: loading it would call on a
+ server this key chose, and the picture may be the reason. */}
+
Date: Mon, 31 Aug 2026 02:51:54 +0200
Subject: [PATCH 06/10] feat(web): report from a key made on the spot, with or
without one of your own
A reader with no key can now report, and a reader with one can choose not
to sign with it: the event is signed by a key generated for it and thrown
away, so nothing ties it to them. The form says what that is worth, since
most relays weigh such a report at nothing, and the report also goes to the
relay this site runs, where it is read whatever its weight. The row that sent
a keyless reader to connect is gone.
---
apps/web/app/components/moderation/more.tsx | 23 ++++--------
.../app/components/moderation/report-form.tsx | 36 +++++++++++++++----
apps/web/app/lib/publish.test.ts | 27 ++++++++++++--
apps/web/app/lib/publish.ts | 27 ++++++++++++++
apps/web/app/lib/relays.test.ts | 11 ++++++
apps/web/app/lib/relays.ts | 21 ++++++++---
6 files changed, 115 insertions(+), 30 deletions(-)
diff --git a/apps/web/app/components/moderation/more.tsx b/apps/web/app/components/moderation/more.tsx
index 80e9e10..c691d3f 100644
--- a/apps/web/app/components/moderation/more.tsx
+++ b/apps/web/app/components/moderation/more.tsx
@@ -1,8 +1,6 @@
import { useEffect, useState, useSyncExternalStore } from "react";
-import { Link, useLocation } from "react-router";
import { CHROME } from "~/components/chrome";
import { block, unblock, useBlocked } from "~/lib/blocked";
-import { connectPath } from "~/lib/paths";
import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session";
import { ReportForm } from "./report-form";
import { SUGGESTION } from "./styles";
@@ -23,10 +21,10 @@ const ROW = `${SUGGESTION} text-left`;
/**
* Report or block, behind one word, on a document, a comment and an account.
*
- * Blocking works without a key: it is this browser deciding what it shows, and
+ * Neither needs a key. Blocking is this browser deciding what it shows, and
* the page or the comment flips to the notice that carries the undo, so the
- * menu closes on the click and says nothing more. Reporting is a signed event,
- * so without a key it is a door to the sign in rather than a form.
+ * menu closes on the click and says nothing more. A report from a reader with
+ * no key is signed by one made for it; the form says what that is worth.
*
* Nothing is offered on the reader's own words: a report on yourself is a
* mistake and a block on yourself is a bug.
@@ -34,7 +32,6 @@ const ROW = `${SUGGESTION} text-left`;
export const More = ({ target }: { target: MoreTarget }) => {
useEffect(restoreSession, []);
const session = useSyncExternalStore(subscribeSession, sessionState, serverSessionState);
- const location = useLocation();
const blocked = useBlocked();
const [stage, setStage] = useState("closed");
@@ -71,15 +68,9 @@ export const More = ({ target }: { target: MoreTarget }) => {
{stage === "menu" && (
diff --git a/apps/web/app/components/moderation/report-form.tsx b/apps/web/app/components/moderation/report-form.tsx
index 7fe2ebf..63cd7dc 100644
--- a/apps/web/app/components/moderation/report-form.tsx
+++ b/apps/web/app/components/moderation/report-form.tsx
@@ -2,7 +2,7 @@ import { buildReport, REPORT_TYPES, type ReportType } from "@openspecs/nostr";
import { useId, useState } from "react";
import { RelayReport } from "~/components/relay-results";
import { block, useBlocked } from "~/lib/blocked";
-import { type RelayResult, signAndPublish } from "~/lib/publish";
+import { publishAnonymously, type RelayResult, signAndPublish } from "~/lib/publish";
import { reportRelays } from "~/lib/relays";
import type { MoreTarget } from "./more";
import { FIELD, NOTE, SUGGESTION, WRONG } from "./styles";
@@ -27,13 +27,18 @@ const said = (reason: unknown): string =>
* One event, signed and sent wide. The two sentences before the form are the
* whole of what a reader is agreeing to, and they are there because a report
* feels like a private word to a moderator and is nothing of the kind.
+ *
+ * With no key, or when asked, the event is signed by a key made for it and
+ * thrown away. That is the only report a reader who fears an answer can send,
+ * and it is also one most relays weigh at nothing: both are said.
*/
export const ReportForm = ({
me,
target,
onClose,
}: {
- me: string;
+ /** Null when nobody is signed in: the report is then sent from a key made for it. */
+ me: string | null;
target: MoreTarget;
onClose: () => void;
}) => {
@@ -41,12 +46,14 @@ export const ReportForm = ({
const blocked = useBlocked();
const [type, setType] = useState(null);
const [words, setWords] = useState("");
+ const [withoutKey, setWithoutKey] = useState(false);
const [stage, setStage] = useState("asking");
const [relays, setRelays] = useState([]);
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
const noun = target.kind;
+ const nameless = me === null || withoutKey;
const accountBlocked = blocked.pubkeys.has(target.pubkey);
const send = async () => {
@@ -55,7 +62,7 @@ export const ReportForm = ({
setResults([]);
setError(null);
- const targets = reportRelays(me, target.pubkey);
+ const targets = reportRelays(nameless ? null : me, target.pubkey);
targets.then(setRelays).catch(() => {});
const reported =
@@ -64,11 +71,13 @@ export const ReportForm = ({
: target.kind === "comment"
? { pubkey: target.pubkey, id: target.id }
: { pubkey: target.pubkey };
+ const draft = buildReport(reported, type, words);
+ const heard = (result: RelayResult) => setResults((answered) => [...answered, result]);
try {
- const report = await signAndPublish(buildReport(reported, type, words), targets, (result) =>
- setResults((answered) => [...answered, result]),
- );
+ const report = nameless
+ ? await publishAnonymously(draft, targets, heard)
+ : await signAndPublish(draft, targets, heard);
if (report.accepted === 0) {
setStage("failed");
setError("No relay accepted it. Nothing was published.");
@@ -85,7 +94,9 @@ export const ReportForm = ({
return (
<>
- A report is a signed public event. Anyone can read it, and it names you as the reporter.
+ {nameless
+ ? "Sent from a key made for this and thrown away. Nobody can tie it to you, and most relays give such a report little weight."
+ : "A report is a signed public event. Anyone can read it, and it names you as the reporter."}
It goes to every relay this page knows about, so the people who run them see it. This site
@@ -117,6 +128,17 @@ export const ReportForm = ({
className={`${FIELD} font-serif text-[0.9375rem] leading-relaxed`}
/>
+ {me !== null && (
+
+ )}
+