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/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/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/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/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/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/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, + } +} 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 + +
    ) : (