diff --git a/apps/web/app/components/variants.tsx b/apps/web/app/components/copies.tsx similarity index 57% rename from apps/web/app/components/variants.tsx rename to apps/web/app/components/copies.tsx index f571c8c..edcbdf3 100644 --- a/apps/web/app/components/variants.tsx +++ b/apps/web/app/components/copies.tsx @@ -1,14 +1,14 @@ import { useEffect, useSyncExternalStore } from "react"; import { Link } from "react-router"; import { keyTextColor } from "~/lib/color"; +import type { Copy } from "~/lib/copies"; import { diffPath } from "~/lib/paths"; import { authorName } from "~/lib/profile"; import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; -import type { Standing } from "~/lib/spots"; -import type { Variant } from "~/lib/variants"; +import { type Standing, standingKey } from "~/lib/spots"; import { AuthorAvatar } from "./author-avatar"; -export const VARIANTS_ID = "under-this-name"; +export const COPIES_ID = "other-copies"; const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10); @@ -24,16 +24,27 @@ const said = (standing: Standing): string => { }; /** - * 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. + * The other documents that are copies of this one. + * + * One list, not two. Some of these were found publishing at this address and + * some by the tag their author wrote saying they started here, and that + * difference is how they were found rather than anything about them. What + * separates a copy worth reading from a document that only shares a name is the + * standing on its row, which is measured against the text. + * + * No sentence under the heading. The heading names the list, each row carries + * its author, its standing and its address, and a paragraph saying this site + * vouches for none of them would answer a suspicion nobody arrived with. + * + * Drawn dashed like everything on this site that is available rather than + * settled: these exist, and existing is the whole point. */ -export const Variants = ({ - variants, +export const Copies = ({ + copies, standings, from, }: { - variants: Variant[]; + copies: Copy[]; standings: Record; /** The document whose page this section sits on, and a comparison's base side. */ from: { npub: string; identifier: string }; @@ -41,60 +52,57 @@ export const Variants = ({ const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); useEffect(() => { - if (variants.length > 0) wantAuthors(variants.map((variant) => variant.pubkey)); - }, [variants]); + if (copies.length > 0) wantAuthors(copies.map((copy) => copy.pubkey)); + }, [copies]); - if (variants.length === 0) return null; + if (copies.length === 0) return null; return ( -
+

- Under this name + Other copies

-

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

    - {variants.map((variant) => { - const author = authors[variant.pubkey] ?? null; - const standing = standings[variant.pubkey]; + {copies.map((copy) => { + const author = authors[copy.pubkey] ?? null; + const standing = standings[standingKey(copy)]; return (
  • - +
    - {authorName(author, variant.npub)} + {authorName(author, copy.npub)}
    - {variant.title} + {copy.title} - {variant.summary !== "" && ( + {copy.summary !== "" && (

    - {variant.summary} + {copy.summary}

    )}
    @@ -103,6 +111,11 @@ export const Variants = ({ {standing !== undefined && ( {said(standing)} )} + {/* Only where it differs. A copy at this document's own address + would be repeating the line at the top of the page. */} + {!copy.sameName && ( + {copy.identifier} + )}
  • 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 ( - {!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.

    } @@ -366,7 +513,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 diff --git a/apps/web/app/components/moderation/more.tsx b/apps/web/app/components/moderation/more.tsx index c691d3f..bd6da2b 100644 --- a/apps/web/app/components/moderation/more.tsx +++ b/apps/web/app/components/moderation/more.tsx @@ -1,12 +1,26 @@ +import { toNaddr, toNpub } from "@openspecs/nostr"; import { useEffect, useState, useSyncExternalStore } from "react"; import { CHROME } from "~/components/chrome"; +import { CopyButton } from "~/components/copy-button"; +import { Rebroadcast } from "~/components/rebroadcast"; import { block, unblock, useBlocked } from "~/lib/blocked"; +import { eventPath } 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: "document"; + pubkey: string; + id: string; + coordinate: string; + identifier: string; + /** This document's canonical URL, which the address bar already shows. */ + canonical: string; + /** Where a republished copy of the event would be sent. */ + relays: string[]; + } | { kind: "comment"; pubkey: string; id: string } | { kind: "account"; pubkey: string }; @@ -19,15 +33,24 @@ const PANEL = const ROW = `${SUGGESTION} text-left`; /** - * Report or block, behind one word, on a document, a comment and an account. + * Everything else there is to do with a document, a comment or an account, + * behind one word. * - * Neither needs a key. Blocking is this browser deciding what it shows, and - * the page or the comment flips to the notice that carries the undo, so the - * menu closes on the click and says nothing more. A report from a reader with - * no key is signed by one made for it; the form says what that is worth. + * A document's addresses and its signed event live here rather than in the row + * above it. Each of them serves whoever already knows what an naddr is, and a + * reading page that spends seven controls on that before its first paragraph is + * charging every reader for a few. The link is here too, because on a document's + * own page the address bar is already showing it. * - * Nothing is offered on the reader's own words: a report on yourself is a - * mistake and a block on yourself is a bug. + * Nothing here needs a key. Blocking is this browser deciding what it shows, and + * the page or the comment flips to the notice that carries the undo, so the menu + * closes on the click and says nothing more. A report from a reader with no key + * is signed by one made for it; the form says what that is worth. + * + * A report on yourself is a mistake and a block on yourself is a bug, so neither + * is offered on the reader's own words. On their own document the menu still + * opens, holding what is left: an author needs their document's address as much + * as anybody. */ export const More = ({ target }: { target: MoreTarget }) => { useEffect(restoreSession, []); @@ -36,7 +59,9 @@ export const More = ({ target }: { target: MoreTarget }) => { const [stage, setStage] = useState("closed"); const me = session.pubkey; - if (me !== null && me === target.pubkey) return null; + const mine = me !== null && me === target.pubkey; + // A comment or an account of the reader's own leaves nothing to put in it. + if (mine && target.kind !== "document") return null; const noun = target.kind; const accountBlocked = blocked.pubkeys.has(target.pubkey); @@ -59,7 +84,11 @@ export const More = ({ target }: { target: MoreTarget }) => { type="button" onClick={() => setStage(stage === "closed" ? "menu" : "closed")} aria-expanded={stage !== "closed"} - title="Report or block" + title={ + mine + ? "Addresses, and the signed event" + : "Addresses, republishing, reporting and blocking" + } className={CHROME} > More @@ -67,7 +96,44 @@ export const More = ({ target }: { target: MoreTarget }) => { {stage === "menu" && (

    -
    + {target.kind === "document" && ( +
    + + + + fetch(eventPath(toNpub(target.pubkey), target.identifier)).then((event) => + event.text(), + ) + } + label="Copy event" + title="The signed event, exactly as the relays serve it" + className={ROW} + /> + +
    + )} + + {/* Last, and behind a rule: the two that act on somebody rather than on + what this browser is holding. */} + ); -const Contents = ({ - headings, - variantCount, -}: { - headings: MarkdownHeading[]; - variantCount: number; -}) => ( +const Contents = ({ headings, copyCount }: { headings: MarkdownHeading[]; copyCount: number }) => (