Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/web/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,59 @@ html {
font-size: 0.9375rem;
color: var(--color-muted);
}

/*
* A compared reading. Inline marks carry the words one copy has and the other
* lacks; a whole block only one copy holds is edged rather than filled, since
* a struck-through section is unreadable and the point is to read it.
*/
.doc ins,
.diff-key-ins {
text-decoration: none;
background: color-mix(in srgb, var(--color-signal-settled) 16%, transparent);
border-radius: 3px;
padding: 0.05em 0.15em;
}

.doc del,
.diff-key-del {
background: color-mix(in srgb, var(--color-signal-closed) 13%, transparent);
border-radius: 3px;
padding: 0.05em 0.15em;
text-decoration-thickness: 1px;
}

.diff-key-del {
text-decoration: line-through;
}

.doc .diff-ins {
border-left: 2px solid var(--color-signal-settled);
padding-left: 1.1rem;
}

.doc .diff-del {
border-left: 2px solid var(--color-signal-closed);
padding-left: 1.1rem;
}

/* One edge for a run of blocks: the h2 rule inside would draw a second line. */
.doc .diff-ins h2,
.doc .diff-del h2 {
border-top: 0;
padding-top: 0;
margin-top: 0;
}

/*
* An unfolded margin mark: another key's change, shown below the passage it
* concerns. The first heading inside starts a quotation, not a section, so
* it drops the section rule.
*/
.doc .diff-spot h2:first-child,
.doc .diff-spot :first-child > h2:first-child {
border-top: 0;
padding-top: 0;
margin-top: 0;
}
}
179 changes: 179 additions & 0 deletions apps/web/app/components/annotated-doc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { keyTextColor } from "~/lib/color";
import { type Authors, authorName } from "~/lib/profile";
import { authorsState, serverAuthorsState, subscribeAuthors } from "~/lib/profiles";
import type { Spot } from "~/lib/spots";

/** The document's top level elements, skipping the panels this component put there. */
const blocksIn = (doc: HTMLElement): HTMLElement[] =>
[...doc.children].filter(
(child): child is HTMLElement =>
child instanceof HTMLElement && child.dataset.spot === undefined,
);

const SpotPanel = ({ spot, authors }: { spot: Spot; authors: Authors }) => (
<div className="rounded-sm border border-dashed border-rule p-4 sm:p-5">
{spot.entries.map((entry, index) => (
<div
key={`${entry.pubkey}:${entry.html.length}`}
className={index > 0 ? "mt-5 border-t border-rule pt-5" : ""}
>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 font-mono text-xs">
<span className="font-medium" style={{ color: keyTextColor(entry.pubkey) }}>
{authorName(authors[entry.pubkey] ?? null, entry.npub)}
</span>
<Link
to={entry.diffHref}
className="text-muted underline decoration-rule underline-offset-2 hover:text-ink hover:decoration-current"
>
Whole comparison
</Link>
</div>
<div
className="doc mt-3 text-[0.9375rem]"
// biome-ignore lint/security/noDangerouslySetInnerHtml: rendered Markdown
dangerouslySetInnerHTML={{ __html: entry.html }}
/>
</div>
))}
</div>
);

/**
* The document with the places other keys' copies differ marked in its margin.
* A mark unfolds the change below the passage it concerns, so the proposals
* come to the reader instead of waiting behind a comparison page.
*
* The marks are measured against the rendered elements and the panels are
* inserted among them, because the document itself is one block of HTML the
* server rendered: annotating it must not mean re-rendering it.
*/
export const AnnotatedDoc = ({ html, spots }: { html: string; spots: Spot[] }) => {
const docRef = useRef<HTMLDivElement>(null);
const kept = useRef<Record<number, HTMLElement>>({});
const [open, setOpen] = useState<ReadonlySet<number>>(new Set());
const [tops, setTops] = useState<Record<number, number>>({});
const [slots, setSlots] = useState<Record<number, HTMLElement>>({});
const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState);

/**
* One object per document, not one per render: React re-applies
* `dangerouslySetInnerHTML` whenever the wrapper object is new, and setting
* `innerHTML` again destroys the panels this component planted in the markup.
*/
const markup = useMemo(() => ({ __html: html }), [html]);

useEffect(() => {
const doc = docRef.current;
if (doc === null) return;
const blocks = blocksIn(doc);
const next: Record<number, HTMLElement> = {};
for (const spot of spots) {
if (!open.has(spot.element)) continue;
const existing = kept.current[spot.element];
if (existing?.isConnected) {
next[spot.element] = existing;
continue;
}
const slot = document.createElement("div");
slot.dataset.spot = "";
slot.className = "diff-spot";
if (spot.element < 0) {
doc.insertBefore(slot, doc.firstChild);
} else {
const target = blocks[spot.element];
if (target === undefined) continue;
target.insertAdjacentElement("afterend", slot);
}
next[spot.element] = slot;
}
for (const [key, slot] of Object.entries(kept.current)) {
if (next[Number(key)] === undefined) slot.remove();
}
kept.current = next;
setSlots(next);
}, [open, spots]);

useEffect(
() => () => {
for (const slot of Object.values(kept.current)) slot.remove();
},
[],
);

// Positions follow the text: images load, panels open, the window narrows.
useLayoutEffect(() => {
const doc = docRef.current;
if (doc === null) return;
const measure = () => {
const blocks = blocksIn(doc);
const next: Record<number, number> = {};
for (const spot of spots) {
if (spot.element < 0) {
next[spot.element] = 0;
continue;
}
const block = blocks[spot.element];
if (block !== undefined) next[spot.element] = block.offsetTop;
}
setTops(next);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(doc);
return () => observer.disconnect();
}, [spots]);

const toggle = (element: number) =>
setOpen((current) => {
const next = new Set(current);
if (next.has(element)) {
next.delete(element);
} else {
next.add(element);
}
return next;
});

return (
<div className="relative">
<div
ref={docRef}
className="doc"
// biome-ignore lint/security/noDangerouslySetInnerHtml: rendered Markdown
dangerouslySetInnerHTML={markup}
/>
{spots.map((spot) => {
const top = tops[spot.element];
if (top === undefined) return null;
const keys = new Set(spot.entries.map((entry) => entry.pubkey)).size;
const label = `${keys === 1 ? "1 key changes" : `${keys} keys change`} this passage`;
return (
<button
key={spot.element}
type="button"
onClick={() => toggle(spot.element)}
aria-expanded={open.has(spot.element)}
aria-label={label}
title={label}
style={{ top }}
className="absolute left-full ml-1.5 rounded-sm border border-dashed border-rule px-1.5 py-0.5 font-mono text-[0.6875rem] leading-none text-muted hover:border-muted hover:text-ink aria-expanded:border-muted aria-expanded:text-ink lg:ml-5"
>
±{keys}
</button>
);
})}
{spots.map((spot) => {
const slot = slots[spot.element];
if (slot === undefined) return null;
return createPortal(
<SpotPanel spot={spot} authors={authors} />,
slot,
String(spot.element),
);
})}
</div>
);
};
114 changes: 114 additions & 0 deletions apps/web/app/components/variants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useEffect, useSyncExternalStore } from "react";
import { Link } from "react-router";
import { keyTextColor } from "~/lib/color";
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 { AuthorAvatar } from "./author-avatar";

export const VARIANTS_ID = "under-this-name";

const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10);

/**
* What their copy is, against the one on this page, in the fewest words that
* stay honest: how many passages it changes, that it reads the same, or that
* it only shares the name.
*/
const said = (standing: Standing): string => {
if (standing.kind === "same") return "reads the same";
if (standing.kind === "independent") return "its own writing";
return `changes ${standing.places} ${standing.places === 1 ? "passage" : "passages"}`;
};

/**
* 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,
standings,
from,
}: {
variants: Variant[];
standings: Record<string, Standing>;
/** The document whose page this section sits on, and a comparison's base side. */
from: { npub: string; identifier: string };
}) => {
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 (
<section id={VARIANTS_ID} className="mt-16 border-t border-rule pt-8">
<h2 className="font-mono text-[0.6875rem] uppercase tracking-[0.18em] text-muted">
Under this name
</h2>
<p className="mt-3 font-serif text-[0.9375rem] leading-relaxed text-muted">
Anyone may publish a document under this name. These are the other keys that have.
</p>
<ul className="mt-5 space-y-3">
{variants.map((variant) => {
const author = authors[variant.pubkey] ?? null;
const standing = standings[variant.pubkey];
return (
<li
key={variant.pubkey}
className="flex gap-4 rounded-sm border border-dashed border-rule p-4 hover:border-muted"
>
<span className="mt-0.5 shrink-0">
<AuthorAvatar pubkey={variant.pubkey} picture={author?.picture ?? null} size={28} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-4">
<span
className="truncate font-mono text-sm font-medium"
style={{ color: keyTextColor(variant.pubkey) }}
>
{authorName(author, variant.npub)}
</span>
<time
dateTime={new Date(variant.revisedAt * 1000).toISOString()}
className="shrink-0 font-mono text-xs text-muted"
>
{asDate(variant.revisedAt)}
</time>
</div>
<Link
to={variant.path}
className="mt-1.5 block font-mono text-base font-medium hover:underline hover:decoration-1 hover:underline-offset-4"
>
{variant.title}
</Link>
{variant.summary !== "" && (
<p className="mt-1.5 line-clamp-2 max-w-[38rem] font-serif text-sm leading-snug text-muted">
{variant.summary}
</p>
)}
<div className="mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<Link
to={diffPath(from.npub, from.identifier, variant.npub)}
title="This document, marked with what their copy changes"
className="inline-block rounded-sm border border-rule px-2 py-1 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted hover:border-muted hover:text-ink"
>
Compare
</Link>
{standing !== undefined && (
<span className="font-mono text-xs text-muted">{said(standing)}</span>
)}
</div>
</div>
</li>
);
})}
</ul>
</section>
);
};
17 changes: 17 additions & 0 deletions apps/web/app/lib/diff.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { diffMarkdown, KINSHIP_FLOOR, type MarkdownDiff } from "@openspecs/markdown";
import { createLoadCache } from "./cache.server";
import { mentionResolver } from "./mention";
import type { CachedSpec } from "./specs.server";

export { KINSHIP_FLOOR };

/**
* Keyed on the two revisions, so a diff is computed once per pair however many
* readers open it, and a republished document makes a new key by itself.
*/
const diffs = createLoadCache<MarkdownDiff>({ max: 100, ttlMs: 60 * 60 * 1000 });

export const loadDiff = (base: CachedSpec, other: CachedSpec): Promise<MarkdownDiff> =>
diffs.get(`${base.page.eventId}:${other.page.eventId}`, async () =>
diffMarkdown(base.event.content, other.event.content, { mention: mentionResolver({}) }),
);
4 changes: 4 additions & 0 deletions apps/web/app/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export const oembedPath = (canonical: string): string =>
export const eventPath = (npub: string, identifier: string): string =>
`/spec/${npub}/${encodeURIComponent(identifier)}/event.json`;

/** What another key's document under the same name changes against this one. */
export const diffPath = (npub: string, identifier: string, otherNpub: string): string =>
`/spec/${npub}/${encodeURIComponent(identifier)}/diff/${otherNpub}`;

/** A document nobody has written yet, which belongs to whichever key is connected. */
export const newSpecPath = (): string => "/new";

Expand Down
Loading
Loading