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 ( +
+
+
+
+ ); + } + 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; +}) => ( +
+

+ {title} + {count} +

+
    {children}
+
+); + +const Row = ({ target, children }: { target: MuteTarget; children: React.ReactNode }) => ( +
  • + {children} + +
  • +); + +/** + * Every row leads back to what it hides, except a comment's: the record holds + * its id and not the document it hangs from, and a relay round trip to draw a + * row whose only control is Unblock is not worth the row. + */ +export const BlockedList = () => { + const blocked = useBlocked(); + const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); + + const pubkeys = useMemo(() => [...blocked.pubkeys], [blocked]); + const documents = useMemo( + () => + [...blocked.coordinates].flatMap((coordinate) => { + const pointer = parseCoordinate(coordinate); + return pointer === null ? [] : [{ coordinate, ...pointer }]; + }), + [blocked], + ); + const comments = useMemo(() => [...blocked.eventIds], [blocked]); + + useEffect(() => { + if (pubkeys.length > 0) wantAuthors(pubkeys); + }, [pubkeys]); + + const empty = pubkeys.length + documents.length + comments.length === 0; + + return ( +
    +
    +

    + What you block is hidden from you on this site. Nobody is told, and nothing is hidden from + anyone else. +

    +

    Kept on this device.

    +
    + + {empty &&

    Nothing blocked.

    } + + {pubkeys.length > 0 && ( + + {pubkeys.map((pubkey) => { + const npub = toNpub(pubkey); + const author = authors[pubkey] ?? null; + return ( + + + + + {authorName(author, npub)} + + + + ); + })} + + )} + + {documents.length > 0 && ( +
    + + {documents.map((document) => ( + + + {document.identifier} + {shortNpub(toNpub(document.pubkey))} + + + ))} + +

    + A document in a mute list is this site's addition. Other clients keep the entry and + ignore it. +

    +
    + )} + + {comments.length > 0 && ( + + {comments.map((id) => ( + + {shorten(id)} + + ))} + + )} +
    + ); +}; 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 ( +
    + + + {stage === "menu" && ( +
    +
    + {me === null ? ( + + Connect a key to report + + ) : ( + + )} + {thing !== null && ( + + )} + +
    +
    + )} + + {stage === "report" && me !== null && ( +
    + setStage("closed")} /> +
    + )} +
    + ); +}; 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. +

    + +
    + What is wrong with this {noun} + {REPORT_TYPES.map((option) => ( + + ))} +
    + +