From 68332eb3976149468374ffa607591c608311a4e4 Mon Sep 17 00:00:00 2001 From: s Date: Thu, 17 Sep 2026 01:33:45 +0200 Subject: [PATCH 1/2] CON-11: a revision can take its page out of the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving a page is not a state you set on it — Nostr events are immutable, so there is nothing to set. It is another revision on top of the current head, carrying an `archived` tag, with the content and the placement travelling along unchanged. Restoring publishes the same revision without the tag. `Page.archived` is read off the head, so the tree, the search and the breadcrumbs all honour it without being told, and the history stays complete on both sides of the move: an archived page can be read, compared and blamed exactly as before. The placement (`31818`) is deliberately left alone. It has no effect while a page is out of the tree, and deleting it would make a restore land the page wherever its title sorts to rather than where it was. Subpages do not go with the parent; they move up to the top level. That is the one thing people assume the other way round, so the confirmation says it, counted. Confirming happens in the app, not in the browser. `ConfirmDialog` wraps a native `` — the top layer, the backdrop, the focus trap and Escape come from the platform — and the confirming button says the act rather than `OK`, with Cancel holding the focus. jsdom ships `HTMLDialogElement` without implementing any of its behaviour, hence the shim in `src/test`. --- NOSTR.md | 1 + docs/05-versioning-history.md | 45 +++++- src/domain/blame.test.ts | 1 + src/domain/pages.test.ts | 103 ++++++++++++ src/domain/pages.ts | 47 +++++- src/domain/placement.test.ts | 1 + src/domain/revision.test.ts | 20 +++ src/domain/revision.ts | 9 ++ src/domain/search.test.ts | 20 +++ src/domain/search.ts | 5 + src/nostr/kinds.ts | 18 +++ src/nostr/publish-page.archive.test.ts | 97 +++++++++++ src/nostr/publish-page.ts | 16 +- src/routes/HistoryView.test.tsx | 214 +++++++++++++++++++++++++ src/routes/HistoryView.tsx | 184 +++++++++++++++++++-- src/routes/PageView.crumbs.test.tsx | 154 ++++++++++++++++++ src/routes/PageView.tsx | 29 +++- src/routes/SearchView.tsx | 10 +- src/test/dialog-shim.ts | 30 ++++ src/ui/ConfirmDialog.tsx | 113 +++++++++++++ src/ui/PageEditor.test.tsx | 12 +- src/ui/archive-page.test.ts | 68 ++++++++ src/ui/archive-page.ts | 114 +++++++++++++ 23 files changed, 1293 insertions(+), 18 deletions(-) create mode 100644 src/nostr/publish-page.archive.test.ts create mode 100644 src/routes/HistoryView.test.tsx create mode 100644 src/routes/PageView.crumbs.test.tsx create mode 100644 src/test/dialog-shim.ts create mode 100644 src/ui/ConfirmDialog.tsx create mode 100644 src/ui/archive-page.test.ts create mode 100644 src/ui/archive-page.ts diff --git a/NOSTR.md b/NOSTR.md index 9c9fb85..7e5f7b4 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -127,6 +127,7 @@ backlog, see [docs/10](docs/10-roadmap.md). | `summary` | 1818 | Change note, the equivalent of a commit message | | `content-hash` | 1818 | sha256 of the content | | `restore-of` | 1818 | A restore points at the revision it copied | +| `archived` | 1818 | The revision archives its page: it leaves the tree, the search and the overview, while the history stays and the URL keeps working. **Presence is the signal, the value is unread** (written as `1`; a bare one-element tag is legal NIP-01 but an edge every relay handles a bit differently). A later revision without the tag brings the page back. Not a standard — our own kind, our own tag | | `m` | 1818 | Always `text/markdown` | | `alt` | 1818, 1111, 31818 | NIP-31 fallback for foreign clients | | `K` / `k` / `e` | 1111 | NIP-22: kind of the root object, kind of the direct parent, parent comment | diff --git a/docs/05-versioning-history.md b/docs/05-versioning-history.md index c88a561..9999408 100644 --- a/docs/05-versioning-history.md +++ b/docs/05-versioning-history.md @@ -88,8 +88,49 @@ Features: prompt in the UI. - **Open:** a user deleting their *own* revision via NIP-09 `kind 5` — a *request* to relays, to be phrased in the UI as "request deletion". -- **Open:** hiding a whole page — a new revision with a tombstone tag plus - `9005` on the predecessors, so that sidebar and search leave it out. +- **Implemented:** archiving a whole page, from the foot of its history. A new + revision carries the `archived` tag and the page leaves the tree, the search + and the space overview. It is a step in the chain, not a deletion — nothing + is removed, and publishing a later revision without the tag brings the page + back, so "restore" needs no mechanism of its own. + + The tag is named for what it does. A *tombstone*, in the distributed-systems + sense the word comes from, marks a deletion — and nothing is deleted here. + *Hidden* was the other candidate and promises secrecy the feature does not + deliver: an archived page still answers its own URL. `archived` is also the + word the wikis people arrive from use for exactly this, and the only one of + the three not already spoken for elsewhere in this codebase. + + The plan here used to add `9005` on the predecessors. That is dropped: it + would destroy the history of a page somebody may want back, to hide a page + that the archived tag already takes out of the navigation. Consequences worth + knowing, because each is easy to assume the other way round: + + - **The page still answers its own URL.** It is out of the *navigation*, so + nobody comes across it — but the link keeps working for everyone who has + it, and `PageView` says so on the page rather than letting a reader assume + otherwise. Archiving is not access control; a private space is + ([09](09-security-privacy.md)). + - **The head decides.** `Page.archived` is read off the head revision, so on + a fork the newer leaf wins — the same rule that already decides which text + is shown, rather than a second one nobody could predict. + - **Subpages stay.** They come up to the top level by the rule `buildTree` + already applies to a missing parent. Archiving a page is a statement about + that page; taking a branch off screen would remove pages nobody asked to + remove. + - **The placement (`31818`) is left alone.** It has no effect while the page + is archived, and deleting it would make a restore land the page + wherever its title sorts instead of where it was. The genuinely orphaned + case — a placement whose slug has no revisions at all — is a different + problem ([02](02-data-model-events.md)). + - **The archive is a place.** `/s/:group/archive` lists what was archived, + newest first, with the way back on every row — linked from under the page + tree and from the space overview ([06](06-ui-information-architecture.md)). + Without it, taking a page out of the tree, the search and the overview + leaves its own URL as the only route back to it, which is exactly what + somebody who archived a page by mistake no longer has. Ordered by when + each page left rather than by title: an archive is read as a log of what + was taken out, not as a second page index. ## Relationship to ngit / NIP-34 diff --git a/src/domain/blame.test.ts b/src/domain/blame.test.ts index e56b6bd..82b0fd1 100644 --- a/src/domain/blame.test.ts +++ b/src/domain/blame.test.ts @@ -15,6 +15,7 @@ function rev(id: string, content: string, parents: string[] = [], author = 'alic parentRevs: parents, summary: null, content, + archived: false, } } diff --git a/src/domain/pages.test.ts b/src/domain/pages.test.ts index cec0ae0..545902b 100644 --- a/src/domain/pages.test.ts +++ b/src/domain/pages.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + archivedPages, buildPages, buildTree, canMoveUnder, @@ -22,6 +23,7 @@ function rev(partial: Partial & { id: string }): Revision { parentRevs: [], summary: null, content: '', + archived: false, ...partial, } } @@ -239,3 +241,104 @@ describe('sibling order', () => { expect(second.map((page) => page.slug)).toEqual(first.map((page) => page.slug)) }) }) + +describe('an archived page', () => { + const archived = (slug: string, parent: string | null = null) => + rev({ id: `${slug}-2`, slug, title: slug, parentSlug: parent, archived: true, createdAt: 2000, parentRevs: [`${slug}-1`] }) + const visible = (slug: string, parent: string | null = null) => + rev({ id: `${slug}-1`, slug, title: slug, parentSlug: parent }) + + it('is archived by its head, so a later revision brings it back', () => { + const gone = buildPages([visible('notes'), archived('notes')]) + expect(gone[0].archived).toBe(true) + + const back = buildPages([ + visible('notes'), + archived('notes'), + rev({ id: 'notes-3', slug: 'notes', title: 'notes', createdAt: 3000, parentRevs: ['notes-2'] }), + ]) + expect(back[0].archived).toBe(false) + // nothing was thrown away: the archiving revision is still part of the history + expect(back[0].revisions).toHaveLength(3) + }) + + it('stays in `pages` — its history and its own URL still have to find it', () => { + const pages = buildPages([visible('notes'), archived('notes')]) + expect(pages.map((page) => page.slug)).toEqual(['notes']) + }) + + it('is left out of the tree', () => { + const pages = buildPages([visible('a'), visible('notes'), archived('notes')]) + expect(flattenTree(buildTree(pages)).map((node) => node.slug)).toEqual(['a']) + }) + + it('does not take its subpages with it — they come up to the top level', () => { + // Hiding a page is a statement about that page. A subpage somebody else + // wrote is not covered by it, and taking the branch off screen would + // remove pages nobody asked to remove. + const pages = buildPages([ + visible('handbook'), + archived('handbook'), + visible('onboarding', 'handbook'), + ]) + const tree = buildTree(pages) + expect(tree.map((node) => node.slug)).toEqual(['onboarding']) + expect(tree[0].depth).toBe(0) + }) + + it('follows the newer leaf on a fork, the same revision the content follows', () => { + const base = rev({ id: 'r1', slug: 'notes', title: 'notes' }) + const keep = rev({ id: 'keep', slug: 'notes', title: 'notes', createdAt: 2000, parentRevs: ['r1'] }) + const drop = rev({ id: 'drop', slug: 'notes', title: 'notes', createdAt: 3000, parentRevs: ['r1'], archived: true }) + const pages = buildPages([base, keep, drop]) + expect(pages[0].leaves).toHaveLength(2) + expect(pages[0].head.id).toBe('drop') + expect(pages[0].archived).toBe(true) + }) +}) + + +/** + * The archive listing. An archived page is out of the tree, the search and the + * overview, which leaves its own URL as the only way back to it — so this list + * is the only way back for anyone who does not still have that link. + * src/routes/ArchiveView.tsx + */ +describe('archivedPages', () => { + const archivedAt = (slug: string, at: number) => + rev({ id: `${slug}-2`, slug, title: slug, archived: true, createdAt: at, parentRevs: [`${slug}-1`] }) + const visible = (slug: string) => rev({ id: `${slug}-1`, slug, title: slug }) + + it('lists only the archived pages', () => { + const pages = buildPages([visible('notes'), visible('deploy'), archivedAt('deploy', 2000)]) + expect(archivedPages(pages).map((page) => page.slug)).toEqual(['deploy']) + }) + + it('puts the most recently archived first, not the alphabetically first', () => { + // The page somebody archived a minute ago by mistake is the one they come + // here for; a second index sorted by title would bury it. + const pages = buildPages([ + visible('alpha'), + archivedAt('alpha', 2000), + visible('omega'), + archivedAt('omega', 3000), + ]) + expect(archivedPages(pages).map((page) => page.slug)).toEqual(['omega', 'alpha']) + }) + + it('orders two pages archived in the same second by slug, so every client agrees', () => { + const pages = buildPages([ + visible('beta'), + archivedAt('beta', 2000), + visible('alpha'), + archivedAt('alpha', 2000), + ]) + expect(archivedPages(pages).map((page) => page.slug)).toEqual(['alpha', 'beta']) + }) + + it('drops a page again once a later revision brings it back', () => { + const back = rev({ id: 'deploy-3', slug: 'deploy', title: 'deploy', createdAt: 3000, parentRevs: ['deploy-2'] }) + const pages = buildPages([visible('deploy'), archivedAt('deploy', 2000), back]) + expect(archivedPages(pages)).toEqual([]) + }) +}) diff --git a/src/domain/pages.ts b/src/domain/pages.ts index 26a1a28..ed8c9dc 100644 --- a/src/domain/pages.ts +++ b/src/domain/pages.ts @@ -18,6 +18,17 @@ export type Page = { revisions: Revision[] /** leaves of the chain. More than one = a fork */ leaves: Revision[] + /** + * The page was archived by a revision carrying the archived tag: it leaves + * the tree, the search and the navigation, and only its history and its own + * URL still reach it. + * + * Read off the **head**, so it follows the same revision the content does. + * On a fork where one leaf archives the page and the other does not, the + * newer leaf decides — the same rule that decides which text is shown, + * rather than a second one nobody could predict. + */ + archived: boolean } function sortNewestFirst(a: Revision, b: Revision): number { @@ -69,6 +80,7 @@ export function buildPages( head, revisions: sorted, leaves, + archived: head.archived, }) } @@ -96,10 +108,19 @@ export type PageNode = Page & { children: PageNode[]; depth: number } * The page tree for the sidebar. Pages whose parent does not (or no longer) * exist hang at the top level — hiding them would be worse than filing them in * the wrong place. + * + * An archived page is left out, and by that same rule its children come up to + * the top level rather than disappearing with it. Archiving a page is a + * statement about that page; a subpage somebody else wrote is not covered by + * it, and taking a branch off screen because its root was archived would + * remove pages nobody asked to remove. */ export function buildTree(pages: Page[]): PageNode[] { const nodes = new Map() - for (const page of pages) nodes.set(page.slug, { ...page, children: [], depth: 0 }) + for (const page of pages) { + if (page.archived) continue + nodes.set(page.slug, { ...page, children: [], depth: 0 }) + } const roots: PageNode[] = [] for (const node of nodes.values()) { @@ -184,6 +205,30 @@ export function descendantSlugs(pages: Page[], slug: string): Set { return found } +/** The direct children of `slug`, whatever their own visibility. */ +export function childSlugs(pages: Page[], slug: string): string[] { + return pages.filter((page) => page.parentSlug === slug).map((page) => page.slug) +} + +/** + * The archived pages, most recently archived first. + * + * Ordered by the head's timestamp and not by title, because an archive is read + * as a log of what was taken out rather than as a second page index — the page + * somebody archived by mistake a minute ago is the one they come here for. The + * head *is* the archiving revision: archiving publishes one on top of the + * chain, so its timestamp is when the page left the tree. + * src/routes/ArchiveView.tsx + */ +export function archivedPages(pages: Page[]): Page[] { + return pages + .filter((page) => page.archived) + .sort((a, b) => { + if (b.head.createdAt !== a.head.createdAt) return b.head.createdAt - a.head.createdAt + return a.slug < b.slug ? -1 : 1 + }) +} + /** Whether `slug` may become a child of `targetSlug`. null = top level. */ export function canMoveUnder(pages: Page[], slug: string, targetSlug: string | null): boolean { if (targetSlug === null) return true diff --git a/src/domain/placement.test.ts b/src/domain/placement.test.ts index 90c6554..b6f0d73 100644 --- a/src/domain/placement.test.ts +++ b/src/domain/placement.test.ts @@ -30,6 +30,7 @@ function rev(partial: Partial & { id: string }): Revision { parentRevs: [], summary: null, content: '', + archived: false, ...partial, } } diff --git a/src/domain/revision.test.ts b/src/domain/revision.test.ts index dbb7400..6615fad 100644 --- a/src/domain/revision.test.ts +++ b/src/domain/revision.test.ts @@ -60,4 +60,24 @@ describe('parseRevision', () => { it('falls back to the slug as the title when no title tag exists', () => { expect(parseRevision(event({}), 'engineering')?.title).toBe('onboarding') }) + + it('reads the archived flag from the tag being there, not from its value', () => { + const archived = (tag: string[]) => + parseRevision( + event({ tags: [['h', 'engineering'], ['d', 'onboarding'], tag] }), + 'engineering', + )?.archived + + expect(archived(['archived', '1'])).toBe(true) + // The value is reserved and deliberately not read — anything there means + // the same thing, so a client writing something else still hides the page + // rather than silently publishing a visible one. src/nostr/kinds.ts + expect(archived(['archived', 'whatever'])).toBe(true) + expect(archived(['archived', ''])).toBe(true) + expect(archived(['summary', 'not archived'])).toBe(false) + }) + + it('is not archived when the tag is absent', () => { + expect(parseRevision(event({}), 'engineering')?.archived).toBe(false) + }) }) diff --git a/src/domain/revision.ts b/src/domain/revision.ts index fa94879..768f893 100644 --- a/src/domain/revision.ts +++ b/src/domain/revision.ts @@ -23,6 +23,13 @@ export type Revision = { parentRevs: string[] summary: string | null content: string + /** + * This revision archives its page. Not a property of the page but of the + * revision, so archiving is an ordinary, signed step in the chain that a + * later revision undoes — and the history of an archived page stays + * readable. docs/05-versioning-history.md + */ + archived: boolean } function firstTag(event: Event, name: string): string | null { @@ -56,5 +63,7 @@ export function parseRevision(event: Event, expectedGroup: string): Revision | n .map((tag) => tag[1]), summary: firstTag(event, TAGS.SUMMARY), content: event.content, + // Presence is the signal; the value is reserved. src/nostr/kinds.ts + archived: event.tags.some((tag) => tag[0] === TAGS.ARCHIVED), } } diff --git a/src/domain/search.test.ts b/src/domain/search.test.ts index 080c1ad..130d8ca 100644 --- a/src/domain/search.test.ts +++ b/src/domain/search.test.ts @@ -16,6 +16,7 @@ function rev(slug: string, title: string, content: string): Revision { parentRevs: [], summary: null, content, + archived: false, } } @@ -74,3 +75,22 @@ describe('highlightParts', () => { expect(highlightParts('nothing here', 'vpn')).toEqual([{ text: 'nothing here', hit: false }]) }) }) + +describe('an archived page', () => { + // Both directions, because one of them alone proves nothing: an + // implementation that never returns a hit would pass the exclusion on its + // own. The pair pins that the archived tag is what makes the difference. + it('is never a search hit — a result is navigation', () => { + const first = rev('notes', 'Release notes', 'the release notes') + const second = { ...first, id: 'gone', createdAt: 2000, parentRevs: ['notes'] } + + const visible = buildPages([first, { ...second, archived: false }]) + expect(visible[0].archived).toBe(false) + expect(searchPages(visible, 'release').map((hit) => hit.page.slug)).toEqual(['notes']) + + const archived = buildPages([first, { ...second, archived: true }]) + expect(archived[0].archived).toBe(true) + expect(searchPages(archived, 'release')).toEqual([]) + }) +}) + diff --git a/src/domain/search.ts b/src/domain/search.ts index 740c2c2..3193ed9 100644 --- a/src/domain/search.ts +++ b/src/domain/search.ts @@ -38,6 +38,11 @@ export function searchPages(pages: Page[], query: string, maxSnippets = 3): Sear const hits: SearchHit[] = [] for (const page of pages) { + // An archived page is out of the navigation, and a search result is + // navigation. Filtered here rather than at the call site so a new caller + // cannot forget it — `space.pages` deliberately still carries archived + // pages, because the history and the page's own URL must still find them. + if (page.archived) continue const title = page.title.toLowerCase() const lines = page.head.content.split('\n') const lower = lines.map((line) => line.toLowerCase()) diff --git a/src/nostr/kinds.ts b/src/nostr/kinds.ts index f8ad516..e873224 100644 --- a/src/nostr/kinds.ts +++ b/src/nostr/kinds.ts @@ -92,6 +92,24 @@ export const TAGS = { SUMMARY: 'summary', /** Restore: id of the revision whose content was taken over */ RESTORE_OF: 'restore-of', + /** + * Marks a revision as archiving its page: the page leaves the tree, the + * search and the navigation, while its history stays reachable and a later + * revision without the tag brings it back. + * + * Named for what it does, not for what it resembles. A "tombstone" in the + * distributed-systems sense marks a *deletion*, and nothing is deleted here + * — the text travels with the revision and the operation is symmetric. The + * neighbouring candidate, `hidden`, promises secrecy the feature does not + * deliver: an archived page still answers its own URL. + * + * **Presence is the signal, the value is not read.** It is written as `1` + * rather than as a one-element tag because a bare `["archived"]` is legal + * NIP-01 but is the kind of edge every relay and foreign client handles + * slightly differently. The value is reserved; nothing may start depending + * on it without a NIP to point at. docs/05-versioning-history.md + */ + ARCHIVED: 'archived', /** Content type, always text/markdown here */ MIME: 'm', /** NIP-31: fallback description for foreign clients */ diff --git a/src/nostr/publish-page.archive.test.ts b/src/nostr/publish-page.archive.test.ts new file mode 100644 index 0000000..c8c794f --- /dev/null +++ b/src/nostr/publish-page.archive.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' +import type { Signer } from './signer' +import { KINDS, TAGS } from './kinds' +import { client } from './client' +import { publishRevision } from './publish-page' +import { parseRevision } from '../domain/revision' +import { buildPages, buildTree } from '../domain/pages' + +/** + * Hiding a page and bringing it back, end to end through the real signer and + * the real parser — the round trip is the claim CON-11 makes, and it only + * holds if the tag the publisher writes is the tag the parser reads. + * docs/05-versioning-history.md + */ +vi.mock('./client', () => ({ + client: { publish: vi.fn() }, +})) + +const secretKey = generateSecretKey() +const signer: Signer = { + kind: 'dev', + getPublicKey: async () => getPublicKey(secretKey), + signEvent: async (template) => finalizeEvent(template, secretKey), +} + +const base = { + relayUrl: 'ws://relay.example', + groupId: 'engineering', + slug: 'onboarding', + title: 'Onboarding', + parentSlug: null, + order: null, + summary: null, + content: '# Onboarding\n\nWelcome.', + parentRevs: [] as string[], +} + +async function publish(input: Parameters[1]) { + vi.mocked(client.publish).mockResolvedValue({ ok: true, message: 'accepted' }) + await publishRevision(signer, input) + return vi.mocked(client.publish).mock.calls.at(-1)![1] +} + +describe('publishRevision that archives the page', () => { + afterEach(() => { + vi.mocked(client.publish).mockReset() + }) + + it('writes the tag, and writes nothing when the page stays visible', async () => { + expect((await publish({ ...base, archived: true })).tags).toContainEqual([TAGS.ARCHIVED, '1']) + + const visible = await publish(base) + expect(visible.tags.some((tag) => tag[0] === TAGS.ARCHIVED)).toBe(false) + }) + + it('is still an ordinary revision — the text travels with it', async () => { + const event = await publish({ ...base, archived: true }) + expect(event.kind).toBe(KINDS.PAGE_REVISION) + // Not an empty body: that would be indistinguishable from somebody + // clearing the page, and it would make restoring lossy. + expect(event.content).toBe(base.content) + }) + + it('says in the alt text that the page was archived, for a foreign client', async () => { + const event = await publish({ ...base, archived: true }) + const alt = event.tags.find((tag) => tag[0] === TAGS.ALT)![1] + expect(alt).toContain('was archived') + }) + + it('round trips: published, parsed, and the page is out of the tree', async () => { + const first = await publish(base) + const removal = await publish({ ...base, archived: true, parentRevs: [first.id] }) + + const revisions = [ + parseRevision(first, base.groupId)!, + parseRevision(removal, base.groupId)!, + ] + const pages = buildPages(revisions) + expect(pages[0].archived).toBe(true) + expect(buildTree(pages)).toEqual([]) + }) + + it('round trips back: a revision without the tag brings the page back', async () => { + const first = await publish(base) + const removal = await publish({ ...base, archived: true, parentRevs: [first.id] }) + const back = await publish({ ...base, parentRevs: [removal.id] }) + + const pages = buildPages( + [first, removal, back].map((event) => parseRevision(event, base.groupId)!), + ) + expect(pages[0].archived).toBe(false) + expect(buildTree(pages).map((node) => node.slug)).toEqual(['onboarding']) + // nothing was deleted on the way there and back + expect(pages[0].revisions).toHaveLength(3) + }) +}) diff --git a/src/nostr/publish-page.ts b/src/nostr/publish-page.ts index 5b8e02b..ba3c4b5 100644 --- a/src/nostr/publish-page.ts +++ b/src/nostr/publish-page.ts @@ -19,6 +19,13 @@ export type RevisionInput = { parentRevs: string[] /** restore: event id of the revision whose content was taken over */ restoreOf?: string + /** + * Archive the page: the revision carries the archived tag and the page + * leaves the tree, the search and the navigation. Publishing a later + * revision without it brings the page back, so this is a step in the chain + * and not a deletion. docs/05-versioning-history.md + */ + archived?: boolean } async function sha256Hex(input: string): Promise { @@ -41,7 +48,12 @@ export async function publishRevision( [TAGS.TITLE, input.title], [TAGS.MIME, MIME_MARKDOWN], [TAGS.CONTENT_HASH, await sha256Hex(input.content)], - [TAGS.ALT, `Wiki page "${input.title}" in space ${input.groupId}`], + [ + TAGS.ALT, + input.archived + ? `Wiki page "${input.title}" was archived in space ${input.groupId}` + : `Wiki page "${input.title}" in space ${input.groupId}`, + ], ] if (input.parentSlug) tags.push([TAGS.PAGE_PARENT, input.parentSlug]) // Absent rather than empty when there is none: the tree then falls back to @@ -52,6 +64,8 @@ export async function publishRevision( // A restore deletes nothing: it creates a new revision with the old content // that points at its template. docs/05-versioning-history.md if (input.restoreOf) tags.push([TAGS.RESTORE_OF, input.restoreOf]) + // Presence is the signal; the value is reserved. src/nostr/kinds.ts + if (input.archived) tags.push([TAGS.ARCHIVED, '1']) // NIP-27: everybody the text mentions gets a `p` tag, so the mention is // findable by the person mentioned and not only by whoever reads the page. // src/nostr/mentions.ts diff --git a/src/routes/HistoryView.test.tsx b/src/routes/HistoryView.test.tsx new file mode 100644 index 0000000..1504446 --- /dev/null +++ b/src/routes/HistoryView.test.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import type { ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import type { Root } from 'react-dom/client' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { installDialogShim } from '../test/dialog-shim' + +installDialogShim() + +vi.mock('../nostr/space-store', () => ({ useSpace: vi.fn(), forgetEvent: vi.fn() })) +vi.mock('../session/session', () => ({ useSession: vi.fn() })) +vi.mock('../nostr/publish-page', () => ({ publishRevision: vi.fn() })) +vi.mock('../nostr/moderation', () => ({ deleteGroupEvent: vi.fn() })) +// Stubbed for what they pull in, not for what they do: the profile store opens +// its own relay connection, and PageFrame renders through a portal. +vi.mock('../ui/Author', () => ({ + Author: () => null, + AuthorName: () => null, +})) +vi.mock('../ui/DiffView', () => ({ DiffView: () => null })) +vi.mock('../ui/layout/PageFrame', () => ({ + PageFrame: ({ children }: { children?: ReactNode }) => children, + PageTitle: ({ children }: { children?: ReactNode }) => children, +})) + +import { useSpace } from '../nostr/space-store' +import type { SpaceSnapshot } from '../nostr/space-store' +import { useSession } from '../session/session' +import { publishRevision } from '../nostr/publish-page' +import { buildPages } from '../domain/pages' +import type { Revision } from '../domain/revision' +import { HistoryView } from './HistoryView' + +const ME = 'a'.repeat(64) +const GROUP = 'engineering' + +let root: Root + +function revision(): Revision { + return { + id: 'r1', + author: ME, + createdAt: 1000, + group: GROUP, + slug: 'notes', + title: 'Release notes', + parentSlug: null, + order: null, + parentRevs: [], + summary: null, + content: 'the release notes', + archived: false, + } +} + +function snapshot(): SpaceSnapshot { + return { + loading: false, + metadata: { + name: 'Engineering', + about: null, + picture: null, + isPublic: false, + isOpen: false, + supportedKinds: [], + }, + admins: [], + members: [ME], + pages: buildPages([revision()]), + tree: [], + comments: [], + } +} + +function button(label: string): HTMLButtonElement { + const found = [...document.querySelectorAll('button')].find( + (element) => element.textContent?.trim() === label, + ) + if (!found) throw new Error(`no button labelled "${label}"`) + return found as HTMLButtonElement +} + +async function click(label: string) { + await act(async () => { + button(label).dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +/** + * The whole gesture: the button at the foot only opens the confirmation, and + * the act itself is the button inside it. Going through both is the point — + * a test that called the handler directly would pass while the dialog was + * wired to nothing. src/ui/ConfirmDialog.tsx + */ +async function archivePage() { + await click('Archive this page') + await click('Archive the page') +} + +async function render() { + const host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [`/s/${encodeURIComponent(`localhost:8081'${GROUP}`)}/notes/history`] }, + createElement( + Routes, + null, + createElement(Route, { + path: '/s/:group/:slug/history', + element: createElement(HistoryView), + }), + ), + ), + ) + }) +} + +beforeEach(() => { + vi.mocked(useSpace).mockReturnValue(snapshot()) + vi.mocked(useSession).mockReturnValue({ + session: { status: 'signed-in', pubkey: ME, signer: {} }, + ensureSamePubkey: async () => ({ ok: true }), + } as unknown as ReturnType) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +/** + * The archive flow reports its failures through `useArchivePage`'s own state. + * Reading that back off the hook object *after* awaiting `setArchived` returns + * the value from the render the click handler was created in — always the + * previous attempt's. These two cases pin the symptoms that produced: a failure + * with no message at all, and a success carrying the last failure's message. + */ +describe('HistoryView: archiving a page that the relay refuses', () => { + it('shows the relay reason instead of failing silently', async () => { + vi.mocked(publishRevision).mockResolvedValue({ ok: false, reason: 'blocked: not a member' }) + await render() + + await archivePage() + + expect(document.body.textContent).toContain('blocked: not a member') + expect(document.body.textContent).toContain('That did not work') + }) + + it('does not carry the failed attempt into the next, successful one', async () => { + vi.mocked(publishRevision).mockResolvedValue({ ok: false, reason: 'blocked: not a member' }) + await render() + await archivePage() + + vi.mocked(publishRevision).mockResolvedValue({ ok: true, message: 'ok' }) + await archivePage() + + expect(document.body.textContent).not.toContain('blocked: not a member') + expect(document.body.textContent).not.toContain('That did not work') + expect(document.body.textContent).toContain('The page is archived') + }) +}) + +/** + * Both flows on this page write to the same notice. A fixed headline meant the + * archive confirmation was announced as a deletion — the one thing the whole + * feature is built not to claim. docs/05-versioning-history.md + */ +describe('HistoryView: the success notice', () => { + it('does not announce an archived page as a deletion', async () => { + vi.mocked(publishRevision).mockResolvedValue({ ok: true, message: 'ok' }) + await render() + + await archivePage() + + expect(document.body.textContent).toContain('The page is archived') + expect(document.body.textContent).not.toContain('Deletion requested') + }) +}) + +/** + * The confirmation replaced `window.confirm`, and with it the one thing that + * made "cancel" safe for free: a blocking call that simply returned false. + * Now cancelling is a state change, and nothing stops a rewiring that opens + * the dialog and archives the page anyway. docs/05-versioning-history.md + */ +describe('HistoryView: the archive confirmation', () => { + it('says what it is about to do before it does it', async () => { + await render() + expect(document.body.textContent).not.toContain('Archive "Release notes"?') + + await click('Archive this page') + + expect(document.body.textContent).toContain('Archive "Release notes"?') + expect(document.body.textContent).toContain('Nothing is deleted') + expect(publishRevision).not.toHaveBeenCalled() + }) + + it('publishes nothing when it is cancelled', async () => { + await render() + await click('Archive this page') + await click('Cancel') + + expect(publishRevision).not.toHaveBeenCalled() + expect(document.body.textContent).not.toContain('Archive "Release notes"?') + }) +}) diff --git a/src/routes/HistoryView.tsx b/src/routes/HistoryView.tsx index ad280da..824772f 100644 --- a/src/routes/HistoryView.tsx +++ b/src/routes/HistoryView.tsx @@ -11,6 +11,9 @@ import { publishRevision } from '../nostr/publish-page' import { classifyRejection } from '../nostr/client' import { deleteGroupEvent } from '../nostr/moderation' import { forgetEvent } from '../nostr/space-store' +import { archiveConfirmation, useArchivePage } from '../ui/archive-page' +import { ConfirmDialog } from '../ui/ConfirmDialog' +import { childSlugs } from '../domain/pages' import type { Revision } from '../domain/revision' import { PageFrame, PageTitle } from '../ui/layout/PageFrame' import { Button, Callout, Card, IconButtonLink, SectionLabel } from '../ui/controls' @@ -21,6 +24,16 @@ function stamp(seconds: number): string { return new Date(seconds * 1000).toLocaleString() } +/** + * The act waiting for a confirmation. Both of this page's destructive actions + * go through one piece of state rather than a boolean each: only one dialog can + * be open, and a pair of booleans is a way to end up with two. + * + * Bringing an archived page back is deliberately not in here — it takes nothing + * away, and a dialog in front of it would ask people to confirm the undo. + */ +type Pending = { kind: 'archive' } | { kind: 'delete'; revision: Revision } + export function HistoryView() { const { group, space, base, slug } = useSpaceRoute() const { session, ensureSamePubkey } = useSession() @@ -29,6 +42,12 @@ export function HistoryView() { const [details, setDetails] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + // The notice carries its own title: this view has two flows that succeed in + // very different ways, and a fixed title would put an archiving under a + // headline announcing a deletion — the one reading this page must not draw. + const [notice, setNotice] = useState<{ title: string; body: string } | null>(null) + const archive = useArchivePage(group?.relayUrl ?? '', group?.id ?? '') + const [pending, setPending] = useState(null) if (!group || !base || !slug) { return ( @@ -59,14 +78,29 @@ export function HistoryView() { space.admins.some((admin) => admin.pubkey === session.pubkey) const revisions = page.revisions + /** + * Wipes what the previous action left on screen. The archive hook keeps its + * own error state, so clearing the local one is not enough — otherwise a + * failed archiving stays in the callout while the next action reports its + * own result. + */ + const clearFeedback = () => { + setError(null) + setNotice(null) + archive.setError(null) + } + + // Two sources, one callout: the actions on this page report through local + // state, archiving reports through its hook. Only one of them can be set at + // a time, because every action clears both before it starts. + const shownError = error ?? archive.error + + // The confirmation stays open until the relay has answered — closing it on + // the click would leave a slow relay looking like nothing happened, and the + // dialog is the only thing on screen disabled while the request is out. const removeRevision = async (revision: Revision) => { if (session.status !== 'signed-in') return - // The relay really enforces this deletion — so ask first. - const ok = window.confirm( - `Delete the revision from ${stamp(revision.createdAt)} on the relay? This cannot be undone.`, - ) - if (!ok) return - setError(null) + clearFeedback() setBusy(true) try { const same = await ensureSamePubkey() @@ -88,6 +122,7 @@ export function HistoryView() { setError(err instanceof Error ? err.message : 'signing was cancelled') } finally { setBusy(false) + setPending(null) } } const from = selection ? revisions.find((r) => r.id === selection.from) : revisions[1] @@ -95,7 +130,7 @@ export function HistoryView() { const restore = async (revision: Revision) => { if (session.status !== 'signed-in') return - setError(null) + clearFeedback() setBusy(true) try { const same = await ensureSamePubkey() @@ -139,6 +174,40 @@ export function HistoryView() { } } + /** + * Archiving the page, and bringing it back. A page-level action, so it sits + * in the view that is already about this page's lifecycle rather than among + * the navigation icons on the page itself. src/ui/archive-page.ts + */ + const toggleArchived = async () => { + if (session.status !== 'signed-in') return + clearFeedback() + // The failure is *not* read back off `archive` here: this closure holds the + // hook's object from the render it was created in, and `setArchived` + // reports its error by setting state, which produces a new object rather + // than mutating that one. Reading `archive.error` after the await would + // therefore show the previous attempt's message, never the current one. The + // callout renders `archive.error` directly instead — the same way the + // sidebar renders `useMovePage`'s. src/ui/archive-page.ts + const ok = await archive.setArchived(page, !page.archived) + // Only now, for the same reason `removeRevision` waits: the confirmation + // is what shows that the request is still out. + setPending(null) + if (ok && !page.archived) { + setNotice({ + title: 'The page is archived — and can come back', + body: + 'The page is out of the tree, the search and the overview. Its history is ' + + 'unchanged and this link still works — bring it back from here whenever you want.', + }) + } + } + + // Built on every render rather than inside the dialog, because the subpage + // count has to be the one at the moment the dialog is read — a page whose + // children moved while it was open would otherwise promise the old number. + const confirmation = archiveConfirmation(page, childSlugs(space.pages, page.slug).length) + const option = (revision: Revision) => ( <> {stamp(revision.createdAt)} · @@ -175,10 +244,41 @@ export function HistoryView() { {page.title} - {error ? ( + {shownError ? (
- {error} + {shownError} + +
+ ) : null} + + {notice ? ( +
+ + {notice.body} + +
+ ) : null} + + {page.archived ? ( +
+ void toggleArchived()} + > + Bring the page back + + ) : null + } + > + It is out of the tree and the search. Nothing was deleted — the history below is + complete.
) : null} @@ -299,7 +399,7 @@ export function HistoryView() { variant="subtle" className="text-danger hover:bg-danger-bg" disabled={busy} - onClick={() => void removeRevision(revision)} + onClick={() => setPending({ kind: 'delete', revision })} > Delete @@ -324,6 +424,70 @@ export function HistoryView() { })} + + {/* At the foot, not in the actions bar. Archiving a page is a page-level + action and belongs in the view about this page's lifecycle — but it + is also the weightiest thing here, and it has no business sitting + next to the navigation icons somebody reaches for to get back to + reading. */} + {session.status === 'signed-in' && !page.archived ? ( +
+
+ + {/* One line. The consequences are spelled out in full in the + confirmation a click away, and saying them twice here turned + the button into a paragraph with a button in front of it. + src/ui/ConfirmDialog.tsx */} +

+ Takes it out of the navigation. Nothing is deleted. +

+
+
+ ) : null} + + {/* Both confirmations live here rather than next to the buttons that open + them: a `` is in the top layer wherever it sits in the markup, + and keeping them together makes it obvious that only one can be open. + src/ui/ConfirmDialog.tsx */} + void toggleArchived()} + onCancel={() => setPending(null)} + > +

{confirmation.body}

+ {confirmation.subpages ?

{confirmation.subpages}

: null} +
+ + { + if (pending?.kind === 'delete') void removeRevision(pending.revision) + }} + onCancel={() => setPending(null)} + > +

+ The revision from{' '} + {pending?.kind === 'delete' ? stamp(pending.revision.createdAt) : ''} is removed by the + relay, which really enforces this. It cannot be undone. +

+

+ Newer revisions built on it keep their text — what goes is this one step of the + chain. +

+
) } diff --git a/src/routes/PageView.crumbs.test.tsx b/src/routes/PageView.crumbs.test.tsx new file mode 100644 index 0000000..12f803f --- /dev/null +++ b/src/routes/PageView.crumbs.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import type { ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import type { Root } from 'react-dom/client' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +vi.mock('../nostr/space-store', () => ({ useSpace: vi.fn() })) +vi.mock('../session/session', () => ({ useSession: vi.fn() })) +// The crumbs are the subject, so the frame is the one thing rendered honestly: +// it reports the labels it was handed. Everything else is stubbed for what it +// drags in — markdown rendering, the comment subscription, the profile store. +vi.mock('../ui/layout/PageFrame', () => ({ + PageFrame: ({ crumbs, children }: { crumbs?: { label: string }[]; children?: ReactNode }) => + createElement( + 'div', + null, + createElement('nav', { 'data-testid': 'crumbs' }, (crumbs ?? []).map((crumb) => crumb.label).join(' / ')), + children, + ), + PageTitle: ({ children }: { children?: ReactNode }) => children, +})) +vi.mock('../ui/Markdown', () => ({ Markdown: () => null })) +vi.mock('../ui/Comments', () => ({ Comments: () => null })) +vi.mock('../ui/Byline', () => ({ Byline: () => null })) +vi.mock('../ui/Author', () => ({ Author: () => null, AuthorName: () => null })) +vi.mock('../ui/layout/toc-context', () => ({ useTocSource: () => {} })) + +import { useSpace } from '../nostr/space-store' +import type { SpaceSnapshot } from '../nostr/space-store' +import { useSession } from '../session/session' +import { buildPages } from '../domain/pages' +import type { Revision } from '../domain/revision' +import { PageView } from './PageView' + +const ME = 'a'.repeat(64) +const GROUP = 'engineering' + +let root: Root + +function rev( + slug: string, + title: string, + parentSlug: string | null, + extra: Partial = {}, +): Revision { + return { + id: slug, + author: ME, + createdAt: 1000, + group: GROUP, + slug, + title, + parentSlug, + order: null, + parentRevs: [], + summary: null, + content: 'text', + archived: false, + ...extra, + } +} + +function snapshot(revisions: Revision[]): SpaceSnapshot { + return { + loading: false, + metadata: { + name: 'Engineering', + about: null, + picture: null, + isPublic: false, + isOpen: false, + supportedKinds: [], + }, + admins: [], + members: [ME], + pages: buildPages(revisions), + tree: [], + comments: [], + } +} + +async function render(slug: string) { + const host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [`/s/${encodeURIComponent(`localhost:8081'${GROUP}`)}/${slug}`] }, + createElement( + Routes, + null, + createElement(Route, { path: '/s/:group/:slug', element: createElement(PageView) }), + ), + ), + ) + }) +} + +function crumbs(): string { + return document.querySelector('[data-testid="crumbs"]')?.textContent ?? '' +} + +beforeEach(() => { + vi.mocked(useSession).mockReturnValue({ + session: { status: 'signed-in', pubkey: ME, signer: {} }, + ensureSamePubkey: async () => ({ ok: true }), + } as unknown as ReturnType) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +/** + * `buildTree` lifts the children of an archived page to the top level, so the + * sidebar shows no parent for them. A breadcrumb still leading into that parent + * would be the same tree described two different ways on one screen. + * src/domain/pages.ts, docs/06-ui-information-architecture.md + */ +describe('PageView breadcrumbs and an archived parent', () => { + it('shows the parent while it is visible', async () => { + vi.mocked(useSpace).mockReturnValue( + snapshot([rev('handbook', 'Handbook', null), rev('onboarding', 'Onboarding', 'handbook')]), + ) + await render('onboarding') + + expect(crumbs()).toBe('Engineering / Handbook / Onboarding') + }) + + it('stops at an archived parent, as the tree does', async () => { + vi.mocked(useSpace).mockReturnValue( + snapshot([ + rev('handbook', 'Handbook', null), + rev('handbook', 'Handbook', null, { + id: 'handbook-gone', + createdAt: 2000, + parentRevs: ['handbook'], + archived: true, + }), + rev('onboarding', 'Onboarding', 'handbook'), + ]), + ) + await render('onboarding') + + expect(crumbs()).toBe('Engineering / Onboarding') + }) +}) diff --git a/src/routes/PageView.tsx b/src/routes/PageView.tsx index edb0a86..b23c48c 100644 --- a/src/routes/PageView.tsx +++ b/src/routes/PageView.tsx @@ -20,6 +20,12 @@ import type { Page } from '../domain/pages' * reversed, with a guard against a cycle: `page-parent` comes off the relay * from an arbitrary key, and two pages naming each other as parent would * otherwise loop here forever. + * + * An archived parent ends the trail, exactly as it ends a branch in + * `buildTree`: the subpage is at the top level there, and a crumb leading into + * a page that is out of the tree would contradict the sidebar the reader is + * looking at. + * src/domain/pages.ts */ function ancestors(pages: Page[], page: Page): Page[] { const chain: Page[] = [] @@ -28,7 +34,7 @@ function ancestors(pages: Page[], page: Page): Page[] { while (cursor && !seen.has(cursor)) { seen.add(cursor) const parent = pages.find((entry) => entry.slug === cursor) - if (!parent) break + if (!parent || parent.archived) break chain.unshift(parent) cursor = parent.parentSlug } @@ -164,6 +170,27 @@ export function PageView() {
}>{page.title} + {/* A page that is out of the tree still answers its own URL, so the + one place somebody can learn it was archived is the page itself. + Above the fork notice: "this page is out of the space" outranks + "it has two versions". */} + {page.archived ? ( +
+ + History and restore + + } + > + It is out of the tree and the search, so nobody will come across it. This link + keeps working. + +
+ ) : null} + {forked ? (
!page.archived) // Search runs over the pages the store holds. In a space the relay is // withholding that is none, so "Nothing found in 0 pages" would be true and // useless — it sounds like the search failed, not like the door is shut. @@ -57,7 +63,7 @@ export function SearchView() { below={ empty || hidden ? null : (

- {hits.length} of {space.pages.length} pages + {hits.length} of {searchable.length} pages

) } @@ -79,7 +85,7 @@ export function SearchView() {
) : hits.length === 0 ? (

- {space.loading ? 'loading pages…' : `Nothing found in ${space.pages.length} pages.`} + {space.loading ? 'loading pages…' : `Nothing found in ${searchable.length} pages.`}

) : (
    diff --git a/src/test/dialog-shim.ts b/src/test/dialog-shim.ts new file mode 100644 index 0000000..134d7e8 --- /dev/null +++ b/src/test/dialog-shim.ts @@ -0,0 +1,30 @@ +/** + * jsdom ships `HTMLDialogElement` but implements none of its behaviour: + * `showModal` and `close` simply do not exist, so a component that opens a + * dialog throws on render instead of showing one. This teaches it the part the + * tests depend on — the element is in the document and reachable when open — + * and nothing more. + * + * Deliberately not a substitute for the real thing: the top layer, the + * backdrop, the focus move and Escape are all still missing here, so a test + * written against this shim can assert *what* a dialog says and what its + * buttons do, never that it behaves like a modal. That part is the browser's. + * src/ui/ConfirmDialog.tsx + */ +export function installDialogShim(): void { + const proto = globalThis.HTMLDialogElement?.prototype + if (!proto || typeof proto.showModal === 'function') return + + proto.showModal = function showModal(this: HTMLDialogElement) { + this.open = true + } + proto.show = function show(this: HTMLDialogElement) { + this.open = true + } + proto.close = function close(this: HTMLDialogElement, returnValue?: string) { + if (!this.open) return + this.open = false + if (returnValue !== undefined) this.returnValue = returnValue + this.dispatchEvent(new Event('close')) + } +} diff --git a/src/ui/ConfirmDialog.tsx b/src/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..ea34213 --- /dev/null +++ b/src/ui/ConfirmDialog.tsx @@ -0,0 +1,113 @@ +import { useEffect, useId, useRef } from 'react' +import type { MouseEvent, ReactNode } from 'react' +import { Button } from './controls' +import type { Variant } from './controls' + +/** + * The app's confirmation. Replaces `window.confirm`, which the browser draws in + * its own chrome: a system font, the origin above it, and no room for the two + * or three sentences a consequential action actually has to explain. A + * confirmation is part of the product, not a pause in it. + * + * Built on the native `` element rather than a `position: fixed` div, + * because `showModal()` gives the things a hand-rolled overlay gets wrong: the + * top layer (so nothing can paint over it), a real `::backdrop`, focus moved + * in and kept inside, the page behind it inert, and Escape wired up. What is + * left for us is the look and the two buttons. + * + * **Cancel holds the focus**, not the confirming button. Everything this + * dialog is used for is a change somebody may not have meant to make, and a + * dialog that answers Return with "yes" turns a stray keypress into the act it + * was there to guard. + * + * jsdom implements the element but none of its behaviour, so a test that + * renders one needs `src/test/dialog-shim.ts`. + */ +export type ConfirmDialogProps = { + open: boolean + title: string + /** The wording of the act, on the button that performs it — never "OK". */ + confirmLabel: string + confirmVariant?: Variant + busy?: boolean + onConfirm: () => void + onCancel: () => void + children: ReactNode +} + +/** + * Mounted only while it is open, rather than kept in the document and toggled. + * A closed `` is invisible but its content is still there — read by a + * screen reader in some browsers, found by the page's own text search, and + * rendered with whatever data it was last given. Mounting on demand means the + * text in it is always the text of the act being confirmed right now. + */ +export function ConfirmDialog({ open, ...props }: ConfirmDialogProps) { + if (!open) return null + return +} + +function Dialog({ + title, + confirmLabel, + confirmVariant = 'danger', + busy = false, + onConfirm, + onCancel, + children, +}: Omit) { + const ref = useRef(null) + const titleId = useId() + // Closing is the caller's business — it unmounts this — so the flag only has + // to stop `close` from being reported as a cancellation on the way out. + const closing = useRef(false) + + useEffect(() => { + const dialog = ref.current + if (!dialog) return + dialog.showModal() + return () => { + closing.current = true + dialog.close() + } + }, []) + + // Escape and the backdrop both end up here. + const handleClose = () => { + if (!closing.current) onCancel() + } + + // The backdrop is not a child, so a click on it reports the dialog itself as + // the target; anything inside reports the element it landed on. + const handleClick = (event: MouseEvent) => { + if (event.target === ref.current) onCancel() + } + + return ( + +
    +

    + {title} +

    +
    {children}
    +
    +
    + + +
    +
    + ) +} diff --git a/src/ui/PageEditor.test.tsx b/src/ui/PageEditor.test.tsx index 6567fc2..2b44f31 100644 --- a/src/ui/PageEditor.test.tsx +++ b/src/ui/PageEditor.test.tsx @@ -44,12 +44,22 @@ function revision(id: string, slug: string, title: string): Revision { parentRevs: [], summary: null, content: `${title} body`, + archived: false, } } function pageOf(slug: string, title: string): Page { const head = revision(`rev-${slug}`, slug, title) - return { slug, title, parentSlug: null, order: null, head, revisions: [head], leaves: [head] } + return { + slug, + title, + parentSlug: null, + order: null, + head, + revisions: [head], + leaves: [head], + archived: false, + } } function titleInput(): HTMLInputElement { diff --git a/src/ui/archive-page.test.ts b/src/ui/archive-page.test.ts new file mode 100644 index 0000000..4ffac3d --- /dev/null +++ b/src/ui/archive-page.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { archiveConfirmation } from './archive-page' +import { buildPages, childSlugs } from '../domain/pages' +import type { Revision } from '../domain/revision' + +function rev(slug: string, title: string, parentSlug: string | null = null): Revision { + return { + id: slug, + author: 'alice', + createdAt: 1000, + group: 'engineering', + slug, + title, + parentSlug, + order: null, + parentRevs: [], + summary: null, + content: 'text', + archived: false, + } +} + +const pages = buildPages([ + rev('handbook', 'Handbook'), + rev('onboarding', 'Onboarding', 'handbook'), + rev('leave', 'Leave', 'handbook'), + rev('deploy', 'Deploy'), +]) + +function page(slug: string) { + const found = pages.find((entry) => entry.slug === slug) + if (!found) throw new Error(`no page ${slug}`) + return found +} + +/** + * The confirmation is the only place a user is told what archiving a page does + * before they do it, and both of its claims are ones people assume the other + * way round: the page survives for anyone with the link, and the subpages do + * not go with it. A reworded dialogue that quietly drops either is the failure + * this guards. docs/05-versioning-history.md + */ +describe('archiveConfirmation', () => { + it('names the page in the title and says nothing is deleted', () => { + const { title, body } = archiveConfirmation(page('deploy'), 0) + expect(title).toContain('"Deploy"') + expect(body).toContain('Nothing is deleted') + expect(body).toContain('bring it back') + }) + + it('warns that the subpages stay behind, counted', () => { + const subpages = childSlugs(pages, 'handbook') + expect(subpages).toHaveLength(2) + expect(archiveConfirmation(page('handbook'), subpages.length).subpages).toContain( + 'Its 2 subpages will stay', + ) + }) + + it('counts a single subpage in the singular', () => { + expect(archiveConfirmation(page('handbook'), 1).subpages).toContain('Its 1 subpage will stay') + }) + + it('has nothing to say about subpages when there are none', () => { + // null and not an empty string: the dialog renders a paragraph per part, + // and an empty one would be a blank line nobody put there. + expect(archiveConfirmation(page('deploy'), 0).subpages).toBeNull() + }) +}) diff --git a/src/ui/archive-page.ts b/src/ui/archive-page.ts new file mode 100644 index 0000000..aadf428 --- /dev/null +++ b/src/ui/archive-page.ts @@ -0,0 +1,114 @@ +import { useState } from 'react' +import { classifyRejection } from '../nostr/client' +import { publishRevision } from '../nostr/publish-page' +import { useSession } from '../session/session' +import type { Page } from '../domain/pages' + +/** + * Archiving a page, and bringing it back. + * + * Both are the same operation: publish a revision on top of the current head, + * one carrying the archived tag and one not. Nothing is deleted, so there is + * no asymmetry to model — "restore" is not a repair, it is the other value of + * the same flag. docs/05-versioning-history.md + * + * The content is carried over unchanged. An archiving revision with an empty + * body would be indistinguishable from somebody clearing the page, and it + * would make restoring lossy: the text has to come back with the page. + * + * **The placement (`31818`) is deliberately left alone.** It has no effect + * while the page is archived — a page that is not in the tree cannot be placed + * anywhere — and deleting it would make a restore land the page at whatever + * its title sorts to instead of where it was. The genuinely orphaned case, a + * placement whose slug has no revisions at all, is a different problem and + * belongs to CON-21. docs/02-data-model-events.md + */ +export type ArchivePage = { + /** Publishes the archiving revision (or its undo). True when it landed. */ + setArchived: (page: Page, archived: boolean) => Promise + busy: boolean + error: string | null + setError: (message: string | null) => void +} + +export function useArchivePage(relayUrl: string, groupId: string): ArchivePage { + const { session, ensureSamePubkey } = useSession() + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const setArchived = async (page: Page, archived: boolean): Promise => { + if (session.status !== 'signed-in') { + setError('Archiving a page signs an event — please sign in first.') + return false + } + setError(null) + setBusy(true) + try { + const same = await ensureSamePubkey() + if (!same.ok) { + setError(same.reason) + return false + } + const result = await publishRevision(session.signer, { + relayUrl, + groupId, + slug: page.slug, + title: page.title, + // Where the page hangs travels with it, for the same reason a restore + // carries it: this revision is about visibility, not about position. + parentSlug: page.parentSlug, + order: page.order, + summary: archived ? 'archived this page' : 'brought this page back', + content: page.head.content, + parentRevs: [page.head.id], + archived, + }) + if (result.ok) return true + + const kind = classifyRejection(result.reason) + setError( + kind === 'permission' + ? `The relay does not allow you to write here: ${result.reason}` + : `Not saved: ${result.reason}`, + ) + return false + } catch (err) { + setError(err instanceof Error ? err.message : 'signing was cancelled') + return false + } finally { + setBusy(false) + } + } + + return { setArchived, busy, error, setError } +} + +/** + * What the confirmation has to say before a page is archived. Two sentences, + * because nobody reads a paragraph they have to get past to click a button: + * what changes, and that it is not a deletion. The rest — the history stays, + * the link keeps working — is what "nothing is deleted" already means, and the + * archive itself shows it. + * + * Returned in parts rather than as one string, so the dialog can set the title + * as a heading and give the subpage warning a paragraph of its own — the shape + * `window.confirm` could only approximate with blank lines. + * src/ui/ConfirmDialog.tsx + */ +export type ArchiveConfirmation = { + title: string + body: string + /** Only when the page has subpages, because they do not go with it. */ + subpages: string | null +} + +export function archiveConfirmation(page: Page, subpages: number): ArchiveConfirmation { + return { + title: `Archive "${page.title}"?`, + body: 'It leaves the tree and the search. Nothing is deleted — you can bring it back.', + subpages: + subpages > 0 + ? `Its ${subpages} subpage${subpages === 1 ? '' : 's'} will stay, and move up to the top level.` + : null, + } +} From c76b660659eb5da3b31fd8379958402489e3cbe0 Mon Sep 17 00:00:00 2001 From: s Date: Thu, 17 Sep 2026 01:33:49 +0200 Subject: [PATCH 2/2] CON-11: the archive is a place you can go to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving without somewhere to look is a one-way door: the only route back to a page was a link somebody still happened to have. So the archive gets an address. A row under the page tree leads to `/s/:group/archive`, with a count beside it when there is something in it, and the view lists what was archived — newest first, ties broken by slug so the order does not shuffle between renders — with the author, the date and one button per row to bring a page back. The space overview links there too, under its page list. The route sits above `:slug` in the table, so `archive` is a route and not a page that happens to be called that. A page whose slug normalises to one of the app's own words is shadowed by it — `new` and `search` have had that since they were added — and that is CON-50, not this branch. --- docs/06-ui-information-architecture.md | 42 ++++++++ docs/10-roadmap.md | 1 - src/routes/ArchiveView.tsx | 135 +++++++++++++++++++++++++ src/routes/SpaceOverview.tsx | 24 ++++- src/routes/router.tsx | 4 + src/ui/icons.tsx | 15 +++ src/ui/layout/Sidebar.tsx | 32 +++++- 7 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 src/routes/ArchiveView.tsx diff --git a/docs/06-ui-information-architecture.md b/docs/06-ui-information-architecture.md index a443d25..a60aa1e 100644 --- a/docs/06-ui-information-architecture.md +++ b/docs/06-ui-information-architecture.md @@ -151,6 +151,24 @@ suffocates in a column that narrow, so it gets `max-w-4xl`. marker, but amber and *after* the title — otherwise it would read as the leaf dot. Each level indents by 14px. + An **archived page** (a revision carrying the `archived` tag, + [05](05-versioning-history.md)) is not drawn here at all, and neither is it + in the search or the space overview — those three are the navigation. Its + subpages come up to the top level rather than vanishing with it, by the same + rule that catches a missing parent. The page itself still answers its own + URL, and says on the page that it is archived. + + Because the tree is where people look for a page, the way out of it is + directly under the tree: an **Archive** row pinned below the scroll area, + carrying the count when there is one. The row itself is always there, empty + space or not — showing it only once a space has its first archived page + would reveal it to exactly the people who already know the way, which is + the opposite of what a place to find things is for. The space overview + repeats the link under its page list when there is something to see, for + the reader who never opens the bar. `/s/:group/archive` lists them newest + first — an archive nobody can open is not an archive but a hole the pages + fall into. + Which branches are folded is kept in `localStorage`. Deliberately the *folded* ones rather than the open ones, otherwise a page created later would stay hidden until somebody expanded its parent. The branch leading to the @@ -279,6 +297,29 @@ lives only in the browser would be a second storage location with its own questions (where? for how long? what on account switch?) — that would need the local cache that has not been built yet. +## Confirming something irreversible + +Anything that takes a page or a revision away asks first, and it asks in the +app's own dialog (`src/ui/ConfirmDialog.tsx`), not in `window.confirm`. The +browser's version is drawn in browser chrome — a system font, the origin above +it, one line of text and an "OK" — which is the wrong voice for a decision the +product is asking somebody to make, and has no room for the two or three +sentences these decisions actually need. + +It is built on the native `` and `showModal()`, so the top layer, the +backdrop, the focus move, the inert page behind it and Escape come from the +browser rather than from a `position: fixed` div pretending. Two rules it +keeps: + +- **The button says the act**, never "OK": *Archive the page*, *Delete the + revision*. The label is the last thing read before the click. +- **Cancel holds the focus.** Every one of these dialogs guards a change + somebody may not have meant to make, and a dialog that answers Return with + "yes" turns a stray keypress into the act it was there to prevent. + +Undoing is not confirmed. Bringing an archived page back takes nothing away, +and a dialog in front of it would be asking people to confirm the undo. + ## When the relay shows nothing A private space does not turn a stranger away — it answers with **nothing**, the @@ -446,6 +487,7 @@ of the possible states. /s/:group space overview (metadata, members, page list) /s/:group/new create a page (?parent= for a subpage) /s/:group/search search (?q=…) +/s/:group/archive the archived pages, newest first /s/:group/:slug read a page /s/:group/:slug/edit edit (?merge=1 to merge versions) /s/:group/:slug/history history with comparison diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 560ef88..192f795 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -223,7 +223,6 @@ As of 2026-09-07, found while comparing the docs against the code: | End-to-end tests (Playwright), including the colour-mode regression | [07](07-tech-stack.md), [12](12-theming.md) | | `9021` join flow for relays without auto-join | [04](04-permissions-nip29.md) | | Deleting your own revision (NIP-09 `kind 5`) | [05](05-versioning-history.md) | -| Hiding a whole page (tombstone) | [05](05-versioning-history.md) | | Writing `previous` timeline references | [02](02-data-model-events.md) | | Sidebar entries "all pages", "recently changed", "space settings" | [06](06-ui-information-architecture.md) | | Onboarding note that an npub is a permanent pseudonym | [09](09-security-privacy.md) | diff --git a/src/routes/ArchiveView.tsx b/src/routes/ArchiveView.tsx new file mode 100644 index 0000000..a844144 --- /dev/null +++ b/src/routes/ArchiveView.tsx @@ -0,0 +1,135 @@ +import { Link } from 'react-router-dom' +import { useSpaceRoute } from './space-route' +import { Author } from '../ui/Author' +import { useSession } from '../session/session' +import { SpaceHiddenNotice } from '../ui/SpaceHiddenNotice' +import { spaceAccess } from '../domain/space-access' +import { archivedPages } from '../domain/pages' +import { useArchivePage } from '../ui/archive-page' +import { PageFrame, PageTitle } from '../ui/layout/PageFrame' +import { Button, Callout, Card } from '../ui/controls' +import { ArchiveIcon, PageIcon } from '../ui/icons' + +/** The same spelling of a timestamp the history uses. */ +function stamp(seconds: number): string { + return new Date(seconds * 1000).toLocaleString() +} + +/** + * Where the archived pages are. + * + * An archive nobody can open is not an archive — it is a hole the pages fall + * into. Archiving takes a page out of the tree, the search and the overview, + * which leaves its own URL as the only way back to it, and that is exactly the + * thing somebody who archived a page by mistake no longer has. So this list + * exists, one entry per archived page, newest first. + * + * It is deliberately *not* the page index a second time: no tree, no nesting, + * no ordering by title. A page in here has no place in the tree by definition, + * and what is worth knowing about it is when it left and who put it there. + * docs/05-versioning-history.md + */ +export function ArchiveView() { + const { group, space, base } = useSpaceRoute() + const { session } = useSession() + const archive = useArchivePage(group?.relayUrl ?? '', group?.id ?? '') + + if (!group || !base) { + return ( + +

    Invalid address.

    +
    + ) + } + + const spaceName = space.metadata?.name ?? group.id + const crumbs = [{ label: spaceName, to: base }, { label: 'Archive' }] + + // A space the relay is withholding holds no pages here either, and "nothing + // archived" would read as a fact about the space rather than about access. + // docs/04-permissions-nip29.md + if (spaceAccess(session.status === 'signed-in' ? session.pubkey : null, space).state === 'hidden') { + return ( + + Nothing to see here + + + ) + } + + const pages = archivedPages(space.pages) + + return ( + + + {pages.length} archived page{pages.length === 1 ? '' : 's'}, newest first +

    + ) + } + > + {spaceName} +
    + + {archive.error ? ( +
    + + {archive.error} + +
    + ) : null} + + {pages.length === 0 ? ( + // An empty archive is the normal state, not a dead end — so it says + // what would put something in here rather than apologising. +
    + +

    + {space.loading + ? 'loading pages…' + : 'Nothing is archived in this space. Archiving a page — from the foot of its history — takes it out of the tree, the search and the overview, and puts it here.'} +

    +
    + ) : ( + +
      + {pages.map((page) => ( +
    • + + {/* The title still links to the page: archived is not deleted, + and reading it is how you decide whether to bring it back. */} + + {page.title} + + + archived {stamp(page.head.createdAt)} + + + + + {session.status === 'signed-in' ? ( + + ) : null} +
    • + ))} +
    +
    + )} +
    + ) +} diff --git a/src/routes/SpaceOverview.tsx b/src/routes/SpaceOverview.tsx index d9d030c..2c34c64 100644 --- a/src/routes/SpaceOverview.tsx +++ b/src/routes/SpaceOverview.tsx @@ -45,6 +45,11 @@ export function SpaceOverview() { const name = meta?.name ?? group.id const access = spaceAccess(session.status === 'signed-in' ? session.pubkey : null, space) const isMember = session.status === 'signed-in' && space.members.includes(session.pubkey) + // An archived page is out of the navigation, and this list is navigation. It + // stays in `space.pages` on purpose — the page's own URL and its history + // still have to reach it. src/domain/pages.ts + const listed = space.pages.filter((page) => !page.archived) + const archivedCount = space.pages.length - listed.length const isAdmin = session.status === 'signed-in' && space.admins.some((admin) => admin.pubkey === session.pubkey) @@ -182,15 +187,15 @@ export function SpaceOverview() { scanned down one column — cards would put every title at a different x. */}
    - Pages · {space.pages.length} - {space.pages.length === 0 ? ( + Pages · {listed.length} + {listed.length === 0 ? (

    {space.loading ? 'loading pages…' : 'No pages in this space yet.'}

    ) : (
      - {space.pages.map((page) => ( + {listed.map((page) => (
    • )} + + {/* The overview is the other place somebody looks for a page that is + not in the sidebar, so the way to the archive is stated here too — + quietly, under the list it is the complement of. + src/routes/ArchiveView.tsx */} + {archivedCount > 0 ? ( +

      + + Archive · {archivedCount} page{archivedCount === 1 ? '' : 's'} + {' '} + out of the tree, the search and this list. +

      + ) : null}
    diff --git a/src/routes/router.tsx b/src/routes/router.tsx index fc86ba2..4b57fa2 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -5,6 +5,7 @@ import { PageView } from './PageView' import { EditorView } from './EditorView' import { NewPageView } from './NewPageView' import { SearchView } from './SearchView' +import { ArchiveView } from './ArchiveView' import { SpaceSettingsRoute } from './SpaceSettings' import { SpacesSettingsList } from './SpacesSettingsList' import { HistoryView } from './HistoryView' @@ -28,6 +29,9 @@ export const router = createBrowserRouter([ { path: '/s/:group', element: }, { path: '/s/:group/new', element: }, { path: '/s/:group/search', element: }, + // Before `:slug`, so the word is a route and not a page that happens to + // be called "archive". Same reason `new` and `search` sit up here. + { path: '/s/:group/archive', element: }, { path: '/s/:group/:slug', element: }, { path: '/s/:group/:slug/edit', element: }, { path: '/s/:group/:slug/history', element: }, diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 5a91ad0..8e0e3ae 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -191,6 +191,21 @@ export function SubpageIcon(props: IconProps) { ) } +/** + * The archive. A lid over a box, with a handle on the drawer below it — the + * shape people know from a storage box rather than the folder that already + * means "page" here. src/routes/ArchiveView.tsx + */ +export function ArchiveIcon(props: IconProps) { + return ( + + + + + + ) +} + export function CloseIcon(props: IconProps) { return ( diff --git a/src/ui/layout/Sidebar.tsx b/src/ui/layout/Sidebar.tsx index 88dae45..27f7a18 100644 --- a/src/ui/layout/Sidebar.tsx +++ b/src/ui/layout/Sidebar.tsx @@ -14,6 +14,7 @@ import { useSession } from '../../session/session' import { useMovePage } from '../move-page' import { InitialsDisc, SectionLabel } from '../controls' import { + ArchiveIcon, ChevronDownIcon, ChevronRightIcon, HomeIcon, @@ -129,11 +130,14 @@ function NavRow({ to, icon, end = false, + trailing, children, }: { to: string icon: ReactNode end?: boolean + /** Right-aligned, for a count the label would otherwise have to carry. */ + trailing?: ReactNode children: ReactNode }) { return ( @@ -149,7 +153,8 @@ function NavRow({ } > {icon} - {children} + {children} + {trailing} ) } @@ -169,6 +174,9 @@ export function Sidebar({ group, space, snapshot, info, inSettings }: Props) { space.pages, ) const forcedOpen = pathToActive(nodes, slug) + // Counted off `space.pages`, not off the tree: an archived page is exactly + // the one the tree leaves out. src/domain/pages.ts + const archivedCount = space.pages.filter((page) => page.archived).length // Dragging a page onto another one files it there, dragging it into the gap // between two rows puts it at that position of their level — the same two @@ -358,6 +366,28 @@ export function Sidebar({ group, space, snapshot, info, inSettings }: Props) { {busySlug ?
    moving…
    : null} {error ?
    {error}
    : null}
    + + {/* Directly under the tree, and outside its scroll area, because that + is the question it answers: the page is not in this list, so where + is it. + **Always shown, empty or not.** Hiding it until the space has its + first archived page was the obvious saving and the wrong one: the + row would only appear to someone who already knew the way, which + is the opposite of what a place to find things is for. The count + is what appears and disappears. src/routes/ArchiveView.tsx */} +
    + } + trailing={ + archivedCount > 0 ? ( + {archivedCount} + ) : null + } + > + Archive + +
    ) : (