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 `
+ ) : 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 `