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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
45 changes: 43 additions & 2 deletions docs/05-versioning-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 42 additions & 0 deletions docs/06-ui-information-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<dialog>` 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
Expand Down Expand Up @@ -446,6 +487,7 @@ of the possible states.
/s/:group space overview (metadata, members, page list)
/s/:group/new create a page (?parent=<slug> 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
Expand Down
1 change: 0 additions & 1 deletion docs/10-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions src/domain/blame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function rev(id: string, content: string, parents: string[] = [], author = 'alic
parentRevs: parents,
summary: null,
content,
archived: false,
}
}

Expand Down
103 changes: 103 additions & 0 deletions src/domain/pages.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
archivedPages,
buildPages,
buildTree,
canMoveUnder,
Expand All @@ -22,6 +23,7 @@ function rev(partial: Partial<Revision> & { id: string }): Revision {
parentRevs: [],
summary: null,
content: '',
archived: false,
...partial,
}
}
Expand Down Expand Up @@ -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([])
})
})
47 changes: 46 additions & 1 deletion src/domain/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -69,6 +80,7 @@ export function buildPages(
head,
revisions: sorted,
leaves,
archived: head.archived,
})
}

Expand Down Expand Up @@ -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<string, PageNode>()
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()) {
Expand Down Expand Up @@ -184,6 +205,30 @@ export function descendantSlugs(pages: Page[], slug: string): Set<string> {
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
Expand Down
1 change: 1 addition & 0 deletions src/domain/placement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function rev(partial: Partial<Revision> & { id: string }): Revision {
parentRevs: [],
summary: null,
content: '',
archived: false,
...partial,
}
}
Expand Down
20 changes: 20 additions & 0 deletions src/domain/revision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
9 changes: 9 additions & 0 deletions src/domain/revision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
}
}
Loading
Loading