From 66fe6adff14f75fe0270ae314ebeae9b1cf035ef Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 14:31:36 +0200 Subject: [PATCH 01/12] feat: build a fork, carrying one tag that says where it came from --- packages/nostr/src/index.ts | 2 + packages/nostr/src/spec.ts | 40 +++++++++++++++++++ packages/nostr/test/spec.test.ts | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts index 03b37c2..436e799 100644 --- a/packages/nostr/src/index.ts +++ b/packages/nostr/src/index.ts @@ -238,6 +238,8 @@ export { buildSpec, buildSpecDeletion, editSpec, + type ForkOrigin, + forkSpec, parseSpec, type Spec, type SpecDraft, diff --git a/packages/nostr/src/spec.ts b/packages/nostr/src/spec.ts index b91a15f..64e00dd 100644 --- a/packages/nostr/src/spec.ts +++ b/packages/nostr/src/spec.ts @@ -1,3 +1,4 @@ +import { toCoordinate } from "./address"; import { allTags, type EventDraft, @@ -270,6 +271,45 @@ export const editSpec = (live: NostrEvent | null, draft: SpecDraft): EventDraft /** The same, for a document nobody has published yet: there is no live revision to read first. */ export const buildSpec = (draft: SpecDraft): EventDraft => editSpec(null, draft); +/** The document a fork came from, as the marker naming it will be written. */ +export type ForkOrigin = { + pubkey: string; + identifier: string; + /** A relay it is known to sit on. The slot is positional, so an empty one is still written. */ + relay?: string | null; +}; + +/** + * A document somebody starts from another key's, carrying one tag that says + * where it came from. + * + * Built from the draft and never from the origin's event. `editSpec(origin, draft)` + * looks like the way to do this and is the trap: it hands back every tag it does + * not own, so the other author's `published_at` and whatever else they wrote + * would travel into a document signed by somebody else. + * + * The marker is written here and never again. Every later revision of the fork + * goes through `editSpec`, which copies an `a` tag through with everything else + * this editor has no opinion about, so a fork renamed a year later still names + * the document it came from rather than the name it used to have. + */ +export const forkSpec = (origin: ForkOrigin, forker: string, draft: SpecDraft): EventDraft => { + const event = buildSpec(draft); + const pubkey = origin.pubkey.trim(); + const identifier = origin.identifier.trim(); + // A marker pointing at the document being built says nothing, and drawn on a + // page it is a link back to the page you are on. + const itself = pubkey === forker && identifier === draft.identifier.trim(); + if (pubkey === "" || identifier === "" || itself) return event; + + const marker = ["a", toCoordinate({ pubkey, identifier }), origin.relay?.trim() ?? "", "fork"]; + // Where `editSpec` puts the foreign tags it carries through, so a fork and its + // own later revisions order their tags the same way. + const alt = event.tags.findIndex((tag) => tag[0] === "alt"); + const at = alt === -1 ? event.tags.length : alt; + return { ...event, tags: [...event.tags.slice(0, at), marker, ...event.tags.slice(at)] }; +}; + /** * The empty revision an author replaces their own document with, and the first * half of withdrawing it. diff --git a/packages/nostr/test/spec.test.ts b/packages/nostr/test/spec.test.ts index 7e3e133..10e26b0 100644 --- a/packages/nostr/test/spec.test.ts +++ b/packages/nostr/test/spec.test.ts @@ -7,6 +7,7 @@ import { buildSpec, buildSpecDeletion, editSpec, + forkSpec, parseSpec, type Spec, type SpecDraft, @@ -472,6 +473,72 @@ describe("buildSpec", () => { }); }); +describe("forkSpec", () => { + const originKey = getPublicKey(generateSecretKey()); + const origin = { pubkey: originKey, identifier: "nip-01" }; + const copy = draftOf({ identifier: "nip-01", title: "NIP-01" }); + + it("writes the marker, empty relay slot included", () => { + expect(named(forkSpec(origin, author, copy), "a")).toEqual([ + ["a", `${SPEC_KIND}:${originKey}:nip-01`, "", "fork"], + ]); + }); + + it("writes a relay hint when it is given one", () => { + const draft = forkSpec({ ...origin, relay: " wss://relay.example " }, author, copy); + expect(named(draft, "a")).toEqual([ + ["a", `${SPEC_KIND}:${originKey}:nip-01`, "wss://relay.example", "fork"], + ]); + }); + + it("says where it came from once it has been through the parser again", () => { + expect(parseSpec(sign(forkSpec(origin, author, copy)))?.forks).toEqual([ + { type: "spec", coordinate: `${SPEC_KIND}:${originKey}:nip-01`, relay: null }, + ]); + }); + + it("carries nothing of the origin's own event", () => { + const live = specEvent([ + ["d", "nip-01"], + ["title", "NIP-01"], + ["published_at", "1600000000"], + ["icon", "https://example.com/i.png"], + ]); + const draft = forkSpec(origin, author, specDraftOf(live)); + expect(named(draft, "published_at")).toEqual([]); + expect(named(draft, "icon")).toEqual([]); + }); + + it("does not point at the document it is building", () => { + expect(named(forkSpec({ pubkey: author, identifier: "nip-01" }, author, copy), "a")).toEqual( + [], + ); + }); + + it("points at the origin again once the fork moves to a name of its own", () => { + const forked = forkSpec({ pubkey: author, identifier: "nip-01" }, author, copy); + expect(named(forked, "a")).toEqual([]); + const renamed = forkSpec( + { pubkey: author, identifier: "nip-01" }, + author, + draftOf({ identifier: "nip-01-mine", title: "NIP-01" }), + ); + expect(named(renamed, "a")).toEqual([["a", `${SPEC_KIND}:${author}:nip-01`, "", "fork"]]); + }); + + it("keeps the marker through a later revision that renames the fork", () => { + const live = sign(forkSpec(origin, author, copy)); + const renamed = editSpec(live, { ...specDraftOf(live), identifier: "nip-01-mine" }); + expect(named(renamed, "a")).toEqual([["a", `${SPEC_KIND}:${originKey}:nip-01`, "", "fork"]]); + expect(named(renamed, "d")).toEqual([["d", "nip-01-mine"]]); + }); + + it("writes the marker where an edit would carry it, before the alt", () => { + const names = forkSpec(origin, author, copy).tags.map((tag) => tag[0]); + expect(names).toEqual(["d", "title", "a", "alt", "client"]); + }); +}); + describe("withdrawSpec", () => { it("writes an address and nothing that was ever the document", () => { expect(withdrawSpec("x")).toEqual({ From 8a0e495422b7a08d711440fdee04da89aa86f395 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 14:43:08 +0200 Subject: [PATCH 02/12] feat: say where a forked document came from --- apps/web/app/lib/spec-page.test.ts | 56 +++++++++++++++++++++++++++--- apps/web/app/lib/spec-page.ts | 55 ++++++++++++++++++++++++++++- apps/web/app/routes/spec.tsx | 23 ++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/apps/web/app/lib/spec-page.test.ts b/apps/web/app/lib/spec-page.test.ts index 9797fc5..d583108 100644 --- a/apps/web/app/lib/spec-page.test.ts +++ b/apps/web/app/lib/spec-page.test.ts @@ -4,16 +4,15 @@ import { toPage } from "./spec-page"; const PUBKEY = "1336a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; -const specOf = (content: string) => { +const ORIGIN = "2447a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; + +const specWithTags = (tags: string[][], content = "") => { const spec = parseSpec({ id: "a".repeat(64), pubkey: PUBKEY, created_at: 1, kind: SPEC_KIND, - tags: [ - ["d", "bud-02"], - ["title", "BUD-02"], - ], + tags: [["d", "bud-02"], ["title", "BUD-02"], ...tags], content, sig: "b".repeat(128), }); @@ -21,6 +20,8 @@ const specOf = (content: string) => { return spec; }; +const specOf = (content: string) => specWithTags([], content); + describe("toPage", () => { const naddr = toNaddr({ pubkey: PUBKEY, identifier: "bud-01" }); const path = `/spec/${toNpub(PUBKEY)}/bud-01`; @@ -39,3 +40,48 @@ describe("toPage", () => { expect(toPage(specOf(`as nostr:${naddr} says`)).html).toContain(`href="${path}"`); }); }); + +describe("toPage and where a document says it came from", () => { + const marker = (coordinate: string) => [["a", coordinate, "", "fork"]]; + + it("names the origin by its address, without asking a relay for it", () => { + expect(toPage(specWithTags(marker(`${SPEC_KIND}:${ORIGIN}:bud-02`))).forkedFrom).toEqual([ + { + type: "spec", + npub: toNpub(ORIGIN), + identifier: "bud-02", + path: `/spec/${toNpub(ORIGIN)}/bud-02`, + }, + ]); + }); + + it("drops a coordinate that addresses nothing", () => { + expect(toPage(specWithTags(marker("30817:not-a-key:bud-02"))).forkedFrom).toEqual([]); + expect(toPage(specWithTags(marker(`${SPEC_KIND}:${ORIGIN}:`))).forkedFrom).toEqual([]); + }); + + it("reads only an `a` tag marked as a fork", () => { + const coordinate = `${SPEC_KIND}:${ORIGIN}:bud-01`; + // An `a` tag is also how another client says `update` or `extends`, and how a + // documentation space names its pages. + expect(toPage(specWithTags([["a", coordinate]])).forkedFrom).toEqual([]); + expect(toPage(specWithTags([["a", coordinate, "", "update"]])).forkedFrom).toEqual([]); + // The marker sits at index 3 on an `a` tag, where index 2 is the relay hint. + expect(toPage(specWithTags([["a", coordinate, "fork"]])).forkedFrom).toEqual([]); + }); + + it("says nothing about a marker naming the document it sits on", () => { + expect(toPage(specWithTags(marker(`${SPEC_KIND}:${PUBKEY}:bud-02`))).forkedFrom).toEqual([]); + }); + + it("names an origin outside Nostr by its host, without the www", () => { + expect( + toPage(specWithTags([["i", "https://www.example.org/nips/47", "fork"]])).forkedFrom, + ).toEqual([{ type: "external", url: "https://www.example.org/nips/47", host: "example.org" }]); + }); + + it("drops an origin outside Nostr that no reader can open", () => { + expect(toPage(specWithTags([["i", "javascript:alert(1)", "fork"]])).forkedFrom).toEqual([]); + expect(toPage(specWithTags([["i", "not a url at all", "fork"]])).forkedFrom).toEqual([]); + }); +}); diff --git a/apps/web/app/lib/spec-page.ts b/apps/web/app/lib/spec-page.ts index da8c23a..c4475a6 100644 --- a/apps/web/app/lib/spec-page.ts +++ b/apps/web/app/lib/spec-page.ts @@ -1,8 +1,58 @@ import type { MarkdownHeading } from "@openspecs/markdown"; import { renderMarkdown } from "@openspecs/markdown"; -import { type Spec, type SpecKindRef, toNaddr, toNpub } from "@openspecs/nostr"; +import { + parseCoordinate, + type Spec, + type SpecKindRef, + specPath, + toNaddr, + toNpub, +} from "@openspecs/nostr"; import { mentionResolver } from "./mention"; +/** One place a document says it was forked from, reduced to what the row drawing it needs. */ +export type ForkSource = + | { type: "spec"; npub: string; identifier: string; path: string } + | { type: "external"; url: string; host: string }; + +/** + * Where a document says it came from, named by its address and never fetched. + * Reading the origin before this page could render would make the origin's + * availability a condition of the fork's page, which is the accelerator rule + * turned inside out. The address is what the tag actually says, and the link + * goes where the title is. + * + * A list, because a document may honestly say it came from two places. In + * practice there is one. + */ +const forkSourcesOf = (spec: Spec): ForkSource[] => { + const sources: ForkSource[] = []; + for (const fork of spec.forks) { + if (fork.type === "external") { + try { + const url = new URL(fork.url); + if (url.protocol !== "http:" && url.protocol !== "https:") continue; + sources.push({ type: "external", url: fork.url, host: url.host.replace(/^www\./, "") }); + } catch { + // A document is not worth less for carrying an address nobody can open. + } + continue; + } + const pointer = parseCoordinate(fork.coordinate); + if (pointer === null) continue; + // A marker naming the document it sits on draws a link back to the page you + // are already reading, so it says nothing worth a row. + if (pointer.pubkey === spec.pubkey && pointer.identifier === spec.identifier) continue; + sources.push({ + type: "spec", + npub: toNpub(pointer.pubkey), + identifier: pointer.identifier, + path: specPath(pointer), + }); + } + return sources; +}; + export type SpecPage = { kind: number; title: string; @@ -18,6 +68,8 @@ export type SpecPage = { kinds: SpecKindRef[]; publishedAt: number; revisedAt: number; + /** Named for where it points: `forks` here would read as the documents that forked this one. */ + forkedFrom: ForkSource[]; isEmpty: boolean; html: string; headings: MarkdownHeading[]; @@ -61,6 +113,7 @@ export const toPage = (spec: Spec): SpecPage => { kinds: spec.kinds, publishedAt: spec.publishedAt, revisedAt: spec.createdAt, + forkedFrom: forkSourcesOf(spec), isEmpty: spec.isEmpty, html, headings, diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index 509fd19..186d696 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -284,6 +284,29 @@ const Masthead = ({ {shorten(spec.npub, 10, 6)} + {spec.forkedFrom.map((source) => ( + + {source.type === "spec" ? ( + <> + + {source.identifier} + {" "} + {shorten(source.npub, 10, 6)} + + ) : ( + + {source.host} + + )} + + ))} {asDate(spec.publishedAt)} {spec.revisedAt > spec.publishedAt && ( {asDate(spec.revisedAt)} From 19cbf07b3c006aa76b50e527413cb8e24bd61efd Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 14:50:04 +0200 Subject: [PATCH 03/12] refactor: name the slot a draft is kept in --- .../web/app/components/editor/spec-editor.tsx | 11 ++- apps/web/app/lib/spec-draft-store.test.ts | 80 ++++++++++++------- apps/web/app/lib/spec-draft-store.ts | 39 +++++---- 3 files changed, 86 insertions(+), 44 deletions(-) diff --git a/apps/web/app/components/editor/spec-editor.tsx b/apps/web/app/components/editor/spec-editor.tsx index 12ee23f..e876eff 100644 --- a/apps/web/app/components/editor/spec-editor.tsx +++ b/apps/web/app/components/editor/spec-editor.tsx @@ -17,7 +17,13 @@ import { rememberSpec } from "~/lib/corpus"; import { specEditPath } from "~/lib/paths"; import { type RelayResult, signAndPublish } from "~/lib/publish"; import { documentRelays } from "~/lib/relays"; -import { clearDraft, readDraft, type StoredDraft, writeDraft } from "~/lib/spec-draft-store"; +import { + clearDraft, + type DraftSlot, + readDraft, + type StoredDraft, + writeDraft, +} from "~/lib/spec-draft-store"; import { ACTION, DocumentField, @@ -128,7 +134,8 @@ export const SpecEditor = ({ // first save turns a new document into an existing one, and the half written // draft it replaces is the one under the address it did not have yet. const slot = useMemo( - () => (initial === null ? null : specDraftOf(initial).identifier), + (): DraftSlot => + initial === null ? { of: "new" } : { of: "doc", identifier: specDraftOf(initial).identifier }, [initial], ); diff --git a/apps/web/app/lib/spec-draft-store.test.ts b/apps/web/app/lib/spec-draft-store.test.ts index 6ff12c0..4668abf 100644 --- a/apps/web/app/lib/spec-draft-store.test.ts +++ b/apps/web/app/lib/spec-draft-store.test.ts @@ -87,69 +87,93 @@ describe("parseStoredDraft", () => { describe("readDraft and writeDraft", () => { it("hands back what was written", () => { const record = stored(); - writeDraft(ME, "a-document", record); - expect(readDraft(ME, "a-document")).toEqual(record); + writeDraft(ME, { of: "doc", identifier: "a-document" }, record); + expect(readDraft(ME, { of: "doc", identifier: "a-document" })).toEqual(record); }); it("keeps the slot for a document apart from the one for a new document", () => { - writeDraft(ME, null, stored({ title: "Unaddressed" })); - writeDraft(ME, "a-document", stored({ title: "Addressed" })); + writeDraft(ME, { of: "new" }, stored({ title: "Unaddressed" })); + writeDraft(ME, { of: "doc", identifier: "a-document" }, stored({ title: "Addressed" })); - expect(readDraft(ME, null)?.draft.title).toBe("Unaddressed"); - expect(readDraft(ME, "a-document")?.draft.title).toBe("Addressed"); + expect(readDraft(ME, { of: "new" })?.draft.title).toBe("Unaddressed"); + expect(readDraft(ME, { of: "doc", identifier: "a-document" })?.draft.title).toBe("Addressed"); }); - /** The one collision two prefixes exist to prevent. */ + it("keeps a fork apart from both, so starting one eats neither", () => { + const origin = `30817:${OTHER}:a-document`; + writeDraft(ME, { of: "new" }, stored({ title: "Unaddressed" })); + writeDraft(ME, { of: "doc", identifier: "a-document" }, stored({ title: "Addressed" })); + writeDraft(ME, { of: "fork", origin }, stored({ title: "Forked" })); + + expect(readDraft(ME, { of: "new" })?.draft.title).toBe("Unaddressed"); + expect(readDraft(ME, { of: "doc", identifier: "a-document" })?.draft.title).toBe("Addressed"); + expect(readDraft(ME, { of: "fork", origin })?.draft.title).toBe("Forked"); + }); + + it("gives every origin a fork slot of its own", () => { + writeDraft(ME, { of: "fork", origin: `30817:${OTHER}:one` }, stored({ title: "One" })); + writeDraft(ME, { of: "fork", origin: `30817:${OTHER}:two` }, stored({ title: "Two" })); + + expect(readDraft(ME, { of: "fork", origin: `30817:${OTHER}:one` })?.draft.title).toBe("One"); + expect(readDraft(ME, { of: "fork", origin: `30817:${OTHER}:two` })?.draft.title).toBe("Two"); + }); + + /** The collision the separate prefixes exist to prevent. */ it("keeps a document identified as `new` apart from a new document", () => { - writeDraft(ME, null, stored({ title: "Unaddressed" })); - writeDraft(ME, "new", stored({ title: "A document called new" })); + writeDraft(ME, { of: "new" }, stored({ title: "Unaddressed" })); + writeDraft(ME, { of: "doc", identifier: "new" }, stored({ title: "A document called new" })); - expect(readDraft(ME, null)?.draft.title).toBe("Unaddressed"); - expect(readDraft(ME, "new")?.draft.title).toBe("A document called new"); + expect(readDraft(ME, { of: "new" })?.draft.title).toBe("Unaddressed"); + expect(readDraft(ME, { of: "doc", identifier: "new" })?.draft.title).toBe( + "A document called new", + ); }); it("keeps one key's draft out of another's", () => { - writeDraft(ME, "a-document", stored({ title: "Mine" })); - expect(readDraft(OTHER, "a-document")).toBeNull(); + writeDraft(ME, { of: "doc", identifier: "a-document" }, stored({ title: "Mine" })); + expect(readDraft(OTHER, { of: "doc", identifier: "a-document" })).toBeNull(); }); it("has nothing to offer where nothing was written", () => { - expect(readDraft(ME, "never-typed")).toBeNull(); + expect(readDraft(ME, { of: "doc", identifier: "never-typed" })).toBeNull(); }); it("leaves a draft too large to store unsaved rather than throwing", () => { const huge = stored({ content: "x".repeat(MAX_DRAFT_BYTES + 1) }); - expect(() => writeDraft(ME, "big", huge)).not.toThrow(); - expect(readDraft(ME, "big")).toBeNull(); + expect(() => writeDraft(ME, { of: "doc", identifier: "big" }, huge)).not.toThrow(); + expect(readDraft(ME, { of: "doc", identifier: "big" })).toBeNull(); }); it("carries on where the browser refuses to store anything", () => { vi.stubGlobal("localStorage", fakeStorage(true)); - expect(() => writeDraft(ME, "a-document", stored())).not.toThrow(); - expect(readDraft(ME, "a-document")).toBeNull(); + expect(() => writeDraft(ME, { of: "doc", identifier: "a-document" }, stored())).not.toThrow(); + expect(readDraft(ME, { of: "doc", identifier: "a-document" })).toBeNull(); }); it("carries on where there is no storage at all", () => { vi.stubGlobal("localStorage", undefined); - expect(() => writeDraft(ME, "a-document", stored())).not.toThrow(); - expect(readDraft(ME, "a-document")).toBeNull(); - expect(() => clearDraft(ME, "a-document")).not.toThrow(); + expect(() => writeDraft(ME, { of: "doc", identifier: "a-document" }, stored())).not.toThrow(); + expect(readDraft(ME, { of: "doc", identifier: "a-document" })).toBeNull(); + expect(() => clearDraft(ME, { of: "doc", identifier: "a-document" })).not.toThrow(); }); it("refuses to store a record it could not read back", () => { - writeDraft(ME, "a-document", { ...stored(), v: 2 } as unknown as StoredDraft); - expect(readDraft(ME, "a-document")).toBeNull(); + writeDraft(ME, { of: "doc", identifier: "a-document" }, { + ...stored(), + v: 2, + } as unknown as StoredDraft); + expect(readDraft(ME, { of: "doc", identifier: "a-document" })).toBeNull(); }); }); describe("clearDraft", () => { it("removes only the slot it was asked for", () => { - writeDraft(ME, null, stored({ title: "Unaddressed" })); - writeDraft(ME, "a-document", stored({ title: "Addressed" })); + writeDraft(ME, { of: "new" }, stored({ title: "Unaddressed" })); + writeDraft(ME, { of: "doc", identifier: "a-document" }, stored({ title: "Addressed" })); - clearDraft(ME, "a-document"); + clearDraft(ME, { of: "doc", identifier: "a-document" }); - expect(readDraft(ME, "a-document")).toBeNull(); - expect(readDraft(ME, null)?.draft.title).toBe("Unaddressed"); + expect(readDraft(ME, { of: "doc", identifier: "a-document" })).toBeNull(); + expect(readDraft(ME, { of: "new" })?.draft.title).toBe("Unaddressed"); }); }); diff --git a/apps/web/app/lib/spec-draft-store.ts b/apps/web/app/lib/spec-draft-store.ts index 0df5957..a961e12 100644 --- a/apps/web/app/lib/spec-draft-store.ts +++ b/apps/web/app/lib/spec-draft-store.ts @@ -2,14 +2,16 @@ import type { SpecDraft, SpecKindEntry } from "@openspecs/nostr"; /** * Where a document waits while it is being written, so a closed tab is not a - * lost afternoon. Two prefixes rather than one, or a document whose identifier - * is literally `new` would share a slot with the one nobody has addressed yet. + * lost afternoon. Three prefixes rather than one, or a document whose identifier + * is literally `new` would share a slot with the one nobody has addressed yet, + * and starting a fork would eat whichever of those two it landed on. * * NIP-37 encrypted drafts replace this. Until then a draft never leaves the * browser it was typed in, which is worth saying on screen rather than hiding. */ const NEW_PREFIX = "openspecs:draft:new:"; const DOC_PREFIX = "openspecs:draft:doc:"; +const FORK_PREFIX = "openspecs:draft:fork:"; export const DRAFT_VERSION = 1; @@ -76,8 +78,21 @@ export const parseStoredDraft = (input: unknown): StoredDraft | null => { }; }; -const slot = (pubkey: string, identifier: string | null): string => - identifier === null ? `${NEW_PREFIX}${pubkey}` : `${DOC_PREFIX}${pubkey}:${identifier}`; +/** + * Which of a key's drafts is meant. A fork waiting to be published is keyed on + * the document it came from, so forking the same origin twice picks the draft + * back up and forking another one does not. + */ +export type DraftSlot = + | { of: "new" } + | { of: "doc"; identifier: string } + | { of: "fork"; origin: string }; + +const slot = (pubkey: string, at: DraftSlot): string => { + if (at.of === "new") return `${NEW_PREFIX}${pubkey}`; + if (at.of === "doc") return `${DOC_PREFIX}${pubkey}:${at.identifier}`; + return `${FORK_PREFIX}${pubkey}:${at.origin}`; +}; /** * Storage is an accelerator, never a dependency: a private window whose @@ -87,30 +102,26 @@ const slot = (pubkey: string, identifier: string | null): string => const local = (): Storage | undefined => typeof localStorage === "undefined" ? undefined : localStorage; -export const readDraft = (pubkey: string, identifier: string | null): StoredDraft | null => { +export const readDraft = (pubkey: string, at: DraftSlot): StoredDraft | null => { try { - const raw = local()?.getItem(slot(pubkey, identifier)); + const raw = local()?.getItem(slot(pubkey, at)); return raw === null || raw === undefined ? null : parseStoredDraft(JSON.parse(raw)); } catch { return null; } }; -export const writeDraft = ( - pubkey: string, - identifier: string | null, - stored: StoredDraft, -): void => { +export const writeDraft = (pubkey: string, at: DraftSlot, stored: StoredDraft): void => { if (parseStoredDraft(stored) === null) return; try { const serialized = JSON.stringify(stored); if (serialized.length > MAX_DRAFT_BYTES) return; - local()?.setItem(slot(pubkey, identifier), serialized); + local()?.setItem(slot(pubkey, at), serialized); } catch {} }; -export const clearDraft = (pubkey: string, identifier: string | null): void => { +export const clearDraft = (pubkey: string, at: DraftSlot): void => { try { - local()?.removeItem(slot(pubkey, identifier)); + local()?.removeItem(slot(pubkey, at)); } catch {} }; From 71ab96e5450bf7adabc39b6eab27200519960335 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 15:02:54 +0200 Subject: [PATCH 04/12] feat: let the editor start a document from another key's --- .../web/app/components/editor/spec-editor.tsx | 112 +++++++++++++++--- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/apps/web/app/components/editor/spec-editor.tsx b/apps/web/app/components/editor/spec-editor.tsx index e876eff..7094cb6 100644 --- a/apps/web/app/components/editor/spec-editor.tsx +++ b/apps/web/app/components/editor/spec-editor.tsx @@ -1,20 +1,28 @@ import { + type EventDraft, editSpec, + type ForkOrigin, fetchSpec, firstHeading, + forkSpec, type NostrEvent, type SpecDraft, type SpecFault, specDraftOf, specFaults, specPath, + tagValue, + toCoordinate, toIdentifier, + toNpub, } from "@openspecs/nostr"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Link } from "react-router"; import { RelayReport } from "~/components/relay-results"; import { rememberSpec } from "~/lib/corpus"; import { specEditPath } from "~/lib/paths"; +import { authorName } from "~/lib/profile"; +import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; import { type RelayResult, signAndPublish } from "~/lib/publish"; import { documentRelays } from "~/lib/relays"; import { @@ -98,6 +106,35 @@ const Held = ({ ); +/** + * Whose document this one is starting from, said once above the fields rather + * than in a warning: forking is not a thing to be talked out of, and the only + * part worth knowing is that theirs is left alone. + */ +const ForkedFrom = ({ origin }: { origin: NostrEvent }) => { + const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); + const npub = toNpub(origin.pubkey); + const identifier = tagValue(origin, "d"); + + useEffect(() => { + wantAuthors([origin.pubkey]); + }, [origin.pubkey]); + + return ( +

+ Starting from{" "} + + {tagValue(origin, "title") || identifier} + {" "} + by {authorName(authors[origin.pubkey] ?? null, npub)}. What you publish is your own document, + signed by your key, carrying a tag that says where it came from. Theirs is untouched. +

+ ); +}; + /** * A document replaces the whole of its previous revision, so what is on screen * has to be what was published: the fields start filled from the live event, and @@ -111,17 +148,41 @@ export const SpecEditor = ({ me, npub, live: initial, + fork = null, }: { me: string; npub: string; /** The revision being edited, or null for a document nobody has published. */ live: NostrEvent | null; + /** The document this one starts from, for a fork nobody has published yet. */ + fork?: { origin: NostrEvent; relay: string | null } | null; }) => { const [live, setLive] = useState(initial); - const published = useMemo(() => specDraftOf(live), [live]); + // Read off `live` and never off the prop: publishing the fork makes it a + // document of its own, and every save after that is an ordinary revision. + const forking = live === null && fork !== null; + const origin = useMemo( + (): ForkOrigin | null => + fork === null + ? null + : { + pubkey: fork.origin.pubkey, + // Read off the event rather than off the route, so the marker names + // what was actually signed. + identifier: tagValue(fork.origin, "d"), + relay: fork.relay, + }, + [fork], + ); + const published = useMemo( + () => (live === null && fork !== null ? specDraftOf(fork.origin) : specDraftOf(live)), + [live, fork], + ); const [draft, setDraft] = useState(published); - // Only until somebody types one of their own. A published address never moves. - const [deriving, setDeriving] = useState(initial === null); + // Only until somebody types one of their own. A published address never moves, + // and neither does a fork's, which keeps the name it was forked from until its + // author decides otherwise. + const [deriving, setDeriving] = useState(initial === null && fork === null); const [state, setState] = useState("editing"); const [relays, setRelays] = useState([]); const [results, setResults] = useState([]); @@ -133,19 +194,26 @@ export const SpecEditor = ({ // The slot a draft is kept in, fixed for as long as this editor is open: a // first save turns a new document into an existing one, and the half written // draft it replaces is the one under the address it did not have yet. - const slot = useMemo( - (): DraftSlot => - initial === null ? { of: "new" } : { of: "doc", identifier: specDraftOf(initial).identifier }, - [initial], - ); + const slot = useMemo((): DraftSlot => { + if (initial !== null) return { of: "doc", identifier: specDraftOf(initial).identifier }; + if (origin !== null) return { of: "fork", origin: toCoordinate(origin) }; + return { of: "new" }; + }, [initial, origin]); + + /** + * The event this form would sign. A fork is built from the draft alone, never + * from the document it came from: see `forkSpec`. Only the first one is, and + * after it every revision is an ordinary edit that carries the marker through. + */ + const build = (fields: SpecDraft): EventDraft => + origin !== null && !existing ? forkSpec(origin, me, fields) : editSpec(live, fields); /** * Whether saving would say anything different, asked of the event rather than * of the form. A blank kind row, a repeated topic and a trailing space all * change the draft and none of them changes the document. */ - const changed = - JSON.stringify(editSpec(live, draft)) !== JSON.stringify(editSpec(live, published)); + const changed = JSON.stringify(build(draft)) !== JSON.stringify(build(published)); const faults = specFaults(draft); const suggested = toIdentifier(draft.title); @@ -219,7 +287,7 @@ export const SpecEditor = ({ const targets = documentRelays(me); targets.then(setRelays).catch(() => {}); - const report = await signAndPublish(editSpec(live, draft), targets, (result) => + const report = await signAndPublish(build(draft), targets, (result) => setResults((answered) => [...answered, result]), ); @@ -253,6 +321,8 @@ export const SpecEditor = ({ */
+ {forking && fork !== null && } + {held !== null && (
+ {/* A fork that changes nothing is still a real thing to publish: a copy + under your own key, which is what a mirror is. Everywhere else an + untouched form has nothing to send, and a blank one is refused by + its faults rather than by this. */} {!changed && state !== "sent" && ( -

{existing ? "Nothing to save yet." : "Nothing written yet."}

+

+ {forking + ? "Unchanged from the document you forked. Publishing it puts a copy of it under your key." + : existing + ? "Nothing to save yet." + : "Nothing written yet."} +

)} {state === "sent" && !changed &&

Published.

} @@ -373,7 +453,9 @@ export const SpecEditor = ({ {taken !== null && (

- A document of yours is already published at that address.{" "} + {forking + ? "A fork keeps the name it was forked from, and this key already publishes a document under that name. " + : "A document of yours is already published at that address. "} Edit that one From 05044341ea1c4ef66c69e1332cd7db27d1616689 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 15:28:49 +0200 Subject: [PATCH 05/12] feat: fork a document from the page that shows it --- apps/web/app/lib/paths.test.ts | 12 ++- apps/web/app/lib/paths.ts | 9 ++ apps/web/app/routes.ts | 1 + apps/web/app/routes/spec-fork.tsx | 158 ++++++++++++++++++++++++++++++ apps/web/app/routes/spec.tsx | 14 ++- 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/routes/spec-fork.tsx diff --git a/apps/web/app/lib/paths.test.ts b/apps/web/app/lib/paths.test.ts index c7a925d..fddb163 100644 --- a/apps/web/app/lib/paths.test.ts +++ b/apps/web/app/lib/paths.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { connectPath, returnTo } from "./paths"; +import { connectPath, returnTo, specForkPath } from "./paths"; + +describe("specForkPath", () => { + it("hangs off the document a fork starts from", () => { + expect(specForkPath("npub1abc", "nip-07")).toBe("/spec/npub1abc/nip-07/fork"); + }); + + it("encodes an address that would otherwise read as a path", () => { + expect(specForkPath("npub1abc", "a b/c")).toBe("/spec/npub1abc/a%20b%2Fc/fork"); + }); +}); describe("connectPath", () => { it("is the bare address when nothing sent anybody there", () => { diff --git a/apps/web/app/lib/paths.ts b/apps/web/app/lib/paths.ts index 3007c03..16528e3 100644 --- a/apps/web/app/lib/paths.ts +++ b/apps/web/app/lib/paths.ts @@ -129,3 +129,12 @@ export const blockedSettingsPath = (): string => "/settings/blocked"; */ export const specEditPath = (npub: string, identifier: string): string => `/spec/${npub}/${encodeURIComponent(identifier)}/edit`; + +/** + * Writing somebody else's document again under your own key. It hangs off the + * document it starts from, like the edit above it and the comparison beside it, + * because that is the thing being acted on: the document it makes has no address + * until its author picks one. + */ +export const specForkPath = (npub: string, identifier: string): string => + `/spec/${npub}/${encodeURIComponent(identifier)}/fork`; diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index feea2c7..97df25a 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -19,6 +19,7 @@ export default [ route("atom.xml", "routes/atom.ts"), route("spec/:author/:identifier", "routes/spec.tsx"), route("spec/:author/:identifier/edit", "routes/spec-edit.tsx"), + route("spec/:author/:identifier/fork", "routes/spec-fork.tsx"), route("spec/:author/:identifier/diff/:other", "routes/spec-diff.tsx"), route("spec/:author/:identifier/event.json", "routes/event.ts"), route("og/:author/:identifier", "routes/og.ts"), diff --git a/apps/web/app/routes/spec-fork.tsx b/apps/web/app/routes/spec-fork.tsx new file mode 100644 index 0000000..a35664a --- /dev/null +++ b/apps/web/app/routes/spec-fork.tsx @@ -0,0 +1,158 @@ +import { fetchSpecEvent, type NostrEvent, parsePubkey, toNpub } from "@openspecs/nostr"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { Link } from "react-router"; +import { SpecEditor } from "~/components/editor/spec-editor"; +import { MakeKey } from "~/components/make-key"; +import { Shell } from "~/components/shell"; +import { SignInDialog } from "~/components/sign-in-dialog"; +import { Unlock } from "~/components/unlock"; +import { PAGE_HEADERS } from "~/lib/http"; +import { specEditPath } from "~/lib/paths"; +import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session"; +import type { Route } from "./+types/spec-fork"; + +type Reading = "reading" | "read" | "missing" | "failed"; + +const NOTE = "font-serif text-[0.9375rem] leading-relaxed text-muted"; + +const WRONG = "font-serif text-[0.9375rem] leading-relaxed text-signal-closed"; + +const ACTION = + "rounded-sm border border-rule px-3 py-1.5 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted hover:border-muted hover:text-ink"; + +const LINK = "underline decoration-rule underline-offset-2 hover:decoration-current"; + +export function meta(_: Route.MetaArgs) { + return [ + { title: "Fork a document | Open Specs" }, + // The document being forked has its own page, and this one holds a form + // nobody but its reader can use. + { name: "robots", content: "noindex, nofollow" }, + ]; +} + +export function headers(_: Route.HeadersArgs) { + return PAGE_HEADERS; +} + +/** + * Writing somebody else's document again, under the reader's own key. + * + * The document is read before the form is offered, the way editing one is, but + * for the opposite reason: nothing here is at risk of being written over, and + * what a fork starts from is the whole of the origin, tags included, so a form + * filled from a revision this page never managed to read would be a fork of + * nothing. + * + * The reading starts as soon as the address resolves, without waiting for a key. + * The origin is public, and somebody who arrives with no key and connects one + * here already has the document in hand when the form appears. Making a key is + * offered on the page rather than in the header for the same reason: whoever + * clicked Fork on a document is often somebody who has never signed anything. + */ +export default function SpecForkRoute({ params }: Route.ComponentProps) { + const session = useSyncExternalStore(subscribeSession, sessionState, serverSessionState); + useEffect(restoreSession, []); + const [making, setMaking] = useState(false); + + const me = session.pubkey; + const locked = session.status === "locked" && session.method === "key"; + const pubkey = parsePubkey(params.author); + const identifier = params.identifier; + + const [reading, setReading] = useState("reading"); + const [event, setEvent] = useState(null); + + // The effect itself, so that trying again is running it again rather than + // nudging a counter it happens to depend on. + const read = useCallback(() => { + if (pubkey === null) return; + + let live = true; + setReading("reading"); + + (async () => { + try { + const found = await fetchSpecEvent({ pubkey, identifier }); + if (!live) return; + setEvent(found); + setReading(found === null ? "missing" : "read"); + } catch { + if (live) setReading("failed"); + } + })(); + + return () => { + live = false; + }; + }, [pubkey, identifier]); + + useEffect(read, [read]); + + return ( + + {/* The editor draws its own title at the size the published page uses, so + the page's own heading steps back to a line naming where you are. */} +

+

+ Forking +

+ + {pubkey === null ? ( +

That is not the address of a document.

+ ) : reading === "reading" ? ( +

Reading the document to start from.

+ ) : reading !== "read" || event === null ? ( +
+

+ {reading === "missing" + ? "Nothing came back at this address. Either no document was published here, or no relay answered just now." + : "Could not reach the relays, so there is nothing to start from yet."} +

+ +
+ ) : me === null || making ? ( + // Making a key signs its reader in halfway through, so the step showing + // them their key has to outlive that: the way in holds the page until + // it says it is done, rather than until a key exists. +
+

Connect a key to publish this document under it.

+ {making ? ( + setMaking(false)} /> + ) : ( + {}} onMake={() => setMaking(true)} /> + )} +
+ ) : locked ? ( +
+ +
+ ) : ( +
+ {/* Said before the form rather than after the refusal: a copy of your + own document under its own name is the document, and the only + other feedback would be the taken address the editor reports. */} + {me === pubkey && ( +

+ This document is already yours. A copy of it under the same name is the same + document, so leave the address alone only if you meant to{" "} + + revise it + {" "} + instead. +

+ )} + +
+ )} +
+ + ); +} diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index 186d696..9c8c59a 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -11,6 +11,7 @@ import { useState } from "react"; import { data, Link, redirect } from "react-router"; import { AnnotatedDoc } from "~/components/annotated-doc"; import { AuthorAvatar } from "~/components/author-avatar"; +import { CHROME } from "~/components/chrome"; import { CopyButton } from "~/components/copy-button"; import { Discussion } from "~/components/discussion/discussion"; import { LikeButton } from "~/components/discussion/like-button"; @@ -28,7 +29,7 @@ import { keyTextColor } from "~/lib/color"; import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http"; import { useLiveRevision } from "~/lib/live-revision"; import { publicOrigin } from "~/lib/origin.server"; -import { DISCUSSION_ID, eventPath, oembedPath, ogImagePath } from "~/lib/paths"; +import { DISCUSSION_ID, eventPath, oembedPath, ogImagePath, specForkPath } from "~/lib/paths"; import type { LinkPreview } from "~/lib/preview"; import { loadLinkPreviews } from "~/lib/preview.server"; import { type Author, shortNpub } from "~/lib/profile"; @@ -346,6 +347,17 @@ const Masthead = ({ identifier: spec.identifier, }} /> + {/* Drawn on the server for everybody, with no session gating: the page it + leads to offers the ways in, and a control appearing a tick after paint + for the already connected would hide this from the readers most likely + to have never signed anything. */} + + Fork +
From 49984ccb95f9d8fb9f61af201d59b41d302097b9 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 15:39:33 +0200 Subject: [PATCH 06/12] fix: cut the fork line down to the one thing the form does not show --- apps/web/app/components/editor/spec-editor.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/app/components/editor/spec-editor.tsx b/apps/web/app/components/editor/spec-editor.tsx index 7094cb6..379b41a 100644 --- a/apps/web/app/components/editor/spec-editor.tsx +++ b/apps/web/app/components/editor/spec-editor.tsx @@ -107,9 +107,10 @@ const Held = ({ ); /** - * Whose document this one is starting from, said once above the fields rather - * than in a warning: forking is not a thing to be talked out of, and the only - * part worth knowing is that theirs is left alone. + * Whose document this one is starting from, and nothing else. The form below is + * already full of somebody else's writing, so the one thing it does not show is + * whose. That a document signed by one key does not change another's needs no + * saying, and the tag recording it is not the reader's business. */ const ForkedFrom = ({ origin }: { origin: NostrEvent }) => { const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); @@ -129,8 +130,7 @@ const ForkedFrom = ({ origin }: { origin: NostrEvent }) => { > {tagValue(origin, "title") || identifier} {" "} - by {authorName(authors[origin.pubkey] ?? null, npub)}. What you publish is your own document, - signed by your key, carrying a tag that says where it came from. Theirs is untouched. + by {authorName(authors[origin.pubkey] ?? null, npub)}.

); }; From 00a13dc315584c211df38f35f3ded83f6166f751 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 15:44:42 +0200 Subject: [PATCH 07/12] fix: move Fork out from behind the overflow menu --- apps/web/app/routes/spec.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index 9c8c59a..1a94157 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -326,6 +326,20 @@ const Masthead = ({
+ {/* At the head with the reaction, because those two are the things done to + the document. What follows takes a copy of it somewhere else. + + Drawn on the server for everybody, with no session gating: the page it + leads to offers the ways in, and a control appearing a tick after paint + for the already connected would hide this from the readers most likely + to have never signed anything. */} + + Fork + - {/* Drawn on the server for everybody, with no session gating: the page it - leads to offers the ways in, and a control appearing a tick after paint - for the already connected would hide this from the readers most likely - to have never signed anything. */} - - Fork -
From 4a21b5db709ea662e4903b6d47db718dcb73f1e0 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 15:57:00 +0200 Subject: [PATCH 08/12] fix: put the author's own controls before the catch-all --- apps/web/app/components/copy-button.tsx | 10 +-- apps/web/app/components/moderation/more.tsx | 88 ++++++++++++++++++--- apps/web/app/components/rebroadcast.tsx | 13 ++- apps/web/app/routes/spec.tsx | 28 +++---- 4 files changed, 102 insertions(+), 37 deletions(-) diff --git a/apps/web/app/components/copy-button.tsx b/apps/web/app/components/copy-button.tsx index 914672d..d6eb644 100644 --- a/apps/web/app/components/copy-button.tsx +++ b/apps/web/app/components/copy-button.tsx @@ -10,11 +10,14 @@ export const CopyButton = ({ value, label, title, + className = "rounded-sm border border-rule px-2 py-1 text-muted hover:border-muted hover:text-ink", }: { /** A string, or a way to go and get one: the event is fetched, not held. */ value: string | (() => Promise); label: string; title?: string; + /** The face it wears, so the same button reads as a row inside a menu. */ + className?: string; }) => { const [state, setState] = useState("idle"); const timer = useRef | null>(null); @@ -35,12 +38,7 @@ export const CopyButton = ({ }; return ( - diff --git a/apps/web/app/components/rebroadcast.tsx b/apps/web/app/components/rebroadcast.tsx index 33ffe6c..54d03ca 100644 --- a/apps/web/app/components/rebroadcast.tsx +++ b/apps/web/app/components/rebroadcast.tsx @@ -9,7 +9,16 @@ type State = "idle" | "sending" | "done" | "failed"; * every relay checks that signature itself. So this asks for no account, keeps * no session, and works the same in a private window. */ -export const Rebroadcast = ({ eventUrl, relays }: { eventUrl: string; relays: string[] }) => { +export const Rebroadcast = ({ + eventUrl, + relays, + className = "rounded-sm border border-rule px-2 py-1 text-muted hover:border-muted hover:text-ink disabled:hover:border-rule disabled:hover:text-muted", +}: { + eventUrl: string; + relays: string[]; + /** The face it wears, so the same button reads as a row inside a menu. */ + className?: string; +}) => { const [state, setState] = useState("idle"); const [results, setResults] = useState([]); @@ -35,7 +44,7 @@ export const Rebroadcast = ({ eventUrl, relays }: { eventUrl: string; relays: st onClick={send} disabled={state === "sending"} title={`Publish this event again to ${relays.length} relays`} - className="rounded-sm border border-rule px-2 py-1 text-muted hover:border-muted hover:text-ink disabled:hover:border-rule disabled:hover:text-muted" + className={className} > {state === "idle" ? "Rebroadcast" : state === "sending" ? "Sending" : "Rebroadcast again"} diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index 1a94157..6a67e48 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -12,7 +12,6 @@ import { data, Link, redirect } from "react-router"; import { AnnotatedDoc } from "~/components/annotated-doc"; import { AuthorAvatar } from "~/components/author-avatar"; import { CHROME } from "~/components/chrome"; -import { CopyButton } from "~/components/copy-button"; import { Discussion } from "~/components/discussion/discussion"; import { LikeButton } from "~/components/discussion/like-button"; import { EditLink } from "~/components/editor/edit-link"; @@ -20,7 +19,6 @@ import { Withdraw } from "~/components/editor/withdraw"; import { ErrorPage } from "~/components/error-page"; import { BlockedNotice } from "~/components/moderation/blocked-notice"; import { More } from "~/components/moderation/more"; -import { Rebroadcast } from "~/components/rebroadcast"; import { Shell } from "~/components/shell"; import { SpecTags } from "~/components/spec-tags"; import { VARIANTS_ID, Variants } from "~/components/variants"; @@ -326,8 +324,8 @@ const Masthead = ({
- {/* At the head with the reaction, because those two are the things done to - the document. What follows takes a copy of it somewhere else. + {/* Beside the reaction, because those two are what a reader does with the + document itself. Everything else it has to offer is one word further on. Drawn on the server for everybody, with no session gating: the page it leads to offers the ways in, and a control appearing a tick after paint @@ -340,18 +338,12 @@ const Masthead = ({ > Fork - - - fetch(eventPath(spec.npub, spec.identifier)).then((event) => event.text())} - label="Copy event" - title="The signed event, exactly as the relays serve it" - /> - + {/* Before the menu, though both arrive a tick after paint and push it + along when they do. A named action sitting after a catch-all reads as + something that fell out of it, and that misreading is on the page for + as long as an author is looking at their own document. */} + + - -
); From 799ae9b4c7a8cbb4aa867e4160b9ee30da66bd3d Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 31 Aug 2026 16:28:57 +0200 Subject: [PATCH 09/12] fix: ask about the address as it is typed, not at Publish --- .../web/app/components/editor/spec-editor.tsx | 62 ++++++++++++++++++- apps/web/app/routes/spec-fork.tsx | 23 ++----- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/apps/web/app/components/editor/spec-editor.tsx b/apps/web/app/components/editor/spec-editor.tsx index 379b41a..bd8b2ed 100644 --- a/apps/web/app/components/editor/spec-editor.tsx +++ b/apps/web/app/components/editor/spec-editor.tsx @@ -52,6 +52,9 @@ type State = "editing" | "sending" | "sent" | "failed"; /** Long enough that a sentence is one write rather than forty. */ const SAVE_AFTER_MS = 800; +/** Long enough that typing an address is one question to the relays, not a dozen. */ +const ASK_AFTER_MS = 600; + const SAYING: Record = { "no-identifier": "A document needs an address to be found under.", "no-title": "A document needs a title. It is what every listing and link preview shows.", @@ -188,6 +191,19 @@ export const SpecEditor = ({ const [results, setResults] = useState([]); const [error, setError] = useState(null); const [taken, setTaken] = useState(null); + /** + * The last address found to already hold a document of the reader's own, asked + * of the relays as it is typed rather than when Publish is pressed. A fork + * arrives wearing the name it came from, so the collision is its ordinary case + * and not a slip, and an address is also the one field somebody changes to get + * out of trouble, which is no help if the trouble only shows at the end. + * + * Never a refusal by itself. The relays may be unreachable, in which case this + * stays quiet and the guard before the signer is what answers. + */ + const [standing, setStanding] = useState(null); + /** One question per address, however many times the editor comes back to it. */ + const answered = useRef(new Map()); const [held, setHeld] = useState(null); const existing = live !== null; @@ -216,6 +232,14 @@ export const SpecEditor = ({ const changed = JSON.stringify(build(draft)) !== JSON.stringify(build(published)); const faults = specFaults(draft); + const wanted = draft.identifier.trim(); + /** + * Known, not guessed: only an address the relays actually answered about. The + * guard before the signer would refuse this publish anyway, so an enabled + * button here promises a round trip that ends where the sentence beside the + * address already says it ends. + */ + const clashes = standing !== null && wanted === standing; const suggested = toIdentifier(draft.title); // What the document is already shown under when it carries no title tag. const heading = draft.title === "" ? firstHeading(draft.content) : null; @@ -231,6 +255,32 @@ export const SpecEditor = ({ ); }, [me, slot, published]); + useEffect(() => { + // A published address never moves, so there is nothing to ask about. + if (existing || wanted === "") return; + + const known = answered.current.get(wanted); + if (known !== undefined) { + setStanding(known ? wanted : null); + return; + } + + let live = true; + const timer = setTimeout(() => { + fetchSpec({ pubkey: me, identifier: wanted }) + .then((found) => { + answered.current.set(wanted, found !== null); + if (live) setStanding(found === null ? null : wanted); + }) + .catch(() => {}); + }, ASK_AFTER_MS); + + return () => { + live = false; + clearTimeout(timer); + }; + }, [existing, me, wanted]); + useEffect(() => { if (!changed) return; const timer = setTimeout(() => { @@ -365,6 +415,16 @@ export const SpecEditor = ({ }} /> + {clashes && ( +

+ You already publish a document at that address.{" "} + + Edit that one + + , or change the address above. +

+ )} + {/* Where the published page puts it: under the title, above the document, at the measure the rest of the prose is read at. */}
@@ -401,7 +461,7 @@ export const SpecEditor = ({ diff --git a/apps/web/app/routes/spec-fork.tsx b/apps/web/app/routes/spec-fork.tsx index a35664a..603149f 100644 --- a/apps/web/app/routes/spec-fork.tsx +++ b/apps/web/app/routes/spec-fork.tsx @@ -1,13 +1,11 @@ import { fetchSpecEvent, type NostrEvent, parsePubkey, toNpub } from "@openspecs/nostr"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; -import { Link } from "react-router"; import { SpecEditor } from "~/components/editor/spec-editor"; import { MakeKey } from "~/components/make-key"; import { Shell } from "~/components/shell"; import { SignInDialog } from "~/components/sign-in-dialog"; import { Unlock } from "~/components/unlock"; import { PAGE_HEADERS } from "~/lib/http"; -import { specEditPath } from "~/lib/paths"; import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session"; import type { Route } from "./+types/spec-fork"; @@ -20,8 +18,6 @@ const WRONG = "font-serif text-[0.9375rem] leading-relaxed text-signal-closed"; const ACTION = "rounded-sm border border-rule px-3 py-1.5 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted hover:border-muted hover:text-ink"; -const LINK = "underline decoration-rule underline-offset-2 hover:decoration-current"; - export function meta(_: Route.MetaArgs) { return [ { title: "Fork a document | Open Specs" }, @@ -130,20 +126,11 @@ export default function SpecForkRoute({ params }: Route.ComponentProps) {
) : ( -
- {/* Said before the form rather than after the refusal: a copy of your - own document under its own name is the document, and the only - other feedback would be the taken address the editor reports. */} - {me === pubkey && ( -

- This document is already yours. A copy of it under the same name is the same - document, so leave the address alone only if you meant to{" "} - - revise it - {" "} - instead. -

- )} + /* Nothing said here about forking your own document. The editor asks the + relays what this key already publishes at that address the moment it + opens, and answers that and a collision with somebody else's name in + the same words, beside the address they are about. */ +
Date: Mon, 31 Aug 2026 16:39:02 +0200 Subject: [PATCH 10/12] feat: list the documents written from this one --- apps/web/app/components/forks.tsx | 103 ++++++++++++++++++++++++++++++ apps/web/app/lib/forks.test.ts | 93 +++++++++++++++++++++++++++ apps/web/app/lib/forks.ts | 76 ++++++++++++++++++++++ apps/web/app/routes/spec.tsx | 18 +++++- packages/nostr/src/relay.ts | 8 +++ 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/components/forks.tsx create mode 100644 apps/web/app/lib/forks.test.ts create mode 100644 apps/web/app/lib/forks.ts diff --git a/apps/web/app/components/forks.tsx b/apps/web/app/components/forks.tsx new file mode 100644 index 0000000..384dc16 --- /dev/null +++ b/apps/web/app/components/forks.tsx @@ -0,0 +1,103 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { Link } from "react-router"; +import { keyTextColor } from "~/lib/color"; +import type { Fork } from "~/lib/forks"; +import { authorName } from "~/lib/profile"; +import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; +import { AuthorAvatar } from "./author-avatar"; +import { VARIANTS_ID } from "./variants"; + +export const FORKS_ID = "written-from-this"; + +const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10); + +/** + * The documents whose authors said they started from this one. + * + * Not the same list as the one under this name, and not a subset of it either. + * That one is every key publishing at this address, a relation nobody declared + * and which a shared name is the whole of; this one is a claim its author wrote + * into their event, and it holds however they renamed what they wrote. A fork + * that kept the name appears in both, and says so, because it is both things. + * + * Dashed like everything on this site that is available rather than settled: + * this is what other keys say about where their writing came from, and nobody + * here vouches for it. + */ +export const Forks = ({ forks }: { forks: Fork[] }) => { + const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); + + useEffect(() => { + if (forks.length > 0) wantAuthors(forks.map((fork) => fork.pubkey)); + }, [forks]); + + if (forks.length === 0) return null; + + return ( +
+

+ Written from this one +

+

+ These documents carry a tag saying they started here. Their authors put it there. +

+
    + {forks.map((fork) => { + const author = authors[fork.pubkey] ?? null; + return ( +
  • + + + +
    +
    + + {authorName(author, fork.npub)} + + +
    + + {fork.title} + + {fork.summary !== "" && ( +

    + {fork.summary} +

    + )} +

    + {fork.sameName ? ( + <> + kept this name, so it is also{" "} + + under this name + + + ) : ( + fork.identifier + )} +

    +
    +
  • + ); + })} +
+
+ ); +}; diff --git a/apps/web/app/lib/forks.test.ts b/apps/web/app/lib/forks.test.ts new file mode 100644 index 0000000..586fa2c --- /dev/null +++ b/apps/web/app/lib/forks.test.ts @@ -0,0 +1,93 @@ +import { parseSpec, SPEC_KIND, type Spec } from "@openspecs/nostr"; +import { describe, expect, it } from "vitest"; +import { selectForks } from "./forks"; + +const ORIGIN = { pubkey: "a".repeat(64), identifier: "nip-01" }; +const COORDINATE = `${SPEC_KIND}:${ORIGIN.pubkey}:${ORIGIN.identifier}`; + +const specOf = (pubkey: string, tags: string[][], content = "written", createdAt = 1): Spec => { + const spec = parseSpec({ + id: `${pubkey.slice(0, 2)}${createdAt}`.padEnd(64, "0"), + pubkey, + created_at: createdAt, + kind: SPEC_KIND, + tags, + content, + sig: "b".repeat(128), + }); + if (spec === null) throw new Error("the fixture does not parse as a document"); + return spec; +}; + +const forkedBy = (pubkey: string, identifier: string, createdAt = 1, marker = "fork") => + specOf( + pubkey, + [ + ["d", identifier], + ["title", identifier], + ["a", COORDINATE, "", marker], + ], + "written", + createdAt, + ); + +describe("selectForks", () => { + it("keeps a document whatever its author renamed it to", () => { + const found = selectForks([forkedBy("b".repeat(64), "nip-01-mine")], ORIGIN); + expect(found.map((fork) => fork.identifier)).toEqual(["nip-01-mine"]); + expect(found[0]?.sameName).toBe(false); + }); + + it("says when a fork kept the name, which puts it under this name too", () => { + expect(selectForks([forkedBy("b".repeat(64), "nip-01")], ORIGIN)[0]?.sameName).toBe(true); + }); + + it("keeps out what only cites the coordinate", () => { + // Relays index the tag's value and not the marker, so the query behind this + // hands back every one of these. + expect(selectForks([forkedBy("b".repeat(64), "x", 1, "update")], ORIGIN)).toEqual([]); + expect( + selectForks( + [ + specOf("b".repeat(64), [ + ["d", "x"], + ["title", "X"], + ["a", COORDINATE], + ]), + ], + ORIGIN, + ), + ).toEqual([]); + }); + + it("keeps out a fork of another document under the same name", () => { + const other = `${SPEC_KIND}:${"c".repeat(64)}:nip-01`; + const spec = specOf("b".repeat(64), [ + ["d", "x"], + ["title", "X"], + ["a", other, "", "fork"], + ]); + expect(selectForks([spec], ORIGIN)).toEqual([]); + }); + + it("keeps out the document itself and anything blank", () => { + const itself = forkedBy(ORIGIN.pubkey, ORIGIN.identifier); + const blank = specOf( + "b".repeat(64), + [ + ["d", "empty"], + ["a", COORDINATE, "", "fork"], + ], + " ", + ); + expect(selectForks([itself, blank], ORIGIN)).toEqual([]); + }); + + it("puts the newest revision first", () => { + const found = selectForks( + [forkedBy("b".repeat(64), "older", 10), forkedBy("c".repeat(64), "newer", 20)], + ORIGIN, + ); + expect(found.map((fork) => fork.identifier)).toEqual(["newer", "older"]); + }); +}); diff --git a/apps/web/app/lib/forks.ts b/apps/web/app/lib/forks.ts new file mode 100644 index 0000000..ee6abfa --- /dev/null +++ b/apps/web/app/lib/forks.ts @@ -0,0 +1,76 @@ +import { fetchSpecs, type Spec, specPath, toCoordinate, toNpub } from "@openspecs/nostr"; +import { useEffect, useState } from "react"; + +/** A document that says it came from this one, reduced to what its row draws. */ +export type Fork = { + pubkey: string; + npub: string; + identifier: string; + title: string; + summary: string; + revisedAt: number; + path: string; + /** True where it kept the name it was forked from, which is what makes it a variant too. */ + sameName: boolean; +}; + +/** + * The documents declaring this one as where they came from. + * + * Relays index a tag's value and not the marker after it, so the query behind + * this returns everything citing the coordinate at all: an `update`, an + * `extends`, a page of a documentation space. The marker is checked here, which + * is the whole reason a filter cannot be trusted to have done it. + * + * A document naming itself is dropped, and so are the blank ones, the way every + * listing drops them. Newest revision first, like everything else on this site. + */ +export const selectForks = (specs: Spec[], of: { pubkey: string; identifier: string }): Fork[] => { + const coordinate = toCoordinate(of); + return specs + .filter( + (spec) => + !spec.isEmpty && + !(spec.pubkey === of.pubkey && spec.identifier === of.identifier) && + spec.forks.some((fork) => fork.type === "spec" && fork.coordinate === coordinate), + ) + .sort((a, b) => b.createdAt - a.createdAt) + .map((spec) => ({ + pubkey: spec.pubkey, + npub: toNpub(spec.pubkey), + identifier: spec.identifier, + title: spec.title, + summary: spec.summary, + revisedAt: spec.createdAt, + path: specPath(spec), + sameName: spec.identifier === of.identifier, + })); +}; + +/** + * Asked by the browser after the page is on screen, like the variants and the + * discussion: who wrote a document from this one is not part of the document, + * and a crawler has no use for it. + */ +export const useForks = (of: { pubkey: string; identifier: string }): Fork[] => { + const [found, setFound] = useState([]); + const { pubkey, identifier } = of; + + useEffect(() => { + let live = true; + setFound([]); + + fetchSpecs({ cites: [toCoordinate({ pubkey, identifier })] }) + .then((specs) => { + if (live) setFound(selectForks(specs, { pubkey, identifier })); + }) + // A page that could not learn who wrote from it still has its document. + .catch(() => {}); + + return () => { + live = false; + }; + }, [pubkey, identifier]); + + return found; +}; diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index 6a67e48..800145f 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -17,6 +17,7 @@ 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 { FORKS_ID, Forks } from "~/components/forks"; import { BlockedNotice } from "~/components/moderation/blocked-notice"; import { More } from "~/components/moderation/more"; import { Shell } from "~/components/shell"; @@ -24,6 +25,7 @@ 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 { useForks } from "~/lib/forks"; import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http"; import { useLiveRevision } from "~/lib/live-revision"; import { publicOrigin } from "~/lib/origin.server"; @@ -169,9 +171,11 @@ const Field = ({ label, children }: { label: string; children: React.ReactNode } const Contents = ({ headings, variantCount, + forkCount, }: { headings: MarkdownHeading[]; variantCount: number; + forkCount: number; }) => (