From cdea0dababbf290c0d0fa8a40229cf785e960dfe Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 23 Aug 2026 19:51:40 +0200 Subject: [PATCH 1/5] feat: show the other keys publishing under a document's name --- apps/web/app/components/variants.tsx | 82 ++++++++++++++++++++++++++++ apps/web/app/lib/variants.test.ts | 51 +++++++++++++++++ apps/web/app/lib/variants.ts | 63 +++++++++++++++++++++ apps/web/app/routes/spec.tsx | 40 +++++++++++++- 4 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/components/variants.tsx create mode 100644 apps/web/app/lib/variants.test.ts create mode 100644 apps/web/app/lib/variants.ts diff --git a/apps/web/app/components/variants.tsx b/apps/web/app/components/variants.tsx new file mode 100644 index 0000000..55ab28c --- /dev/null +++ b/apps/web/app/components/variants.tsx @@ -0,0 +1,82 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { Link } from "react-router"; +import { keyTextColor } from "~/lib/color"; +import { authorName } from "~/lib/profile"; +import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; +import type { Variant } from "~/lib/variants"; +import { AuthorAvatar } from "./author-avatar"; + +export const VARIANTS_ID = "under-this-name"; + +const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10); + +/** + * The other documents published under this page's identifier. Drawn dashed like + * everything on this site that is available rather than settled: nobody vouches + * for these, they exist, and existing is the whole point. + */ +export const Variants = ({ variants }: { variants: Variant[] }) => { + const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); + + useEffect(() => { + if (variants.length > 0) wantAuthors(variants.map((variant) => variant.pubkey)); + }, [variants]); + + if (variants.length === 0) return null; + + return ( +
+

+ Under this name +

+

+ Anyone may publish a document under this name. These are the other keys that have. +

+ +
+ ); +}; diff --git a/apps/web/app/lib/variants.test.ts b/apps/web/app/lib/variants.test.ts new file mode 100644 index 0000000..83541f5 --- /dev/null +++ b/apps/web/app/lib/variants.test.ts @@ -0,0 +1,51 @@ +import { parseSpec, SPEC_KIND, toNpub } from "@openspecs/nostr"; +import { describe, expect, it } from "vitest"; +import { selectVariants } from "./variants"; + +const SHOWN = "1336a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; +const OTHER = "2446a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; +const THIRD = "3556a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; + +const specOf = (pubkey: string, content = "text", createdAt = 1) => { + const spec = parseSpec({ + id: "a".repeat(64), + pubkey, + created_at: createdAt, + kind: SPEC_KIND, + tags: [ + ["d", "nip-07"], + ["title", "NIP-07"], + ], + content, + sig: "b".repeat(128), + }); + if (spec === null) throw new Error("the fixture does not parse as a document"); + return spec; +}; + +describe("selectVariants", () => { + it("drops the key whose document the page already is", () => { + const variants = selectVariants([specOf(SHOWN), specOf(OTHER)], SHOWN); + expect(variants.map((variant) => variant.pubkey)).toEqual([OTHER]); + }); + + it("drops blank records, the way listings do", () => { + expect(selectVariants([specOf(OTHER, " ")], SHOWN)).toEqual([]); + }); + + it("lists the newest revision first", () => { + const variants = selectVariants([specOf(OTHER, "text", 1), specOf(THIRD, "text", 2)], SHOWN); + expect(variants.map((variant) => variant.pubkey)).toEqual([THIRD, OTHER]); + }); + + it("carries what a row draws", () => { + const [variant] = selectVariants([specOf(OTHER, "text", 7)], SHOWN); + expect(variant).toMatchObject({ + pubkey: OTHER, + npub: toNpub(OTHER), + title: "NIP-07", + revisedAt: 7, + path: `/spec/${toNpub(OTHER)}/nip-07`, + }); + }); +}); diff --git a/apps/web/app/lib/variants.ts b/apps/web/app/lib/variants.ts new file mode 100644 index 0000000..530fcde --- /dev/null +++ b/apps/web/app/lib/variants.ts @@ -0,0 +1,63 @@ +import { fetchSpecs, type Spec, specPath, toNpub } from "@openspecs/nostr"; +import { useEffect, useState } from "react"; + +/** Another key's document under the same identifier, reduced to what its row draws. */ +export type Variant = { + pubkey: string; + npub: string; + title: string; + summary: string; + revisedAt: number; + path: string; +}; + +/** + * The other keys publishing under this identifier. The one on the page is + * dropped because its document is the page, and blank records are dropped the + * way listings drop them. Newest revision first, which is the order everything + * else on this site lists in: any other order would be a ranking, and ranking + * the versions of a name is the one call this site refuses to make. + */ +export const selectVariants = (specs: Spec[], shownPubkey: string): Variant[] => + specs + .filter((spec) => spec.pubkey !== shownPubkey && !spec.isEmpty) + .sort((a, b) => b.createdAt - a.createdAt) + .map((spec) => ({ + pubkey: spec.pubkey, + npub: toNpub(spec.pubkey), + title: spec.title, + summary: spec.summary, + revisedAt: spec.createdAt, + path: specPath(spec), + })); + +/** + * Asked by the browser after the page is on screen, like the discussion and for + * the same reason: the server renders the document for crawlers and cachers, and + * who else signs its name is not part of the document. When the indexer backend + * exists, this one query is what it replaces. + */ +export const useVariants = (shown: { pubkey: string; identifier: string }): Variant[] => { + const [variants, setVariants] = useState([]); + const { pubkey, identifier } = shown; + + useEffect(() => { + let live = true; + + (async () => { + try { + const specs = await fetchSpecs({ identifiers: [identifier] }); + if (!live) return; + setVariants(selectVariants(specs, pubkey)); + } catch { + // A page that could not learn who else signs this name still has its document. + } + })(); + + return () => { + live = false; + }; + }, [pubkey, identifier]); + + return variants; +}; diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index 9f44f31..3e50ca4 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -17,6 +17,7 @@ import { ErrorPage } from "~/components/error-page"; import { Rebroadcast } from "~/components/rebroadcast"; import { Shell } from "~/components/shell"; import { SpecTags } from "~/components/spec-tags"; +import { VARIANTS_ID, Variants } from "~/components/variants"; import { keyTextColor } from "~/lib/color"; import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http"; import { useLiveRevision } from "~/lib/live-revision"; @@ -29,6 +30,7 @@ import { loadAuthor } from "~/lib/profile.server"; import { discussionRelays, rebroadcastRelays } from "~/lib/relays.server"; import type { SpecPage } from "~/lib/spec-page"; import { loadSpec } from "~/lib/specs.server"; +import { useVariants } from "~/lib/variants"; import type { Route } from "./+types/spec"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -159,7 +161,13 @@ const Field = ({ label, children }: { label: string; children: React.ReactNode } ); -const Contents = ({ headings }: { headings: MarkdownHeading[] }) => ( +const Contents = ({ + headings, + variantCount, +}: { + headings: MarkdownHeading[]; + variantCount: number; +}) => (