diff --git a/README.md b/README.md
index a91a21c0..943ec881 100644
--- a/README.md
+++ b/README.md
@@ -46,8 +46,9 @@ to new users.
Volunteers are always welcome!
## Documentation
-- [Visual Language](https://solidos.github.io/solid-panes/Documentation/VisualLanguage.html)
-- [Conventions](./Documentation/conventions.md)
+- [Visual Language](https://solidos.github.io/solid-panes/docs/VisualLanguage.html)
+- [Conventions](./docs/conventions.md)
+- [Navigation design](./docs/navigation-design.md)
### Profile heading
@@ -65,11 +66,11 @@ shows the resource as usual with the navbar visible and nothing selected; choosi
in the navbar replaces that view with the whole storage, with the container or resource
from the URL opened inside it. Any other pane on a page inside someone else's pod
(for example their container) shows the compact heading of that pod's owner; it is
-hidden inside the viewer's own pod and for account-menu views. Pressing Enter on the current URL, or using the browser's
-back and forward buttons, re-renders the page without a reload: Enter shows the
-URL's own profile (and selects it in the navbar), while back and forward restore
-what each history entry displayed, including account-menu entries that keep a
-different profile in the URL. Solid-panes locally presents the heading data and checks
+hidden inside the viewer's own pod and for account-menu views. Pressing Enter on the current URL,
+or using the browser's back and forward buttons, re-renders the page without a reload, and
+like a reload shows what the URL is: the profile on a WebID (selected in the navbar), otherwise
+the container or resource itself, whatever the history entry last displayed through the navbar
+or the account menu. Solid-panes locally presents the heading data and checks
viewer edit access; the component owns the edit interaction and opens the local
heading editor. The page controller refreshes the heading after a successful save.
After a successful save the component also dispatches a bubbling, composed
diff --git a/docs/navigation-design.md b/docs/navigation-design.md
new file mode 100644
index 00000000..e28ff479
--- /dev/null
+++ b/docs/navigation-design.md
@@ -0,0 +1,318 @@
+# Navigation in the databrowser
+
+This document describes how navigation works in `solid-panes` as implemented: what decides what the
+page shows, where that logic lives, the rules it follows, and its known limits.
+
+## What decides what is shown
+
+What the user sees depends on several things at once:
+
+- the page URL
+- the pane that was last opened, and whether the account menu or the navbar opened it
+- who is logged in
+- whose pod the URL is in
+
+Each of these is read in one place (below), and the rules that combine them are pure functions, so
+the heading, the navbar, the outline and the file explorer header all apply the same decision.
+
+## Where the logic lives
+
+| Concern | Where |
+|---|---|
+| Reading and writing what a history entry displayed (`paneName`, `paneUri`, `viaAccountMenu`) | `navigation/state.ts`; the one write is in `GotoSubject` (`outline/manager.js`) |
+| Rules for the heading, navbar and file explorer header | `navigation/chrome.ts` (pure functions) |
+| Whose pod the page is in, the viewer's storages | `navigation/podContext.ts` (cached lookups) |
+| Displaying a view and the follow-up effects | `navigation/controller.ts`, wired to the real navbar and heading in `mainPage/navigation.ts` |
+| Telling the page that a view was displayed | `navigation/events.ts` (`solid-panes:navigated`, dispatched by `GotoSubject`) |
+| Who asked for a view (`GotoSubject`'s seventh argument) | `navigation/options.ts` |
+| Showing or hiding the navbar | `navigation/navbarVisibility.ts` |
+| Page load and reload | `initMainPage` (`mainPage/index.ts`) |
+| Layout and theme changes | `refreshUI` (`mainPage/index.ts`), called by mashlib |
+| Enter on the current URL, back and forward | `navigation/browserEvents.ts` |
+| Account menu | `mainPage/header.ts` (maps a choice to an intent) |
+| Navbar | `mainPage/navbar.ts` (builds items from the pod context, maps a choice to an intent) |
+| Profile heading | `mainPage/profileHeading.ts` (applies `deriveHeading` to the pod context) |
+| What is displayed for a pane | `GotoSubject` (displayed subject, open-inside-storage), `FileExplorerProvider` (applies the header rule) |
+| Other callers of `GotoSubject` | links in the outline, `solid-ui` buttons, `tabbedPane`, mashlib |
+
+## Paths into the page
+
+Every way of changing what is displayed ends in `GotoSubject`. Most of them go through the
+navigation controller, which always applies the same follow-up effects.
+
+| Path | Resolves the pane | Navbar | Heading |
+|---|---|---|---|
+| Page load (`initMainPage`) | controller (`restore`) | built once from the pod context | refreshed by the controller |
+| Account menu (`header.ts`) | controller | hidden | refreshed by the controller |
+| Navbar click (`navbar.ts`) | controller | selection kept by the navbar, then `selectNavbarPane` | refreshed by the controller |
+| Enter, back and forward | controller (`restore`, after clearing the saved pane) | `selectNavbarPane` | refreshed by the controller |
+| Layout or theme change | controller (`restore`, keeps the saved pane and the account-menu marker) | `selectNavbarPane`, or hidden | refreshed by the controller |
+| Links and buttons in `solid-ui`, `GotoSubject` calls from mashlib | none (default pane); not through the controller | selection updated within the same pod; rebuilt on a pod change, via the `navigated` event | refreshed via the `navigated` event |
+
+`GotoSubject` is called directly, with `solo` true, by `solid-ui` (`buttons.ts`, `forms.js`) and
+mashlib. Those callers rely on it to push the history entry and the URL, so it is the one place that
+writes history, and it announces what it did.
+
+## Rules
+
+### URL and history
+- The page URL is never changed by the account menu or the navbar. They display something else while
+ the URL keeps pointing at what the user opened.
+- A history entry records the displayed pane (`paneName`, `paneUri`) and whether the account menu
+ opened it (`viaAccountMenu`). Pane details are never copied into the entry of a different URL.
+
+### What loads
+- **Reload, Enter and back/forward all show what the URL is.** Whatever the entry last displayed
+ through the navbar or the account menu is discarded first.
+ - A WebID (ends in `#me`): the profile pane, with Profile selected in the navbar.
+ - Any other URL, including the document that holds a WebID such as `/timea2/profile/card`: the
+ resource or container as it is, with its file explorer header (unless it is a root resource) and
+ its content. Only `#me` URLs count as profile pages.
+- **Enter on the current URL** does this without reloading the page. It is only handled when the
+ browser has the Navigation API; otherwise Enter reloads the page, which is the same result for a
+ URL without a fragment.
+- **Layout or theme change** (`refreshUI`) re-renders the current entry in place, keeping the URL
+ and the account-menu marker.
+
+### Displayed subject
+- A pane with its own subject (profile, friends, or the storage of a person) is displayed for that
+ subject, not for the URL.
+- For a storage, the container or resource from the URL is opened inside it, level by level.
+
+### Navbar
+- Hidden for the logged-in viewer's own profile (WebID or its document) and URLs inside any of
+ their storages, including resources opened through storage links. Also hidden by the account
+ menu. Shown for other URLs, including when logged out.
+- Built from the pod the page is in. Profile and Friends are only offered when the owner of the pod
+ is known. When a navigation lands in another pod, the items are built again. Within the same pod,
+ navigation events update the selection from the displayed view without rebuilding the menu.
+- On a container or resource URL nothing is selected; on a WebID, Profile is.
+- Choosing an item displays it for the URL the page has at that moment, not the one it had when the
+ items were built.
+
+### Profile heading
+- Full on the profile pane, compact on friends and on other panes.
+- On other panes it shows the pod owner, unless the owner is the viewer, the view came from the
+ account menu, or the owner is unknown.
+- It reads the owner from the pod context, so it does not depend on the navbar existing.
+
+### File explorer header
+- Never shown for the profile and friends panes.
+- Never shown for root resources or WebIDs.
+- For account-menu views and for panes with their own subject: shown only when the URL is inside the
+ viewer's own pod.
+
+## Modules
+
+### Navigation state (`navigation/state.ts`)
+The only place that knows how a history entry records what was displayed.
+
+```ts
+type ViewKind = 'default' | 'profile' | 'friends' | 'storage' | 'pane'
+
+interface NavigationView {
+ kind: ViewKind
+ pane?: string // registry name of the pane; set unless the kind is 'default'
+ subject?: string // what the pane displays when that is not the page URL
+}
+
+interface NavigationState { view: NavigationView, viaAccountMenu: boolean }
+```
+
+- `readNavigationState()` and `parseNavigationState(raw)` read an entry.
+- `nextHistoryState(current, change)` is the rule for what to store after displaying something: pane
+ details never leak into another URL's entry, a subject does not outlive its pane, and the
+ account-menu marker is set or cleared.
+- `clearPaneState()` and `withoutPaneState()` forget what an entry displayed.
+- The stored shape is `paneName`, `paneUri`, `viaAccountMenu`. The kind is derived from the pane
+ name. There is no stored record of whether the URL or the navbar opened a view; the account-menu
+ marker is the only source any rule uses.
+
+### Chrome rules (`navigation/chrome.ts`)
+Pure functions that read no history, DOM or session:
+
+| Function | Decides | Used by |
+|---|---|---|
+| `deriveHeading` | whether the profile heading shows, full or compact, and for which profile; takes the pod `owner` and the pod's `storageUris` | `profileHeading.ts` |
+| `deriveNavbarSelection`, `isNavbarItemSelected`, `pickNavbarItem` | which navbar item is selected | `navbar.ts` |
+| `isNavbarHidden` (with `setNavbarHidden` in `navbarVisibility.ts`) | whether the navbar is shown | `navbar.ts`, `controller.ts`, `outline/manager.js` |
+| `deriveExplorerHeader`, `explorerHeaderForPane`, `isPersonPane` | whether the file explorer header shows | `outline/manager.js`, `FileExplorerProvider` |
+
+There is no single "derive everything" function: each consumer needs different inputs (the explorer
+header is decided per provider, the heading needs the pod), so the controller composes the effects.
+
+### Pod context (`navigation/podContext.ts`)
+Whose pod the page is in, loaded once and cached.
+
+```ts
+interface PodContext { viewer?: string, owner?: string, storagePanes: PaneItem[], viewerStorages: string[] }
+```
+
+- `getPodContext(url)` assembles it from the parts below. `isInViewerPod`, `isSamePod` and
+ `isPageInViewerPod` answer questions about a context.
+- The parts are cached separately because they depend on different things:
+ - the **owner**, per document: the profile lookup can end with that document's own
+ `foaf:primaryTopic`, so two pages of one pod need not share an owner;
+ - the **storages**, per pod (origin and first path segment);
+ - the **viewer's storages**, per viewer.
+- A failed lookup is not kept, so the next request asks again. At most 500 documents are remembered.
+- The owner is unknown when the lookup falls back to the page URL itself, so an unrelated resource
+ does not get a "pod owner" heading. Tests call `resetPodContexts()` between cases.
+
+### Event (`navigation/events.ts`)
+When `solo`, `GotoSubject` dispatches `solid-panes:navigated` on the document after writing the
+history entry, with the URL and the stored state. This is how calls that bypass the controller
+(`solid-ui`, mashlib) still reach the chrome: the navbar listens, to rebuild itself when a
+navigation lands in another pod, and the heading listens, to refresh itself. The heading does not
+watch the DOM.
+
+### Options (`navigation/options.ts`)
+`GotoSubject`'s seventh argument takes `{ source: 'accountMenu' | 'default' }`. The older boolean form
+(`false` for the account menu) is still accepted with the same meaning.
+
+### Controller (`navigation/controller.ts`)
+`navigation.show(intent)` is the one way to display a view and bring the chrome in line with it:
+
+```ts
+type NavigationIntent =
+ | { kind: 'restore', subject?, forceHeading? } // what the URL (or current entry) displays
+ | { kind: 'profile' | 'friends', subject, source? } // a person, by WebID
+ | { kind: 'storage', pane, source? }
+ | { kind: 'app', pane, source? } // by registry name: home, basicPreferences
+```
+
+- It resolves the pane, renders through `GotoSubject`, then sets navbar visibility and selection and
+ refreshes the heading, in that order.
+- For an account-menu source it hides the navbar before resolving the pane.
+- A newer intent wins over one still resolving its pane. This latest-wins token covers the
+ controller's renders only; the heading's own request counter and the manager's open-inside-storage
+ guard protect different async work, which callers that bypass the controller also use.
+- If a pane cannot be built for anything but `restore`, it warns and displays nothing, rather than
+ falling back to the page URL's default view.
+- `restore` without a pane shows the profile on a WebID, and the default view elsewhere.
+- `mainPage/navigation.ts` wires the controller to the real navbar and heading, one per outliner.
+ The controller is internal; it is not exported from the package.
+
+`header.ts`, `navbar.ts`, `initMainPage` and `refreshUI` are thin callers that map a choice to an
+intent. `createHeader` and `createNavbar` take the controller. Header and navbar clicks pass no table
+to `GotoSubject`, so the outline's current container is used. The account menu does nothing at all
+when nobody is logged in.
+
+### Browser events (`navigation/browserEvents.ts`)
+`registerBrowserNavigation(controller)` handles two things; both clear the saved pane and call
+`show({ kind: 'restore', forceHeading: true })`.
+
+- **Enter on the current URL.** The Navigation API `navigate` event is intercepted when
+ `canIntercept`, `userInitiated`, `navigationType === 'replace'` and the destination is the
+ current URL.
+- **Back and forward.** `currententrychange` with `navigationType === 'traverse'`, or `popstate` in
+ browsers without the Navigation API.
+
+The listeners are added once; a later call only changes which controller they use.
+`isHandlingBrowserNavigation()` (exported from the package) is true once they are registered.
+mashlib's `window.onpopstate` returns early when it is true, so only the main page reacts to back
+and forward. mashlib keeps its handler for pages that load it without running the main page, and
+reads the flag when the event fires, because it registers the handler at load, before the main page
+starts. mashlib pins `solid-panes` to an exact version, so the two release together; a mashlib built
+against a `solid-panes` without this export must not be paired with this version.
+
+### Profile heading placement
+The navbar is inserted at the top of the page after the heading's mount point exists, so
+`initMainPage` calls `placeProfileHeading()` once the navbar is created, which keeps the heading
+above it.
+
+## Tests
+
+- `test/unit/navigation/`: `state`, `chrome`, `podContext`, `controller`, `events`, `options` and
+ `browserEvents` tests, one per module.
+- `test/unit/mainPage/`: `header`, `navbar`, `index` and `profileHeading` tests against the
+ controller and pod context. `reload.test.ts` wires the real navbar, heading and outline manager
+ together, including a direct `GotoSubject` call into another pod (as `solid-ui` makes) and a
+ back navigation to the first pod.
+- `test/unit/outline/manager.test.ts` covers the header rule; `test/unit/components/fileExplorerProvider.test.ts`
+ covers the provider.
+
+The tests run in jsdom with the network mocked, and the setup answers every fetch with an empty
+Turtle response, so tests that render real file explorer headers need a partial `podUtils` mock.
+
+## Browser support
+
+The controller relies on the Navigation API for two things: handling Enter on the current URL, and
+telling back/forward apart from in-page `#` links. Everything else works without it.
+
+### Navigation API support
+
+Source: MDN browser-compat-data (last updated 2026-01-14). Every member we use (`navigate`,
+`currententrychange`, `userInitiated`, `canIntercept`, `navigationType`, `intercept`) is supported
+from the same version.
+
+| Browser | Supported from |
+|---|---|
+| Chrome, Edge, Opera, Samsung Internet, Android WebView | Chrome 102 (`intercept()` and `canIntercept` from 105) |
+| Firefox | 147 |
+| Safari (macOS and iOS) | 26.2 |
+
+Every iPhone and iPad browser uses Safari's engine, so it follows the Safari row.
+
+### What our build targets
+
+| Layer | Setting | Effect |
+|---|---|---|
+| TypeScript (`tsconfig.json`, also in mashlib) | `target: ES2019`, `lib: ["DOM", "ES2019"]` | Type checking only; both packages run `tsc --noEmit`. The `lib` setting makes newer built-ins fail the type check. |
+| `solid-panes` build (`vite.config.mts`) | no explicit target, so Vite 8's default `baseline-widely-available` | Syntax lowered to Chrome and Edge 111, Firefox 114, Safari and iOS 16.4 |
+| solidos-toolkit's Babel step | `preset-env` only when `transpileTargets` is passed; we don't pass it | Only TypeScript, decorators and class fields are transformed |
+| mashlib (`babel.config.mjs`) | `> 1%, last 3 versions, not dead` | Syntax lowered for the final bundle |
+
+Nothing in this setup polyfills runtime APIs (no `core-js`, no `useBuiltIns`). The Navigation API
+is therefore always feature-detected, and TypeScript does not know `window.navigation`, so the code
+casts it.
+
+mashlib's webpack config skips Babel for `node_modules` except `solid-panes/src`. A published
+`solid-panes` package is not re-transpiled for mashlib's list and keeps Vite's baseline floor. In
+the workspace, the symlinked source most likely does go through Babel.
+
+### Gap between targets and the Navigation API
+
+Browsers inside our build targets that lack the Navigation API:
+
+- Safari and iOS 16.4 to 26.1, including iOS 18.5 to 18.7, which is in mashlib's resolved set.
+- Firefox 114 to 146, including the ESR line.
+- From mashlib's resolved set: Opera Mini, Opera Mobile 80 and KaiOS. UC and QQ are probably in the
+ same position, but their engine versions have not been confirmed.
+
+The gap includes browsers a few months old, so it is not an edge case. The `popstate` fallback has
+its own test.
+
+### Behaviour without the Navigation API
+
+| Behaviour | With | Without |
+|---|---|---|
+| Back and forward | `currententrychange` with `traverse`, not fired for `#` links | `popstate`, which may also fire for in-page `#` links and re-render the page |
+| Enter on the current URL | Handled: shows the URL's own view | Not handled: content opened through the account menu stays in place |
+| Reload | Handled on page load | Handled on page load |
+
+## Known limits
+
+- **Back and forward render in the event itself.** Nothing else is expected to re-render on
+ `popstate`. A package that does would race with the main page.
+- **Calls that bypass the controller** (`solid-ui` links and buttons, mashlib's own `GotoSubject`
+ calls) reach the navbar and heading through the `navigated` event. They are not recorded as an
+ intent. The event is only dispatched when `solo`, after the history entry is written, so a call
+ that fails before that point announces nothing.
+- **The heading does not watch the DOM.** A change to what it should show that happens without a
+ `GotoSubject` call, a login or logout, or a profile save is missed. Today the only such change is
+ the main page's own `clearPaneState()`, which the controller always follows.
+- **Pod discovery is heuristic.** The owner comes from `loadProfileFromURI`, which assumes
+ `profile/card#me` at the origin or under the first path segment, and falls back to the document's
+ `foaf:primaryTopic`. The pod for the storages is keyed on the origin and first path segment, so a
+ storage nested deeper, or two storages under one first segment, share one lookup.
+- **Pods without a `pim:Storage` triple in the profile** cannot be recognised as "the viewer's pod",
+ so the file explorer header stays hidden there. The pod context could fall back to a path derived
+ from the WebID.
+- **Not covered by automated tests:** a real browser, the Navigation API itself, and mashlib's own
+ test suite. Worth checking by hand: a refresh on a WebID and on a container, the account menu on
+ someone else's container, back and forward across entries opened through the navbar or the account
+ menu, and a `solid-ui` link into another pod (the navbar and heading should follow).
+- **A stale `dist/` looks like a bug.** mashlib consumes `solid-panes/dist` through a symlink; if
+ `vite build --watch` stalls, the browser runs old code. The watcher also skips type declarations,
+ so mashlib's `tsc` needs a full `npm run build` in `solid-panes` first.
diff --git a/package-lock.json b/package-lock.json
index 19c84df9..e7611769 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3043,9 +3043,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.153.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
- "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
+ "version": "0.154.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
+ "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
@@ -3103,9 +3103,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
- "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
+ "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
"cpu": [
"arm"
],
@@ -3119,9 +3119,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
- "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
+ "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
"cpu": [
"arm64"
],
@@ -3135,9 +3135,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
- "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
+ "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
"cpu": [
"arm64"
],
@@ -3151,9 +3151,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
- "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
+ "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
"cpu": [
"x64"
],
@@ -3167,9 +3167,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
- "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
+ "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
"cpu": [
"x64"
],
@@ -3183,9 +3183,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
- "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
+ "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
"cpu": [
"arm"
],
@@ -3199,9 +3199,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
- "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
+ "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
"cpu": [
"arm64"
],
@@ -3218,9 +3218,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
- "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
+ "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
"cpu": [
"arm64"
],
@@ -3237,9 +3237,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
- "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
+ "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
"cpu": [
"ppc64"
],
@@ -3256,9 +3256,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
- "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
+ "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
"cpu": [
"s390x"
],
@@ -3275,9 +3275,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
- "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
+ "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
"cpu": [
"x64"
],
@@ -3294,9 +3294,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
- "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
+ "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
"cpu": [
"x64"
],
@@ -3313,9 +3313,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
- "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
+ "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
"cpu": [
"arm64"
],
@@ -3329,9 +3329,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
- "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
+ "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
"cpu": [
"arm64"
],
@@ -3345,9 +3345,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
- "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
+ "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
"cpu": [
"x64"
],
@@ -9258,12 +9258,12 @@
}
},
"node_modules/rolldown": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
- "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
+ "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.153.0",
+ "@oxc-project/types": "=0.154.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -9273,21 +9273,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm-eabi": "1.2.13",
- "@rolldown/binding-android-arm64": "1.2.13",
- "@rolldown/binding-darwin-arm64": "1.2.13",
- "@rolldown/binding-darwin-x64": "1.2.13",
- "@rolldown/binding-freebsd-x64": "1.2.13",
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
- "@rolldown/binding-linux-arm64-gnu": "1.2.13",
- "@rolldown/binding-linux-arm64-musl": "1.2.13",
- "@rolldown/binding-linux-ppc64-gnu": "1.2.13",
- "@rolldown/binding-linux-s390x-gnu": "1.2.13",
- "@rolldown/binding-linux-x64-gnu": "1.2.13",
- "@rolldown/binding-linux-x64-musl": "1.2.13",
- "@rolldown/binding-openharmony-arm64": "1.2.13",
- "@rolldown/binding-win32-arm64-msvc": "1.2.13",
- "@rolldown/binding-win32-x64-msvc": "1.2.13"
+ "@rolldown/binding-android-arm-eabi": "1.2.14",
+ "@rolldown/binding-android-arm64": "1.2.14",
+ "@rolldown/binding-darwin-arm64": "1.2.14",
+ "@rolldown/binding-darwin-x64": "1.2.14",
+ "@rolldown/binding-freebsd-x64": "1.2.14",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.14",
+ "@rolldown/binding-linux-arm64-musl": "1.2.14",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.14",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.14",
+ "@rolldown/binding-linux-x64-gnu": "1.2.14",
+ "@rolldown/binding-linux-x64-musl": "1.2.14",
+ "@rolldown/binding-openharmony-arm64": "1.2.14",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.14",
+ "@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
"node_modules/safe-array-concat": {
diff --git a/src/components/file-explorer-header/FileExplorerProvider.ts b/src/components/file-explorer-header/FileExplorerProvider.ts
index 381d485e..2b46dc12 100644
--- a/src/components/file-explorer-header/FileExplorerProvider.ts
+++ b/src/components/file-explorer-header/FileExplorerProvider.ts
@@ -10,10 +10,10 @@ import styles from './FileExplorerProvider.styles.css'
import personIcon from '../../icons/person.svg'
import friendsIcon from '../../icons/friends.svg'
import '~icons/lucide/share-2'
+import { explorerHeaderForPane } from '../../navigation/chrome'
const PERSON_ICON = personIcon
const FRIENDS_ICON = friendsIcon
-const PERSON_PANE_NAMES = ['profile', 'social']
function createFileExplorerContextValue (value: {
store: LiveStore | undefined
@@ -242,7 +242,7 @@ export default class FileExplorerProvider extends WebComponent {
const store = this.context?.session.store as LiveStore
const subject = store.sym(this.subjectUri as string)
// Profile and friends panes present a person, not the resource they are opened on.
- const showHeader = this.showHeader && !PERSON_PANE_NAMES.includes(this.pane?.name ?? '')
+ const showHeader = explorerHeaderForPane(this.showHeader, this.pane?.name)
return html`
${showHeader
diff --git a/src/components/navbar/Navbar.ts b/src/components/navbar/Navbar.ts
index 422d72d0..fd0108e4 100644
--- a/src/components/navbar/Navbar.ts
+++ b/src/components/navbar/Navbar.ts
@@ -16,7 +16,6 @@ export interface NavbarMenuItem {
selected?: boolean
paneName?: string
paneUri?: string
- profileSubjectUri?: string
onSelected?(): void | Promise
}
diff --git a/src/index.ts b/src/index.ts
index 24f2f449..78f65756 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -26,6 +26,7 @@ import {
} from 'pane-registry'
import { createContext } from './outline/context'
import { initMainPage, refreshUI } from './mainPage'
+import { isHandlingBrowserNavigation } from './navigation/browserEvents'
function getOutliner (dom, environment?: RenderEnvironment): OutlineManager {
if (!dom.outlineManager) {
@@ -69,6 +70,7 @@ export {
versionInfo,
initMainPage,
refreshUI,
+ isHandlingBrowserNavigation,
list, // from paneRegistry
paneForIcon, // from paneRegistry
paneForPredicate, // from paneRegistry
diff --git a/src/mainPage/header.ts b/src/mainPage/header.ts
index 0ef0b98c..126fb4d5 100644
--- a/src/mainPage/header.ts
+++ b/src/mainPage/header.ts
@@ -1,7 +1,6 @@
-import { authn } from 'solid-logic'
import { html, render } from 'lit-html'
import type { AccountMenuItem } from 'solid-ui/components/account'
-import type { OutlineManager } from '../outline/manager'
+import type { NavigationController } from '../navigation/controller'
import '~icons/lucide/user'
import '~icons/lucide/users'
@@ -9,86 +8,61 @@ import '~icons/lucide/folder-open'
import '~icons/lucide/layout-dashboard'
import '~icons/lucide/settings-2'
import '../components/header'
-import { getProfilePaneFromURI, getSocialPaneFromURI, getFolderPanesFromURI } from '../utils/paneUtils'
-import { sym } from 'rdflib'
+import { getFolderPanesFromURI } from '../utils/paneUtils'
import { getCurrentViewer } from '../utils/viewerUtils'
-export async function createHeader (outliner: OutlineManager) {
+export async function createHeader (navigation: NavigationController) {
const existingHeader = document.querySelector('solid-panes-header')
if (existingHeader) {
return existingHeader
}
- const me = authn.currentUser()
-
+ const viewer = await getCurrentViewer()
+ const storagePanes = viewer ? await getFolderPanesFromURI(viewer) : []
const main = document.getElementById('MainContent')
- const outlineView = document.getElementById('OutlineView')
const tmpContainer = document.createElement('div')
-
- function hideNavbar () {
- const navbar = document.querySelector('solid-panes-navbar')
- if (navbar) {
- navbar.classList.add('navbar--hidden')
- }
- }
-
- function showPane (pane: Awaited>) {
- // Without a pane the outline would fall back to the page URL's own default view.
- if (!pane) {
- console.warn('Could not open the requested view for the logged-in user.')
- return
- }
- outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false)
- }
-
- const storagePanes = me ? await getFolderPanesFromURI(me) : []
const storageMenuItems: AccountMenuItem[] = storagePanes.map(pane => ({
label: html` ${pane.label()}`,
async onSelected () {
- hideNavbar()
- if (await getCurrentViewer()) showPane(pane)
+ if (await getCurrentViewer()) {
+ await navigation.show({ kind: 'storage', pane, source: 'accountMenu' })
+ }
}
}))
const menuItems: AccountMenuItem[] = [
{
label: html` Profile`,
async onSelected () {
- hideNavbar()
- const viewer = await getCurrentViewer()
- if (viewer) showPane(await getProfilePaneFromURI(viewer))
+ const currentViewer = await getCurrentViewer()
+ if (currentViewer) {
+ await navigation.show({ kind: 'profile', subject: currentViewer.value, source: 'accountMenu' })
+ }
}
},
{
label: html` Friends`,
async onSelected () {
- hideNavbar()
- const viewer = await getCurrentViewer()
- if (viewer) showPane(await getSocialPaneFromURI(viewer))
+ const currentViewer = await getCurrentViewer()
+ if (currentViewer) {
+ await navigation.show({ kind: 'friends', subject: currentViewer.value, source: 'accountMenu' })
+ }
}
},
...storageMenuItems,
{
label: html` Dashboard`,
async onSelected () {
- hideNavbar()
if (await getCurrentViewer()) {
- const pane = outliner.context.session.paneRegistry.byName('home')
- if (pane) {
- outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false)
- }
+ await navigation.show({ kind: 'app', pane: 'home', source: 'accountMenu' })
}
}
},
{
label: html` Preferences`,
async onSelected () {
- hideNavbar()
if (await getCurrentViewer()) {
- const pane = outliner.context.session.paneRegistry.byName('basicPreferences')
- if (pane) {
- outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false)
- }
+ await navigation.show({ kind: 'app', pane: 'basicPreferences', source: 'accountMenu' })
}
}
},
diff --git a/src/mainPage/index.ts b/src/mainPage/index.ts
index a28f4711..a7d2b08b 100644
--- a/src/mainPage/index.ts
+++ b/src/mainPage/index.ts
@@ -5,13 +5,13 @@
import { LiveStore, NamedNode } from 'rdflib'
import type { RenderEnvironment } from 'pane-registry'
-import * as paneRegistry from 'pane-registry'
import { getOutliner, OutlineManager } from '../index'
import { createHeader } from './header'
-import { createNavbar, selectNavbarPane } from './navbar'
-import { getFolderPaneForStorage, getProfilePaneFromURI, getSocialPaneFromURI } from '../utils/paneUtils'
-import { isWebIdUri } from '../utils/webIdUtils'
-import { initializeProfileHeading, refreshProfileHeading } from './profileHeading'
+import { createNavbar } from './navbar'
+import { navigationFor } from './navigation'
+import { initializeProfileHeading, placeProfileHeading } from './profileHeading'
+import { registerBrowserNavigation } from '../navigation/browserEvents'
+import { clearPaneState } from '../navigation/state'
// Symbol used to stash the last render-relevant env snapshot on the outliner
// so refreshUI can skip a full GotoSubject re-render when nothing changed.
@@ -35,128 +35,6 @@ function ensureMainContent () {
return main
}
-function clearPaneState () {
- const { paneName, paneUri, viaAccountMenu, ...rest } = window.history.state ?? {}
- window.history.replaceState(rest, '', window.location.href)
-}
-
-// A reload keeps history.state; when it came from the account menu it
-// describes the viewer's own content, not the URL being loaded.
-function discardAccountMenuState () {
- if (window.history.state?.viaAccountMenu) clearPaneState()
-}
-
-interface NavigationEvent extends Event {
- canIntercept: boolean
- userInitiated: boolean
- navigationType: string
- destination: { url: string }
- intercept: (options: { handler: () => Promise, scroll: 'manual', focusReset: 'manual' }) => void
-}
-
-interface NavigationApi {
- addEventListener: (type: 'navigate' | 'currententrychange', listener: (event: NavigationEvent) => void) => void
-}
-
-let browserNavigationOutliner: { store: LiveStore, outliner: OutlineManager } | undefined
-let browserNavigationListenersRegistered = false
-let historyEntryRequest = 0
-
-type HistoryState = { paneName?: string, paneUri?: string, viaAccountMenu?: boolean } | null
-async function paneForHistoryEntry (store: LiveStore, subject: NamedNode, state: HistoryState) {
- const { paneName, paneUri } = state ?? {}
- // These panes render a subject other than the page URL, saved as paneUri.
- if (paneName && typeof paneUri === 'string' && paneUri) {
- const paneSubject = store.sym(paneUri)
- if (paneName === 'profile') return getProfilePaneFromURI(paneSubject)
- if (paneName === 'social') return getSocialPaneFromURI(paneSubject)
- if (paneName === 'folder') return getFolderPaneForStorage(paneSubject)
- }
- if (paneName) return paneRegistry.byName(paneName) ?? undefined
- return isWebIdUri(subject) ? getProfilePaneFromURI(subject) : undefined
-}
-
-// Renders the pane described by a history entry's state for the current URL,
-// without changing the URL, and brings the navbar and heading in line with it.
-async function showCurrentHistoryEntry (
- store: LiveStore,
- outliner: OutlineManager,
- forceHeading = false,
- state: HistoryState = window.history.state
-) {
- const request = ++historyEntryRequest
- const { viaAccountMenu } = state ?? {}
- const subject = store.sym(window.location.href)
- const pane = await paneForHistoryEntry(store, subject, state)
- if (request !== historyEntryRequest) return
-
- outliner.GotoSubject(subject, true, pane ?? undefined, true, undefined, undefined, !viaAccountMenu)
- const navbar = document.querySelector('solid-panes-navbar')
- if (viaAccountMenu) {
- navbar?.classList.add('navbar--hidden')
- } else {
- const { paneName, paneUri } = window.history.state ?? {}
- if (paneName) selectNavbarPane(paneName, paneUri)
- }
- await refreshProfileHeading(forceHeading)
-}
-
-function reportBrowserNavigationError (error: unknown) {
- console.error('Failed to show the page after browser navigation.', error)
-}
-
-// The browser changes the URL and history entry without reloading the page in two
-// cases the page would otherwise not notice:
-// - Enter on the current URL (typically one ending in #me), which must show the
-// URL's own profile rather than what the account menu displayed.
-// - Back and forward, which must show what each history entry displayed.
-function registerBrowserNavigationListeners (store: LiveStore, outliner: OutlineManager) {
- browserNavigationOutliner = { store, outliner }
- if (browserNavigationListenersRegistered) return
- browserNavigationListenersRegistered = true
-
- const showEntry = (forceHeading: boolean, state?: HistoryState) => {
- if (!browserNavigationOutliner) return Promise.resolve()
- return showCurrentHistoryEntry(
- browserNavigationOutliner.store, browserNavigationOutliner.outliner, forceHeading, state
- ).catch(reportBrowserNavigationError)
- }
-
- // mashlib re-renders the default pane in its own popstate handler, which also
- // drops the account-menu marker from the entry's state. Keep the state as it
- // was when the browser navigated and render once those handlers have finished,
- // so the restored entry wins.
- const showEntryAfterHandlers = () => {
- const state = window.history.state
- setTimeout(() => { showEntry(false, state) })
- }
-
- const navigation = (window as unknown as { navigation?: NavigationApi }).navigation
- if (!navigation) {
- window.addEventListener('popstate', showEntryAfterHandlers)
- return
- }
-
- navigation.addEventListener('navigate', event => {
- if (!event.canIntercept || !event.userInitiated || event.navigationType !== 'replace' ||
- event.destination.url !== window.location.href) return
-
- event.intercept({
- scroll: 'manual',
- focusReset: 'manual',
- handler: () => {
- clearPaneState()
- return showEntry(true)
- }
- })
- })
-
- // Unlike popstate, this does not fire for in-page fragment links.
- navigation.addEventListener('currententrychange', event => {
- if (event.navigationType === 'traverse') showEntryAfterHandlers()
- })
-}
-
export async function initMainPage (
store: LiveStore,
uri?: string | NamedNode | null,
@@ -165,22 +43,22 @@ export async function initMainPage (
ensureMainContent()
uri = uri || window.location.href
const subject: NamedNode = typeof uri === 'string' ? store.sym(uri) : uri
- // A reload of a WebID always shows its profile, whatever pane the history entry
- // last displayed. Elsewhere, only content the account menu showed in place of
- // the URL's own is dropped.
- if (isWebIdUri(subject)) clearPaneState()
- else discardAccountMenuState()
+ // A reload shows what the URL is, whatever the history entry last displayed through the navbar
+ // or the account menu: the profile on a profile page, otherwise the resource or container itself.
+ // This is also what Enter on the current URL ends in where the Navigation API is missing (see
+ // NAVIGATION API FALLBACK in navigation/browserEvents.ts): the browser reloads the page.
+ clearPaneState()
initializeProfileHeading()
const outliner = getOutliner(document, environment)
;(outliner as any)[LAST_RENDER_ENV_KEY] = renderEnvSignature(environment)
- registerBrowserNavigationListeners(store, outliner)
- const initialPane = await paneForHistoryEntry(store, subject, window.history.state)
-
- outliner.GotoSubject(subject, true, initialPane, true, undefined, undefined, true, false)
- await refreshProfileHeading()
-
- const header = await createHeader(outliner)
- const navbar = await createNavbar(outliner)
+ const navigation = navigationFor(outliner, store)
+ registerBrowserNavigation(navigation)
+ await navigation.show({ kind: 'restore', subject: subject.uri })
+
+ const header = await createHeader(navigation)
+ const navbar = await createNavbar(navigation)
+ // The navbar went in at the top of the page; the heading belongs above it
+ placeProfileHeading()
return Promise.all([header, navbar])
}
@@ -196,7 +74,7 @@ export async function refreshUI (outliner: OutlineManager) {
if (envChanged && store && typeof outliner?.GotoSubject === 'function') {
// Re-render what the current history entry displays without changing the
// URL or losing that it came from the account menu.
- await showCurrentHistoryEntry(store, outliner)
+ await navigationFor(outliner, store).show({ kind: 'restore' })
;(outliner as any)[LAST_RENDER_ENV_KEY] = currentSignature
}
}
diff --git a/src/mainPage/navbar.ts b/src/mainPage/navbar.ts
index 35b8ee8d..f06af6ea 100644
--- a/src/mainPage/navbar.ts
+++ b/src/mainPage/navbar.ts
@@ -1,9 +1,12 @@
import { store } from 'solid-logic'
-import { NamedNode } from 'rdflib'
-import { getSocialPaneFromURI, getProfilePaneFromURI, getFolderPanesFromURI } from '../utils/paneUtils'
import { html, render } from 'lit-html'
-import type { OutlineManager } from '../outline/manager'
-import { isOwnWebIdProfile, isWebIdUri, loadProfileFromURI } from '../utils/webIdUtils'
+import type { NavigationController } from '../navigation/controller'
+import { isWebIdUri } from '../utils/webIdUtils'
+import { deriveNavbarSelection, isNavbarItemSelected, pickNavbarItem } from '../navigation/chrome'
+import { onNavigated } from '../navigation/events'
+import { readNavigationState, type NavigationView } from '../navigation/state'
+import { getPodContext, isSamePod, type PodContext } from '../navigation/podContext'
+import { refreshNavbarVisibility } from '../navigation/navbarVisibility'
import '~icons/lucide/user'
import '~icons/lucide/users'
@@ -13,10 +16,7 @@ import '../components/navbar'
import type { NavbarMenuItem } from '../components/navbar/Navbar'
function showNavbar () {
- const navbar = document.querySelector('solid-panes-navbar')
- if (navbar) {
- navbar.classList.toggle('navbar--hidden', isOwnWebIdProfile(store.sym(window.location.href)))
- }
+ return refreshNavbarVisibility(window.location.href, readNavigationState().viaAccountMenu)
}
let selectPaneInNavbar: ((paneName: string, paneUri?: string) => void) | undefined
@@ -28,7 +28,6 @@ export function selectNavbarPane (paneName: string, paneUri?: string): void {
function createNavItem (
label: string,
paneName: string,
- profileSubjectUri: string,
paneUri: string,
onSelected: () => void,
selected = false
@@ -37,53 +36,47 @@ function createNavItem (
label,
paneName,
paneUri,
- profileSubjectUri,
- onSelected () {
- showNavbar()
+ async onSelected () {
+ await showNavbar()
return onSelected()
},
selected
}
}
-async function createNavbarMenuItems (
- outliner: OutlineManager,
- subject: NamedNode,
- outlineView: HTMLElement | null,
- selectedPaneName?: string,
- selectedPaneUri?: string
-): Promise {
- const webId = await loadProfileFromURI(subject)
- const selectedPane = selectedPaneName || (isWebIdUri(subject) ? 'profile' : undefined)
+// The items offered for the pod the page is in. Choosing one displays that pane while the
+// page URL stays as it is.
+function createNavbarMenuItems (
+ context: PodContext,
+ navigation: NavigationController,
+ view: NavigationView
+): NavbarMenuItem[] {
+ const selection = deriveNavbarSelection(view, isWebIdUri(store.sym(window.location.href)))
const menuItems: NavbarMenuItem[] = []
- if (webId) {
+ if (context.owner) {
+ const owner = context.owner
menuItems.push(
- createNavItem('Profile', 'profile', webId.value, webId.value, async () => {
- const profilePane = await getProfilePaneFromURI(webId)
- outliner.GotoSubject(subject, true, profilePane, true, undefined, outlineView)
- }, selectedPane === 'profile'),
- createNavItem('Friends', 'social', webId.value, webId.value, async () => {
- const socialPane = await getSocialPaneFromURI(webId)
- outliner.GotoSubject(subject, true, socialPane, true, undefined, outlineView)
- }, selectedPane === 'social')
+ createNavItem('Profile', 'profile', owner, () => navigation.show({ kind: 'profile', subject: owner }),
+ isNavbarItemSelected({ paneName: 'profile' }, selection, 0)),
+ createNavItem('Friends', 'social', owner, () => navigation.show({ kind: 'friends', subject: owner }),
+ isNavbarItemSelected({ paneName: 'social' }, selection, 0))
)
}
- const storagePanes = await getFolderPanesFromURI(subject)
- storagePanes.forEach(pane => {
+ context.storagePanes.forEach(pane => {
menuItems.push(
createNavItem(
pane.label(),
pane.paneName,
- webId?.value ?? '',
pane.subject.value,
- async () => {
- outliner.GotoSubject(subject, true, pane, true, undefined, outlineView)
- },
- selectedPane === pane.paneName &&
- (selectedPaneUri ? selectedPaneUri === pane.subject.value : storagePanes.length === 1)
+ () => navigation.show({ kind: 'storage', pane }),
+ isNavbarItemSelected(
+ { paneName: pane.paneName, paneUri: pane.subject.value },
+ selection,
+ context.storagePanes.length
+ )
)
)
})
@@ -91,25 +84,18 @@ async function createNavbarMenuItems (
return menuItems
}
-export async function createNavbar (outliner: OutlineManager) {
+export async function createNavbar (navigation: NavigationController) {
const existingNavbar = document.querySelector('solid-panes-navbar')
if (existingNavbar) {
- showNavbar()
+ await showNavbar()
return existingNavbar
}
- const OutlineView = document.getElementById('OutlineView')
const mainContent = document.getElementById('MainContent')
const tmpContainer = document.createElement('div')
- const uri = window.location.href
- const subject: NamedNode = typeof uri === 'string' ? store.sym(uri) : uri
- const selectedPaneName = window.history.state?.paneName
- const selectedPaneUri = window.history.state?.paneUri
- let menuItems = await createNavbarMenuItems(outliner, subject, OutlineView, selectedPaneName, selectedPaneUri).catch((err) => {
- console.error('Failed to build navbar menu items:', err)
- return []
- })
+ let podContext = await getPodContext(window.location.href)
+ let menuItems = createNavbarMenuItems(podContext, navigation, readNavigationState().view)
function setSelectedItem (selectedItem: NavbarMenuItem) {
menuItems = menuItems.map((menuItem) => ({
@@ -139,8 +125,7 @@ export async function createNavbar (outliner: OutlineManager) {
renderNavbar()
selectPaneInNavbar = (paneName, paneUri) => {
- const panes = menuItems.filter(menuItem => menuItem.paneName === paneName)
- const item = panes.find(menuItem => menuItem.paneUri === paneUri) ?? panes[0]
+ const item = pickNavbarItem(menuItems, paneName, paneUri)
if (!item) return
setSelectedItem(item)
renderNavbar()
@@ -158,7 +143,37 @@ export async function createNavbar (outliner: OutlineManager) {
document.body.prepend(navbar)
}
- showNavbar()
+ await showNavbar()
+
+ // The items belong to one pod. When a navigation (a link, back or forward) lands in another
+ // pod, they would offer the wrong profile and storages, so build them again.
+ let rebuildRequest = 0
+ const stopListening = onNavigated(({ url, state }) => {
+ if (!navbar.isConnected) return stopListening()
+
+ refreshNavbarVisibility(url, state.viaAccountMenu)
+ .catch(error => console.error('Failed to update navbar visibility:', error))
+
+ const request = ++rebuildRequest
+ getPodContext(url).then(next => {
+ if (request !== rebuildRequest) return
+ if (isSamePod(podContext, next)) {
+ const selection = deriveNavbarSelection(state.view, isWebIdUri(store.sym(url)))
+ const selectedItems = menuItems.map(menuItem => ({
+ ...menuItem,
+ selected: isNavbarItemSelected(menuItem, selection, podContext.storagePanes.length)
+ }))
+ if (selectedItems.some((item, index) => item.selected !== menuItems[index].selected)) {
+ menuItems = selectedItems
+ renderNavbar()
+ }
+ return
+ }
+ podContext = next
+ menuItems = createNavbarMenuItems(next, navigation, state.view)
+ renderNavbar()
+ }).catch(error => console.error('Failed to rebuild the navbar for the new pod:', error))
+ })
return navbar
}
diff --git a/src/mainPage/navigation.ts b/src/mainPage/navigation.ts
new file mode 100644
index 00000000..576b5bdd
--- /dev/null
+++ b/src/mainPage/navigation.ts
@@ -0,0 +1,23 @@
+import type { LiveStore } from 'rdflib'
+import * as paneRegistry from 'pane-registry'
+import type { OutlineManager } from '../outline/manager'
+import { createNavigationController, type NavigationController } from '../navigation/controller'
+import { selectNavbarPane } from './navbar'
+import { refreshProfileHeading } from './profileHeading'
+
+const controllers = new WeakMap()
+
+/** The page's navigation controller for an outliner, created on first use. */
+export function navigationFor (outliner: OutlineManager, store: LiveStore): NavigationController {
+ let controller = controllers.get(outliner)
+ if (!controller) {
+ controller = createNavigationController({
+ store,
+ outliner,
+ byName: paneRegistry.byName,
+ effects: { selectNavbarPane, refreshHeading: refreshProfileHeading }
+ })
+ controllers.set(outliner, controller)
+ }
+ return controller
+}
diff --git a/src/mainPage/profileHeading.ts b/src/mainPage/profileHeading.ts
index c8d70bff..084f8fff 100644
--- a/src/mainPage/profileHeading.ts
+++ b/src/mainPage/profileHeading.ts
@@ -7,15 +7,16 @@ import type { ViewerMode } from '../types'
import { loadProfileFromURI } from '../utils/webIdUtils'
import '../components/profile-heading'
import './profileHeading.css'
-import type { NavbarMenuItem } from '../components/navbar/Navbar'
+import { deriveHeading } from '../navigation/chrome'
+import { getPodContext } from '../navigation/podContext'
+import { onNavigated } from '../navigation/events'
+import { readNavigationState } from '../navigation/state'
const PROFILE_HEADING_TAG = 'solid-panes-profile-heading'
const PROFILE_HEADING_MOUNT_ID = 'profile-heading-mount'
-const PROFILE_PANE_NAMES = new Set(['profile', 'social'])
-let observer: MutationObserver | undefined
let refreshRequest = 0
-let popstateListenerRegistered = false
+let navigatedListenerRegistered = false
let authListenersRegistered = false
let profileSavedListenerRegistered = false
let authReady: Promise | undefined
@@ -149,8 +150,7 @@ function ensureMountPoint (): HTMLElement | null {
export async function refreshProfileHeading (force = false): Promise {
await authReady
- const paneName = window.history.state?.paneName
- const paneUri = window.history.state?.paneUri
+ const pod = await getPodContext(window.location.href)
const mount = ensureMountPoint()
if (!mount) return
@@ -160,37 +160,29 @@ export async function refreshProfileHeading (force = false): Promise {
loadingPaneKey = ''
}
- const navbar = document.querySelector('solid-panes-navbar') as (HTMLElement & {
- navbarItems?: NavbarMenuItem[]
- }) | null
- const selectedNavbarItem = navbar?.navbarItems?.find(item =>
- item.paneName === paneName && (!paneUri || item.paneUri === paneUri)
- )
- // Any other pane on a page inside someone's pod (for example their container)
- // shows that pod owner's heading. Account-menu views show the viewer's own content.
- const profileNavbarItem = navbar?.navbarItems?.find(item => item.paneName === 'profile')
- const viewedProfileUri = (
- selectedNavbarItem ?? (window.history.state?.viaAccountMenu ? undefined : profileNavbarItem)
- )?.profileSubjectUri
+ const { view, viaAccountMenu } = readNavigationState()
+ const decision = deriveHeading({
+ view,
+ viaAccountMenu,
+ viewer: authn.currentUser()?.value,
+ pageUrl: window.location.href,
+ owner: pod.owner,
+ storageUris: pod.storagePanes.map(pane => pane.subject.value)
+ })
- if (!paneName || !PROFILE_PANE_NAMES.has(paneName)) {
- const viewer = authn.currentUser()
- if (!viewedProfileUri || viewedProfileUri === viewer?.value) {
- refreshRequest++
- loadingPaneKey = ''
- displayedPaneKey = ''
- displayedSubjectUri = ''
- hideHeading(mount)
- return
- }
+ if (decision.mode === 'none') {
+ refreshRequest++
+ loadingPaneKey = ''
+ displayedPaneKey = ''
+ displayedSubjectUri = ''
+ hideHeading(mount)
+ return
}
- const subjectUri = PROFILE_PANE_NAMES.has(paneName)
- ? paneUri || selectedNavbarItem?.profileSubjectUri || window.location.href
- : viewedProfileUri
- if (!subjectUri) return
+ const { subjectUri } = decision
+ const isProfilePane = decision.mode === 'full'
cancelHeadingHide(mount)
- const paneKey = `${paneName}:${subjectUri}`
+ const paneKey = `${decision.paneName}:${subjectUri}`
if (paneKey === displayedPaneKey || paneKey === loadingPaneKey) return
const request = ++refreshRequest
@@ -202,7 +194,7 @@ export async function refreshProfileHeading (force = false): Promise {
try {
subject = await loadProfileFromURI(store.sym(subjectUri))
profileData = presentProfile(subject, store)
- viewerMode = paneName === 'profile' ? await getViewerMode(subject) : 'anonymous'
+ viewerMode = isProfilePane ? await getViewerMode(subject) : 'anonymous'
canEdit = viewerMode === 'owner'
} catch (error) {
if (request === refreshRequest) loadingPaneKey = ''
@@ -220,7 +212,7 @@ export async function refreshProfileHeading (force = false): Promise {
}
heading.profileData = profileData
const viewer = authn.currentUser()
- heading.friendSubject = paneName === 'profile' && viewer && !viewer.sameTerm(subject)
+ heading.friendSubject = isProfilePane && viewer && !viewer.sameTerm(subject)
? subject
: undefined
heading.editContext = canEdit
@@ -231,7 +223,7 @@ export async function refreshProfileHeading (force = false): Promise {
onSaved: () => refreshProfileHeading(true)
}
: undefined
- heading.compact = paneName !== 'profile'
+ heading.compact = !isProfilePane
const appearing = mount.hidden || !mount.firstElementChild
mount.hidden = false
if (heading.parentNode !== mount) mount.replaceChildren(heading)
@@ -246,6 +238,11 @@ export async function refreshProfileHeading (force = false): Promise {
}
}
+/** Put the heading where it belongs, above the navbar, after the navbar has been added. */
+export function placeProfileHeading (): void {
+ ensureMountPoint()
+}
+
export function initializeProfileHeading (): void {
ensureMountPoint()
@@ -286,25 +283,14 @@ export function initializeProfileHeading (): void {
authListenersRegistered = true
}
- if (!observer && typeof MutationObserver !== 'undefined') {
- observer = new MutationObserver(() => {
- refreshProfileHeading().catch((error: unknown) => {
- console.error('Failed to refresh profile heading.', error)
- })
- })
- observer.observe(document.getElementById('MainContent') || document.body, {
- childList: true,
- subtree: true
- })
- }
-
- if (!popstateListenerRegistered) {
- window.addEventListener('popstate', () => {
+ // Navigations the page's own navigation does not make, such as links and buttons in solid-ui
+ if (!navigatedListenerRegistered) {
+ onNavigated(() => {
refreshProfileHeading().catch((error: unknown) => {
console.error('Failed to refresh profile heading.', error)
})
})
- popstateListenerRegistered = true
+ navigatedListenerRegistered = true
}
readiness.then(() => refreshProfileHeading(true)).catch((error: unknown) => {
diff --git a/src/navigation/browserEvents.ts b/src/navigation/browserEvents.ts
new file mode 100644
index 00000000..b3b2ccf6
--- /dev/null
+++ b/src/navigation/browserEvents.ts
@@ -0,0 +1,86 @@
+/*
+ * What the page does when the browser changes the URL or the history entry without reloading.
+ *
+ * Like a reload, both cases show what the URL is, not what the entry last displayed through the
+ * navbar or the account menu:
+ * - Enter on the current URL (typically one ending in #me)
+ * - back and forward
+ */
+
+import type { NavigationController } from './controller'
+import { clearPaneState } from './state'
+
+interface NavigationEvent extends Event {
+ canIntercept: boolean
+ userInitiated: boolean
+ navigationType: string
+ destination: { url: string }
+ intercept: (options: { handler: () => Promise, scroll: 'manual', focusReset: 'manual' }) => void
+}
+
+interface NavigationApi {
+ addEventListener: (type: 'navigate' | 'currententrychange', listener: (event: NavigationEvent) => void) => void
+}
+
+let navigation: NavigationController | undefined
+let listenersRegistered = false
+
+/**
+ * Whether the main page handles back and forward itself. Packages that used to re-render on
+ * `popstate` (mashlib) ask this and leave it to the main page.
+ */
+export function isHandlingBrowserNavigation (): boolean {
+ return listenersRegistered
+}
+
+function reportError (error: unknown) {
+ console.error('Failed to show the page after browser navigation.', error)
+}
+
+/**
+ * Starts handling Enter, back and forward for a controller. The listeners are added once;
+ * calling this again only changes which controller they use.
+ *
+ * The Navigation API is used where the browser has it; the places that fall back to an older
+ * mechanism are marked NAVIGATION API FALLBACK. See "Browser support" in docs/navigation-design.md.
+ */
+export function registerBrowserNavigation (controller: NavigationController): void {
+ navigation = controller
+ if (listenersRegistered) return
+ listenersRegistered = true
+
+ const showUrl = () => {
+ clearPaneState()
+ return (navigation?.show({ kind: 'restore', forceHeading: true }) ?? Promise.resolve()).catch(reportError)
+ }
+
+ const navigationApi = (window as unknown as { navigation?: NavigationApi }).navigation
+ if (!navigationApi) {
+ // NAVIGATION API FALLBACK (Firefox before 147, Safari before 26.2, older Chromium).
+ // Back and forward use `popstate`, which cannot tell them from in-page `#` links, so a
+ // fragment link also re-renders the page. Enter on the current URL is not handled at all
+ // here: without a fragment the browser reloads the page, and `initMainPage` shows the URL.
+ window.addEventListener('popstate', () => { showUrl() })
+ return
+ }
+
+ // NAVIGATION API: Enter on the current URL (typically one ending in #me) is a same-URL
+ // "replace" navigation; intercepting it shows the URL without reloading. Without the API
+ // there is no equivalent, see the fallback above.
+ navigationApi.addEventListener('navigate', event => {
+ if (!event.canIntercept || !event.userInitiated || event.navigationType !== 'replace' ||
+ event.destination.url !== window.location.href) return
+
+ event.intercept({
+ scroll: 'manual',
+ focusReset: 'manual',
+ handler: showUrl
+ })
+ })
+
+ // NAVIGATION API: unlike the `popstate` fallback above, this does not fire for in-page
+ // fragment links.
+ navigationApi.addEventListener('currententrychange', event => {
+ if (event.navigationType === 'traverse') showUrl()
+ })
+}
diff --git a/src/navigation/chrome.ts b/src/navigation/chrome.ts
new file mode 100644
index 00000000..7cd9eacc
--- /dev/null
+++ b/src/navigation/chrome.ts
@@ -0,0 +1,146 @@
+/*
+ * The rules for what the page chrome shows, as pure functions.
+ *
+ * What the user sees depends on the page URL, the pane that was last opened, whether the
+ * account menu opened it, who is logged in and whose pod the URL is in. These functions
+ * decide it from those inputs alone: they read no history, DOM or session, so every rule
+ * can be tested in one place. The heading, navbar, outline and file explorer provider
+ * only apply the result.
+ */
+
+import { type NavigationView } from './state'
+
+/** Registry names of the panes that present a person rather than a resource. */
+export const PERSON_PANES = ['profile', 'social']
+
+export function isPersonPane (paneName?: string): boolean {
+ return paneName !== undefined && PERSON_PANES.includes(paneName)
+}
+
+/** The parts of a navbar item that the rules look at. */
+export interface NavbarItemRef {
+ paneName?: string
+ paneUri?: string
+}
+
+// Profile heading
+
+export type HeadingDecision =
+ | { mode: 'none' }
+ | {
+ /** Full on the profile pane, compact everywhere else. */
+ mode: 'full' | 'compact'
+ /** The profile (WebID or document) to present. */
+ subjectUri: string
+ paneName?: string
+ }
+
+export interface HeadingInput {
+ view: NavigationView
+ viaAccountMenu: boolean
+ /** WebID of the logged-in user. */
+ viewer?: string
+ pageUrl: string
+ /** WebID of the person whose pod the page is in, if known. */
+ owner?: string
+ /** URIs of the storages of that pod. */
+ storageUris: string[]
+}
+
+/**
+ * Profile and friends panes present their own subject. Any other pane shows the owner of
+ * the pod the page is in, unless that is the viewer, or the view came from the account menu
+ * and so shows the viewer's own content.
+ */
+export function deriveHeading (input: HeadingInput): HeadingDecision {
+ const { view, viaAccountMenu, viewer, pageUrl, owner, storageUris } = input
+ const paneName = view.pane
+
+ // Whether the pod owner's navbar has an item for the displayed pane: Profile and Friends
+ // whenever there is an owner, and a storage by its URI (any of them when the view names none).
+ const ownerHasPane = paneName === 'profile' || paneName === 'social'
+ ? owner !== undefined
+ : paneName === 'folder' && storageUris.some(uri => !view.subject || uri === view.subject)
+ const ownerUri = ownerHasPane || !viaAccountMenu ? owner : undefined
+
+ if (!isPersonPane(paneName)) {
+ if (!ownerUri || ownerUri === viewer) return { mode: 'none' }
+ return { mode: 'compact', subjectUri: ownerUri, paneName }
+ }
+
+ return {
+ mode: paneName === 'profile' ? 'full' : 'compact',
+ subjectUri: view.subject || (ownerHasPane ? owner : undefined) || pageUrl,
+ paneName
+ }
+}
+
+// Navbar
+
+export interface NavbarSelection {
+ paneName?: string
+ paneUri?: string
+}
+
+/** What the navbar should have selected: the displayed pane, or Profile on a WebID. */
+export function deriveNavbarSelection (view: NavigationView, pageIsWebId: boolean): NavbarSelection {
+ return {
+ paneName: view.pane ?? (pageIsWebId ? 'profile' : undefined),
+ paneUri: view.subject
+ }
+}
+
+/**
+ * @param storageItemCount how many storage items the navbar has; a single storage is
+ * selected even when the view does not say which one it was
+ */
+export function isNavbarItemSelected (
+ item: NavbarItemRef,
+ selection: NavbarSelection,
+ storageItemCount: number
+): boolean {
+ if (!selection.paneName || item.paneName !== selection.paneName) return false
+ if (item.paneName === 'folder') {
+ return selection.paneUri ? selection.paneUri === item.paneUri : storageItemCount === 1
+ }
+ return true
+}
+
+/** The item to select for a pane: the one for that subject, else the first for that pane. */
+export function pickNavbarItem- (
+ items: Item[],
+ paneName: string,
+ paneUri?: string
+): Item | undefined {
+ const forPane = items.filter(item => item.paneName === paneName)
+ return forPane.find(item => item.paneUri === paneUri) ?? forPane[0]
+}
+
+/** The navbar is not offered in the viewer's own pod, nor over account-menu content. */
+export function isNavbarHidden (input: { viaAccountMenu: boolean, ownProfile: boolean, inViewerPod: boolean }): boolean {
+ return input.viaAccountMenu || input.ownProfile || input.inViewerPod
+}
+
+// File explorer header
+
+export interface ExplorerHeaderInput {
+ isRootResource: boolean
+ isWebId: boolean
+ /**
+ * The view shows a person's own subject or the viewer's content, not the resource the
+ * page URL points at, so the resource header only fits inside the viewer's own pod.
+ */
+ viewerPodOnly: boolean
+ inViewerPod: boolean
+}
+
+/** Whether the header describing a resource fits the view. */
+export function deriveExplorerHeader (input: ExplorerHeaderInput): boolean {
+ if (input.isRootResource || input.isWebId) return false
+ return !input.viewerPodOnly || input.inViewerPod
+}
+
+/** The header is never shown over a person's profile or friends, whatever the resource. */
+export function explorerHeaderForPane (resourceHeader: boolean, paneName?: string): boolean {
+ return resourceHeader && !isPersonPane(paneName)
+}
diff --git a/src/navigation/controller.ts b/src/navigation/controller.ts
new file mode 100644
index 00000000..3e0d4e16
--- /dev/null
+++ b/src/navigation/controller.ts
@@ -0,0 +1,113 @@
+/*
+ * The one place that displays a view and brings the page chrome in line with it.
+ *
+ * The account menu, the navbar, a page load, Enter in the address bar, back and forward and a
+ * layout change used to each resolve a pane, call `GotoSubject` and then do some of the follow-up
+ * themselves. Now each describes what it wants as an intent, and the follow-up is always the same:
+ * navbar visibility and selection, then the profile heading.
+ */
+
+import type { LiveStore, NamedNode } from 'rdflib'
+import type { PaneDefinition } from 'pane-registry'
+import type { OutlineManager } from '../outline/manager'
+import { getFolderPaneForStorage, getProfilePaneFromURI, getSocialPaneFromURI, type PaneItem } from '../utils/paneUtils'
+import { setNavbarHidden } from './navbarVisibility'
+import { readNavigationState, type NavigationState, type NavigationView } from './state'
+import type { NavigationSource } from './options'
+import { isWebIdUri } from '../utils/webIdUtils'
+
+export type NavigationIntent =
+ /** Display what the current history entry says was displayed, for the page's own URL. */
+ | {
+ kind: 'restore'
+ /** The page subject, when it is not the current URL. */
+ subject?: string
+ /** Redo the heading even if it believes it already shows the right profile. */
+ forceHeading?: boolean
+ }
+ /** A person's profile or friends, by WebID. */
+ | { kind: 'profile' | 'friends', subject: string, source?: NavigationSource }
+ /** A storage, as the navbar or the account menu offers it. */
+ | { kind: 'storage', pane: PaneItem, source?: NavigationSource }
+ /** A pane of the application itself, by registry name (dashboard, preferences). */
+ | { kind: 'app', pane: string, source?: NavigationSource }
+
+export interface NavigationEffects {
+ selectNavbarPane: (paneName: string, paneUri?: string) => void
+ refreshHeading: (force: boolean) => Promise
+}
+
+export interface NavigationDeps {
+ store: LiveStore
+ outliner: OutlineManager
+ byName: (name: string) => PaneDefinition | null | undefined
+ effects: NavigationEffects
+}
+
+export interface NavigationController {
+ /**
+ * Display what the intent asks for. If another intent arrives while this one is still
+ * resolving its pane, only the later one is displayed.
+ */
+ show: (intent: NavigationIntent) => Promise
+}
+
+type DisplayedPane = PaneItem | PaneDefinition | null | undefined
+
+export function createNavigationController (deps: NavigationDeps): NavigationController {
+ const { store, outliner, byName, effects } = deps
+ let latest = 0
+
+ async function paneForView (subject: NamedNode, view: NavigationView): Promise {
+ // These panes render a subject other than the page URL.
+ if (view.subject) {
+ const paneSubject = store.sym(view.subject)
+ if (view.kind === 'profile') return getProfilePaneFromURI(paneSubject)
+ if (view.kind === 'friends') return getSocialPaneFromURI(paneSubject)
+ if (view.kind === 'storage') return getFolderPaneForStorage(paneSubject)
+ }
+ if (view.pane) return byName(view.pane) ?? undefined
+ return isWebIdUri(subject) ? getProfilePaneFromURI(subject) : undefined
+ }
+
+ async function paneFor (intent: NavigationIntent, subject: NamedNode, state: NavigationState): Promise {
+ switch (intent.kind) {
+ case 'restore': return paneForView(subject, state.view)
+ case 'profile': return getProfilePaneFromURI(store.sym(intent.subject))
+ case 'friends': return getSocialPaneFromURI(store.sym(intent.subject))
+ case 'storage': return intent.pane
+ case 'app': return byName(intent.pane)
+ }
+ }
+
+ return {
+ async show (intent) {
+ const request = ++latest
+ const state = readNavigationState()
+ const source: NavigationSource = intent.kind === 'restore'
+ ? (state.viaAccountMenu ? 'accountMenu' : 'default')
+ : intent.source ?? 'default'
+ // The account menu shows the viewer's own content, so the navbar for the page is not offered
+ if (source === 'accountMenu') setNavbarHidden(true)
+
+ const subject = store.sym(intent.kind === 'restore' && intent.subject ? intent.subject : window.location.href)
+ const pane = await paneFor(intent, subject, state)
+ if (request !== latest) return
+
+ // Without a pane the outline would fall back to the page URL's own default view, which
+ // is only right when restoring an entry that displayed nothing in particular.
+ if (!pane && intent.kind !== 'restore') {
+ console.warn('Could not open the requested view for the logged-in user.')
+ return
+ }
+
+ outliner.GotoSubject(subject, true, pane ?? undefined, true, undefined, undefined, { source })
+
+ if (source !== 'accountMenu') {
+ const { view } = readNavigationState()
+ if (view.pane) effects.selectNavbarPane(view.pane, view.subject)
+ }
+ await effects.refreshHeading(intent.kind === 'restore' && intent.forceHeading === true)
+ }
+ }
+}
diff --git a/src/navigation/events.ts b/src/navigation/events.ts
new file mode 100644
index 00000000..8e98bd47
--- /dev/null
+++ b/src/navigation/events.ts
@@ -0,0 +1,30 @@
+import { parseNavigationState, type NavigationState } from './state'
+
+/** Dispatched on the document after a view has been displayed and its history entry written. */
+export const NAVIGATED_EVENT = 'solid-panes:navigated'
+
+export interface NavigatedDetail {
+ /** The page URL after displaying. */
+ url: string
+ /** What the history entry now says was displayed. */
+ state: NavigationState
+}
+
+/**
+ * Whoever calls `GotoSubject` directly (links and buttons in solid-ui, mashlib) gets the same
+ * follow-up as the main page's own navigation, because the page chrome listens for this.
+ */
+export function announceNavigation (doc: Document): void {
+ const detail: NavigatedDetail = {
+ url: doc.location.href,
+ state: parseNavigationState(doc.defaultView?.history.state)
+ }
+ doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail }))
+}
+
+/** Calls the listener after each navigation; returns a function that stops listening. */
+export function onNavigated (listener: (detail: NavigatedDetail) => void, doc: Document = document): () => void {
+ const handler = (event: Event) => listener((event as CustomEvent).detail)
+ doc.addEventListener(NAVIGATED_EVENT, handler)
+ return () => doc.removeEventListener(NAVIGATED_EVENT, handler)
+}
diff --git a/src/navigation/navbarVisibility.ts b/src/navigation/navbarVisibility.ts
new file mode 100644
index 00000000..067e27d7
--- /dev/null
+++ b/src/navigation/navbarVisibility.ts
@@ -0,0 +1,27 @@
+import { sym } from 'rdflib'
+import { isOwnWebIdProfile } from '../utils/webIdUtils'
+import { isNavbarHidden } from './chrome'
+import { isPageInViewerPod } from './podContext'
+
+const NAVBAR_HIDDEN_CLASS = 'navbar--hidden'
+let revision = 0
+
+/** Show or hide the navbar, if the page has one. */
+export function setNavbarHidden (hidden: boolean): void {
+ ++revision
+ document.querySelector('solid-panes-navbar')?.classList.toggle(NAVBAR_HIDDEN_CLASS, hidden)
+}
+
+/** Apply ownership rules without letting an older lookup overwrite a newer navigation. */
+export async function refreshNavbarVisibility (url: string, viaAccountMenu = false): Promise {
+ const request = ++revision
+ const ownProfile = isOwnWebIdProfile(sym(url))
+ if (viaAccountMenu || ownProfile) {
+ setNavbarHidden(true)
+ return
+ }
+
+ const inViewerPod = await isPageInViewerPod(url)
+ if (request !== revision) return
+ setNavbarHidden(isNavbarHidden({ viaAccountMenu, ownProfile, inViewerPod }))
+}
diff --git a/src/navigation/options.ts b/src/navigation/options.ts
new file mode 100644
index 00000000..5e93c49a
--- /dev/null
+++ b/src/navigation/options.ts
@@ -0,0 +1,21 @@
+/**
+ * Who asked for a view to be displayed. `GotoSubject` takes it as its seventh argument.
+ *
+ * - `default`: the page's own navigation (URL, navbar, links)
+ * - `accountMenu`: the viewer's own content, shown while the page URL keeps pointing elsewhere
+ */
+export type NavigationSource = 'default' | 'accountMenu'
+
+export interface GotoSubjectOptions {
+ source?: NavigationSource
+}
+
+/**
+ * The seventh argument used to be `showNavbar`: `false` meant "opened by the account menu"
+ * and anything else meant the page's own navigation. That form is still accepted.
+ */
+export function resolveSource (argument?: boolean | GotoSubjectOptions | null): NavigationSource {
+ if (typeof argument === 'object' && argument !== null) return argument.source ?? 'default'
+ if (argument === undefined) return 'default'
+ return argument ? 'default' : 'accountMenu'
+}
diff --git a/src/navigation/podContext.ts b/src/navigation/podContext.ts
new file mode 100644
index 00000000..143963f1
--- /dev/null
+++ b/src/navigation/podContext.ts
@@ -0,0 +1,124 @@
+/*
+ * Whose pod the page is in, and which storages the viewer owns.
+ *
+ * Several parts of the page need this: the navbar builds its items from it, the profile heading
+ * shows the owner, and the outline decides whether the file explorer header fits. Each part is
+ * looked up once and cached, so they no longer each look it up, or read it back from each
+ * other's DOM.
+ */
+
+import { authn } from 'solid-logic'
+import { sym } from 'rdflib'
+import { isWebIdUri, loadProfileFromURI } from '../utils/webIdUtils'
+import { getFolderPanesFromURI, type PaneItem } from '../utils/paneUtils'
+import { getPodStorages } from '../utils/podUtils'
+
+export interface PodContext {
+ /** WebID of the logged-in user. */
+ viewer?: string
+ /** WebID of the person whose pod the page is in; unknown when no profile could be found. */
+ owner?: string
+ /** The storages of that pod, as panes the navbar can open. */
+ storagePanes: PaneItem[]
+ /** The storages the viewer owns. */
+ viewerStorages: string[]
+}
+
+const MAX_REMEMBERED = 500
+
+// What was found depends on different parts of the URL, so each part is remembered on its own:
+// - the owner depends on the document: the profile lookup can end with that document's own
+// foaf:primaryTopic, so two pages of one pod may not share an owner
+// - the storages depend on the pod, derived from the origin and the first path segment
+// - the viewer's storages depend only on who the viewer is
+const owners = new Map>()
+const storages = new Map>()
+const viewerStorages = new Map>()
+
+function podKey (pageUrl: string): string {
+ try {
+ const url = new URL(pageUrl)
+ const [first = ''] = url.pathname.split('/').filter(Boolean)
+ return `${url.origin}|${first}`
+ } catch {
+ return pageUrl
+ }
+}
+
+function documentKey (pageUrl: string): string {
+ return sym(pageUrl).doc().uri
+}
+
+/**
+ * Remembers a lookup, unless it failed: a failure is not kept, so that the next request asks again.
+ */
+function remember (
+ cache: Map>,
+ key: string,
+ what: string,
+ load: () => Promise,
+ fallback: T
+): Promise {
+ const existing = cache.get(key)
+ if (existing) return existing
+
+ const created: Promise = load().catch(error => {
+ console.warn(`Could not load the ${what} for ${key}`, error)
+ if (cache.get(key) === created) cache.delete(key)
+ return fallback
+ })
+ cache.set(key, created)
+ if (cache.size > MAX_REMEMBERED) cache.delete(cache.keys().next().value as string)
+ return created
+}
+
+function ownerOf (pageUrl: string): Promise {
+ return remember(owners, documentKey(pageUrl), 'owner of the pod', async () => {
+ const subject = sym(pageUrl)
+ const webId = await loadProfileFromURI(subject)
+ // The lookup falls back to the URL itself when it finds no profile
+ return webId.uri === subject.uri && !isWebIdUri(subject) ? undefined : webId.value
+ }, undefined)
+}
+
+function storagesOf (pageUrl: string): Promise {
+ return remember(storages, podKey(pageUrl), 'storages of the pod', () => getFolderPanesFromURI(sym(pageUrl)), [])
+}
+
+function storagesOfViewer (viewer: string | undefined): Promise {
+ if (!viewer) return Promise.resolve([])
+ return remember(viewerStorages, viewer, 'storages of the viewer', async () => {
+ return (await getPodStorages(sym(viewer))).map(storage => storage.uri)
+ }, [])
+}
+
+/** The context for the pod a URL is in; the page's own URL by default. */
+export async function getPodContext (pageUrl: string = window.location.href): Promise {
+ const viewer = authn.currentUser()?.value
+ const [owner, storagePanes, ownStorages] = await Promise.all([
+ ownerOf(pageUrl), storagesOf(pageUrl), storagesOfViewer(viewer)
+ ])
+ return { viewer, owner, storagePanes, viewerStorages: ownStorages }
+}
+
+/** Forget what was loaded, for example when tests start over. */
+export function resetPodContexts (): void {
+ owners.clear()
+ storages.clear()
+ viewerStorages.clear()
+}
+
+/** Whether two contexts describe the same pod, so that a navbar built from one fits the other. */
+export function isSamePod (a: PodContext, b: PodContext): boolean {
+ return a.owner === b.owner && a.viewer === b.viewer &&
+ a.storagePanes.map(pane => pane.subject.value).join() === b.storagePanes.map(pane => pane.subject.value).join()
+}
+
+export function isInViewerPod (context: PodContext, url: string): boolean {
+ return context.viewerStorages.some(storage => url.startsWith(storage))
+}
+
+/** Whether the URL lies within one of the viewer's own storages. */
+export async function isPageInViewerPod (url: string): Promise {
+ return isInViewerPod(await getPodContext(url), url)
+}
diff --git a/src/navigation/state.ts b/src/navigation/state.ts
new file mode 100644
index 00000000..7ff3b014
--- /dev/null
+++ b/src/navigation/state.ts
@@ -0,0 +1,96 @@
+/*
+ * What a history entry records about the view that was displayed.
+ *
+ * This is the only module that knows how that is stored in `history.state`; everything
+ * else works with `NavigationState`. The stored fields are `paneName`, `paneUri` and
+ * `viaAccountMenu`, next to whatever other keys the entry carries.
+ */
+
+export type ViewKind = 'default' | 'profile' | 'friends' | 'storage' | 'pane'
+
+export interface NavigationView {
+ kind: ViewKind
+ /** Registry name of the displayed pane. Always set unless the kind is `default`. */
+ pane?: string
+ /** What the pane displays when that is not the page URL (a profile, friends list or storage). */
+ subject?: string
+}
+
+export interface NavigationState {
+ view: NavigationView
+ /** The account menu opened the view: it shows the viewer's own content under the page URL. */
+ viaAccountMenu: boolean
+}
+
+const KIND_BY_PANE: Record = {
+ profile: 'profile',
+ social: 'friends',
+ folder: 'storage'
+}
+
+function asRecord (raw: unknown): Record {
+ return raw !== null && typeof raw === 'object' ? raw as Record : {}
+}
+
+export function viewFromPane (pane?: string, subject?: string): NavigationView {
+ if (!pane) return { kind: 'default' }
+ return { kind: KIND_BY_PANE[pane] ?? 'pane', pane, ...(subject ? { subject } : {}) }
+}
+
+export function parseNavigationState (raw: unknown): NavigationState {
+ const { paneName, paneUri, viaAccountMenu } = asRecord(raw)
+ return {
+ view: viewFromPane(
+ typeof paneName === 'string' && paneName ? paneName : undefined,
+ typeof paneUri === 'string' && paneUri ? paneUri : undefined
+ ),
+ viaAccountMenu: viaAccountMenu === true
+ }
+}
+
+export function readNavigationState (): NavigationState {
+ return parseNavigationState(window.history.state)
+}
+
+export interface HistoryStateChange {
+ /** The pane that is being displayed, and its own subject if it has one. */
+ pane?: { name: string, subject?: string }
+ /** The entry is for a different URL than the current one. */
+ leavingPage: boolean
+ /** The account menu is displaying it. */
+ viaAccountMenu: boolean
+}
+
+/**
+ * The state to store for an entry after displaying something.
+ *
+ * Pane details describe what was displayed, so they must not leak into the entry of
+ * another URL, nor outlive the pane they were saved for. The account-menu marker
+ * keeps a later page load from restoring the viewer's content over the URL's subject.
+ */
+export function nextHistoryState (current: unknown, change: HistoryStateChange): Record {
+ const next: Record = { ...asRecord(current) }
+
+ if (change.pane) {
+ next.paneName = change.pane.name
+ if (typeof change.pane.subject === 'string') next.paneUri = change.pane.subject
+ }
+ if (change.leavingPage && !change.pane) delete next.paneName
+ if ((change.leavingPage && !change.pane) || (change.pane && !change.pane.subject)) delete next.paneUri
+
+ if (change.viaAccountMenu) next.viaAccountMenu = true
+ else delete next.viaAccountMenu
+
+ return next
+}
+
+/** The state without anything about the displayed pane. */
+export function withoutPaneState (raw: unknown): Record {
+ const { paneName, paneUri, viaAccountMenu, ...rest } = asRecord(raw)
+ return rest
+}
+
+/** Forget what the current entry displayed, so the URL alone decides what to show. */
+export function clearPaneState (): void {
+ window.history.replaceState(withoutPaneState(window.history.state), '', window.location.href)
+}
diff --git a/src/outline/manager.js b/src/outline/manager.js
index a4e1ebec..cb3755f9 100644
--- a/src/outline/manager.js
+++ b/src/outline/manager.js
@@ -11,8 +11,14 @@ import { propertyViews } from './propertyViews'
import { outlineIcons } from './outlineIcons.js' // @@ chec
import { UserInput } from './userInput.js'
import * as queryByExample from './queryByExample.js'
-import { isInViewerPod, loadContainerRepresentation } from '../utils/podUtils'
-import { isOwnWebIdProfile, isWebIdUri } from '../utils/webIdUtils'
+import { loadContainerRepresentation } from '../utils/podUtils'
+import { isWebIdUri } from '../utils/webIdUtils'
+import { deriveExplorerHeader } from '../navigation/chrome'
+import { isPageInViewerPod } from '../navigation/podContext'
+import { nextHistoryState } from '../navigation/state'
+import { announceNavigation } from '../navigation/events'
+import { resolveSource } from '../navigation/options'
+import { refreshNavbarVisibility } from '../navigation/navbarVisibility'
import '../components/file-explorer-header'
export default function (context) {
@@ -300,15 +306,9 @@ export default function (context) {
return getOrCreateContainer('OutlineView', 'Resource browser')
}
- function getNavbarElement () {
- return document.querySelector('solid-panes-navbar')
- }
-
function showSolidPanesNavbar (subject) {
- const navbar = getNavbarElement()
- if (navbar) {
- navbar.classList.toggle('navbar--hidden', isOwnWebIdProfile(subject))
- }
+ refreshNavbarVisibility(subject.uri)
+ .catch(error => UI.log.error('Failed to update navbar visibility: ' + error))
}
/**
@@ -470,11 +470,14 @@ export default function (context) {
provider.relevantPanes = relevantPanes
provider.pane = requiredPane || getPane(relevantPanes, subject)
const isRootResource = !!(subject && subject.uri && subject.site && subject.site().uri === subject.uri)
- // Views opened for the viewer's own content (account menu) or for a person's own
- // subject (navbar) are not a view of the page URL's resource, so the resource
- // header only makes sense when the page URL is inside the viewer's own pod.
- const hideOutsideViewerPod = !!options.headerOnlyInViewerPod && !(await isInViewerPod(document.location.href))
- provider.showHeader = !isRootResource && !isWebIdUri(subject) && !hideOutsideViewerPod
+ const viewerPodOnly = !!options.headerOnlyInViewerPod
+ provider.showHeader = deriveExplorerHeader({
+ isRootResource,
+ isWebId: isWebIdUri(subject),
+ viewerPodOnly,
+ // Only worth looking up when the view does not show the page URL's own resource
+ inViewerPod: viewerPodOnly ? await isPageInViewerPod(document.location.href) : true
+ })
provider.paneRenderOptions = options
provider.soloPane = options.solo
provider.openPane = (paneSubject, paneName) => openPaneInPlace(paneSubject, paneRegistry.byName(paneName))
@@ -1904,9 +1907,14 @@ export default function (context) {
@param solo -- optional -- the window will be cleared out and only the subject displayed
@param referer -- optional -- where did we hear about this from anyway?
@param table -- option -- default is an HTML table element in which to put the outline.
- @param showNavbar -- optional -- when false, suppress automatic navbar reveal (used by the account menu)
+ @param options -- optional -- who asked for the view: `{ source: 'accountMenu' }` for the viewer's
+ own content, which suppresses the automatic navbar reveal and is remembered in
+ the history entry. The old form, `false` for the account menu, is still accepted.
+
+ Afterwards, when `solo`, `solid-panes:navigated` is dispatched on the document.
*/
- this.GotoSubject = function (subject, expand, pane, solo, referrer, table, showNavbar = true) {
+ this.GotoSubject = function (subject, expand, pane, solo, referrer, table, options) {
+ const showNavbar = resolveSource(options) !== 'accountMenu'
const outlineContainer = getOutlineContainer()
if (showNavbar && (!table || table === outlineContainer)) {
showSolidPanesNavbar(subject)
@@ -1955,23 +1963,14 @@ export default function (context) {
dom.defaultView.history
) {
const currentState = dom.defaultView.history.state || {}
- const paneState = pane ? { paneName: pane.name } : {}
- if (pane && pane.subject && typeof pane.subject.uri === 'string') {
- paneState.paneUri = pane.subject.uri
- }
- const stateObj = { ...currentState, ...paneState }
- // Pane details describe what was displayed, so they must not leak into
- // another subject's entry, nor outlive the pane they were saved for.
const leavingPage = document.location.href !== subject.uri
- if (leavingPage && !pane) delete stateObj.paneName
- if ((leavingPage && !pane) || (pane && !paneState.paneUri)) delete stateObj.paneUri
- // The account menu shows the viewer's own content while keeping the URL,
- // so a later page load must not restore that pane over the URL's subject.
- if (showNavbar) {
- delete stateObj.viaAccountMenu
- } else {
- stateObj.viaAccountMenu = true
- }
+ const stateObj = nextHistoryState(currentState, {
+ pane: pane
+ ? { name: pane.name, subject: pane.subject && typeof pane.subject.uri === 'string' ? pane.subject.uri : undefined }
+ : undefined,
+ leavingPage,
+ viaAccountMenu: !showNavbar
+ })
try {
const currentUrl = new URL(document.location.href)
const targetUrl = new URL(subject.uri, document.location.href)
@@ -1986,6 +1985,8 @@ export default function (context) {
}
}
+ if (solo) announceNavigation(dom)
+
return subject
}
diff --git a/src/utils/podUtils.ts b/src/utils/podUtils.ts
index 8a2cdead..27abd7dd 100644
--- a/src/utils/podUtils.ts
+++ b/src/utils/podUtils.ts
@@ -1,4 +1,4 @@
-import { authn, store } from 'solid-logic'
+import { store } from 'solid-logic'
import { ns } from 'solid-ui'
import { LiveStore, NamedNode, parse } from 'rdflib'
import { isWebIdUri } from './webIdUtils'
@@ -56,15 +56,6 @@ export async function getPodStorages (url: NamedNode): Promise {
}
}
-// Whether a URL lies within one of the logged-in user's own storages.
-export async function isInViewerPod (uri: string): Promise {
- const viewer = authn.currentUser()
- if (!viewer) return false
-
- const storages = await getPodStorages(viewer)
- return storages.some(storage => uri.startsWith(storage.uri))
-}
-
async function findPodStorageFromUrl (url: NamedNode): Promise {
const podStorage = new URL(url.value || url.uri)
let pathStorage = podStorage.pathname
diff --git a/test/unit/mainPage/header.test.ts b/test/unit/mainPage/header.test.ts
index 3107ba5c..bbdecbdb 100644
--- a/test/unit/mainPage/header.test.ts
+++ b/test/unit/mainPage/header.test.ts
@@ -1,19 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { authn } from 'solid-logic'
import { sym } from 'rdflib'
-import type { OutlineManager } from '../../../src/outline/manager'
+import type { NavigationController } from '../../../src/navigation/controller'
-const { getProfilePane, getSocialPane, getFolderPanes } = vi.hoisted(() => ({
- getProfilePane: vi.fn(),
- getSocialPane: vi.fn(),
- getFolderPanes: vi.fn()
-}))
+const { getFolderPanes } = vi.hoisted(() => ({ getFolderPanes: vi.fn() }))
-vi.mock('../../../src/utils/paneUtils', () => ({
- getProfilePaneFromURI: getProfilePane,
- getSocialPaneFromURI: getSocialPane,
- getFolderPanesFromURI: getFolderPanes
-}))
+vi.mock('../../../src/utils/paneUtils', () => ({ getFolderPanesFromURI: getFolderPanes }))
import { createHeader } from '../../../src/mainPage/header'
@@ -21,7 +13,6 @@ afterEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
document.body.replaceChildren()
- window.history.replaceState({}, '', '/')
})
type MenuItem = { onSelected?: () => unknown }
@@ -29,68 +20,78 @@ type MenuItem = { onSelected?: () => unknown }
describe('account menu', () => {
const viewer = sym('https://viewer.example/profile/card#me')
- async function setup (currentUser: typeof viewer | null, checkUser: typeof viewer | null = null) {
- const GotoSubject = vi.fn()
- const outliner = { GotoSubject, context: { session: { paneRegistry: { byName: vi.fn() } } } } as unknown as OutlineManager
- const outlineView = document.createElement('table')
- outlineView.id = 'OutlineView'
- document.body.append(outlineView)
- const currentUserSpy = vi.spyOn(authn, 'currentUser').mockReturnValue(currentUser)
+ async function setup (currentUser: typeof viewer | null, checkUser: typeof viewer | null = null, storages: object[] = []) {
+ const show = vi.fn().mockResolvedValue(undefined)
+ vi.spyOn(authn, 'currentUser').mockReturnValue(currentUser)
vi.spyOn(authn, 'checkUser').mockResolvedValue(checkUser)
- getFolderPanes.mockResolvedValue([])
- window.history.replaceState({}, '', '/other/container/')
- const header = await createHeader(outliner) as HTMLElement & { menuItems: MenuItem[] }
- const [profile, friends] = header.menuItems
- return { GotoSubject, currentUserSpy, profile, friends, outlineView }
+ getFolderPanes.mockResolvedValue(storages)
+ const header = await createHeader({ show } as NavigationController) as HTMLElement & { menuItems: MenuItem[] }
+ return { show, header, items: header.menuItems }
}
- it('opens the profile pane for the viewer without changing the URL', async () => {
- const profilePane = { name: 'profile', subject: viewer }
- getProfilePane.mockResolvedValue(profilePane)
- const { GotoSubject, profile, outlineView } = await setup(viewer)
+ it('shows the viewer\'s profile, friends, dashboard and preferences as the account menu', async () => {
+ const { show, items } = await setup(viewer)
+ const [profile, friends, dashboard, preferences] = items
await profile.onSelected?.()
+ await friends.onSelected?.()
+ await dashboard.onSelected?.()
+ await preferences.onSelected?.()
+
+ expect(show.mock.calls.map(call => call[0])).toEqual([
+ { kind: 'profile', subject: viewer.value, source: 'accountMenu' },
+ { kind: 'friends', subject: viewer.value, source: 'accountMenu' },
+ { kind: 'app', pane: 'home', source: 'accountMenu' },
+ { kind: 'app', pane: 'basicPreferences', source: 'accountMenu' }
+ ])
+ })
+
+ it('offers each of the viewer\'s storages, between friends and the dashboard', async () => {
+ const storage = { paneName: 'folder', label: () => 'Storage' }
+ const { show, items } = await setup(viewer, null, [storage])
+
+ expect(items).toHaveLength(5)
+ expect(getFolderPanes).toHaveBeenCalledWith(viewer)
+ await items[2].onSelected?.()
- expect(getProfilePane).toHaveBeenCalledWith(viewer)
- expect(GotoSubject).toHaveBeenCalledWith(
- sym(window.location.href), true, profilePane, true, undefined, outlineView, false
- )
+ expect(show).toHaveBeenCalledWith({ kind: 'storage', pane: storage, source: 'accountMenu' })
+ })
+
+ it('loads storage choices for a viewer restored by the initial session check', async () => {
+ const storage = { paneName: 'folder', label: () => 'Storage' }
+ const { show, items } = await setup(null, viewer, [storage])
+
+ expect(getFolderPanes).toHaveBeenCalledWith(viewer)
+ expect(items).toHaveLength(5)
+ await items[2].onSelected?.()
+
+ expect(show).toHaveBeenCalledWith({ kind: 'storage', pane: storage, source: 'accountMenu' })
})
it.each([
['logged in after the header was created', null, viewer, null],
['restored by a session check', null, null, viewer]
- ])('opens the profile and friends panes when the viewer was %s', async (_name, atCreation, later, fromCheck) => {
- const profilePane = { name: 'profile', subject: viewer }
- const socialPane = { name: 'social', subject: viewer }
- getProfilePane.mockResolvedValue(profilePane)
- getSocialPane.mockResolvedValue(socialPane)
- const { GotoSubject, currentUserSpy, profile, friends } = await setup(atCreation, fromCheck)
- currentUserSpy.mockReturnValue(later)
+ ])('uses the viewer when it was %s', async (_name, atCreation, later, fromCheck) => {
+ const { show, items } = await setup(atCreation, fromCheck)
+ vi.mocked(authn.currentUser).mockReturnValue(later)
- await profile.onSelected?.()
- await friends.onSelected?.()
+ await items[0].onSelected?.()
+ await items[1].onSelected?.()
- expect(GotoSubject.mock.calls.map(call => call[2])).toEqual([profilePane, socialPane])
+ expect(show.mock.calls.map(call => call[0].subject)).toEqual([viewer.value, viewer.value])
})
it('does nothing when nobody is logged in', async () => {
- const { GotoSubject, profile } = await setup(null, null)
+ const { show, items } = await setup(null, null)
- await profile.onSelected?.()
+ for (const item of items) await item.onSelected?.()
- expect(getProfilePane).not.toHaveBeenCalled()
- expect(GotoSubject).not.toHaveBeenCalled()
+ expect(show).not.toHaveBeenCalled()
})
- it('does not fall back to the URL\'s default view when the pane cannot be built', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- getProfilePane.mockResolvedValue(null)
- const { GotoSubject, profile } = await setup(viewer)
-
- await profile.onSelected?.()
+ it('is created once', async () => {
+ const { header, show } = await setup(viewer)
- expect(GotoSubject).not.toHaveBeenCalled()
- expect(warn).toHaveBeenCalled()
+ expect(await createHeader({ show } as NavigationController)).toBe(header)
})
})
diff --git a/test/unit/mainPage/index.test.ts b/test/unit/mainPage/index.test.ts
index d9690962..de044b47 100644
--- a/test/unit/mainPage/index.test.ts
+++ b/test/unit/mainPage/index.test.ts
@@ -20,6 +20,7 @@ vi.mock('../../../src/mainPage/header', () => ({ createHeader: vi.fn() }))
vi.mock('../../../src/mainPage/navbar', () => ({ createNavbar: vi.fn(), selectNavbarPane }))
vi.mock('../../../src/mainPage/profileHeading', () => ({
initializeProfileHeading: vi.fn(),
+ placeProfileHeading: vi.fn(),
refreshProfileHeading: refreshHeading
}))
vi.mock('../../../src/utils/paneUtils', () => ({
@@ -30,6 +31,7 @@ vi.mock('../../../src/utils/paneUtils', () => ({
vi.mock('../../../src/utils/webIdUtils', () => ({ isWebIdUri: isWebId }))
import { initMainPage, refreshUI } from '../../../src/mainPage'
+import { resolveSource } from '../../../src/navigation/options'
const navigationListeners: Record void> = {}
Object.defineProperty(window, 'navigation', {
@@ -63,14 +65,14 @@ const urlProfilePane = {
function setup (state: object, webId = true) {
// Mirrors how the outline manager records the displayed pane in history.state
- const GotoSubject = vi.fn((_subject, _expand, pane, _solo, _referrer, _table, showNavbar = true) => {
+ const GotoSubject = vi.fn((_subject, _expand, pane, _solo, _referrer, _table, options) => {
const next = { ...window.history.state }
if (pane) {
next.paneName = pane.name
if (pane.subject) next.paneUri = pane.subject.value
}
- if (showNavbar) delete next.viaAccountMenu
- else next.viaAccountMenu = true
+ if (resolveSource(options) === 'accountMenu') next.viaAccountMenu = true
+ else delete next.viaAccountMenu
window.history.replaceState(next, '', window.location.href)
})
getOutliner.mockReturnValue({ GotoSubject })
@@ -117,17 +119,20 @@ describe('main page load', () => {
})
})
- it('still restores the pane selected on a non-WebID page', async () => {
- const storagePane = { name: 'folder', paneName: 'folder', subject: store.sym(`${origin}/other/storage/`) }
- getFolderPane.mockResolvedValue(storagePane)
- const GotoSubject = setup({ paneName: 'folder', paneUri: `${origin}/other/storage/` }, false)
+ it.each([
+ ['a storage from the navbar', { paneName: 'folder', paneUri: `${origin}/other/storage/` }],
+ ['friends from the navbar', { paneName: 'social', paneUri: `${origin}/other/profile/card#me` }],
+ ['a pane of the application', { paneName: 'home' }]
+ ])('shows what the URL is on a reload, whatever pane was selected before: %s', async (_name, saved) => {
+ const GotoSubject = setup({ ...saved, other: 'kept' }, false)
await initMainPage(store)
- expect(getFolderPane).toHaveBeenCalledWith(store.sym(`${origin}/other/storage/`))
- expect(getProfilePane).not.toHaveBeenCalled()
- expect(GotoSubject.mock.calls[0][2]).toBe(storagePane)
- expect(window.history.state.paneUri).toBe(`${origin}/other/storage/`)
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(getSocialPane).not.toHaveBeenCalled()
+ expect(byName).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(window.history.state).toEqual({ other: 'kept' })
})
it('drops account-menu content on a non-WebID page', async () => {
@@ -146,8 +151,8 @@ describe('main page load', () => {
})
describe('browser navigation without a reload', () => {
- async function setupLoaded (state: object = {}) {
- const GotoSubject = setup(state)
+ async function setupLoaded (state: object = {}, webId = true) {
+ const GotoSubject = setup(state, webId)
await initMainPage(store)
GotoSubject.mockClear()
refreshHeading.mockClear()
@@ -196,60 +201,64 @@ describe('browser navigation without a reload', () => {
expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href))
expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
- expect(GotoSubject.mock.calls[0][6]).toBe(true)
+ expect(resolveSource(GotoSubject.mock.calls[0][6])).toBe('default')
expect(selectNavbarPane).toHaveBeenCalledWith('profile', urlProfilePane.subject.value)
expect(window.history.state).toEqual({ paneName: 'profile', paneUri: urlProfilePane.subject.value })
expect(refreshHeading).toHaveBeenCalledWith(true)
})
- it('restores an account-menu storage entry on back or forward and hides the navbar', async () => {
- const GotoSubject = await setupLoaded()
- const navbar = document.createElement('solid-panes-navbar')
- document.body.appendChild(navbar)
- const storage = `${origin}/storage/`
- const storagePane = { name: 'folder', paneName: 'folder', subject: store.sym(storage) }
- getFolderPane.mockResolvedValue(storagePane)
- window.history.replaceState({ paneName: 'folder', paneUri: storage, viaAccountMenu: true }, '', window.location.href)
+ it('shows a container\'s own default view when Enter is pressed on its URL after a storage was selected', async () => {
+ const GotoSubject = await setupLoaded({}, false)
+ window.history.replaceState({ paneName: 'folder', paneUri: `${origin}/other/` }, '', window.location.href)
- traverse()
- await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(false))
+ const intercept = enter()
+ expect(intercept).toHaveBeenCalledOnce()
+ await intercept.mock.calls[0][0].handler()
- expect(getFolderPane).toHaveBeenCalledWith(store.sym(storage))
- const [subject, , pane, , , , showNavbar] = GotoSubject.mock.calls[0]
- expect(subject.value).toBe(window.location.href)
- expect(pane).toBe(storagePane)
- expect(showNavbar).toBe(false)
- expect(navbar).toHaveClass('navbar--hidden')
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(window.history.state).toEqual({})
expect(selectNavbarPane).not.toHaveBeenCalled()
- expect(window.location.pathname).toBe('/other/profile/card')
+ expect(refreshHeading).toHaveBeenCalledWith(true)
})
- it('restores a navbar entry on back or forward and selects it in the navbar', async () => {
+ it.each([
+ ['an account-menu storage', { paneName: 'folder', paneUri: `${origin}/storage/`, viaAccountMenu: true }],
+ ['a storage from the navbar', { paneName: 'folder', paneUri: `${origin}/other/storage/` }],
+ ['friends from the navbar', { paneName: 'social', paneUri: `${origin}/other/profile/card#me` }],
+ ['an account-menu dashboard', { paneName: 'home', viaAccountMenu: true }]
+ ])('shows what the URL is on back or forward, not %s the entry displayed', async (_name, saved) => {
const GotoSubject = await setupLoaded()
- const webId = urlProfilePane.subject.value
- const socialPane = { name: 'social', paneName: 'social', subject: urlProfilePane.subject }
- getSocialPane.mockResolvedValue(socialPane)
- window.history.replaceState({ paneName: 'social', paneUri: webId }, '', window.location.href)
+ window.history.replaceState({ ...saved, other: 'kept' }, '', window.location.href)
traverse()
- await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(false))
+ await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(true))
- expect(getSocialPane).toHaveBeenCalledWith(store.sym(webId))
- expect(GotoSubject.mock.calls[0][2]).toBe(socialPane)
- expect(GotoSubject.mock.calls[0][6]).toBe(true)
- expect(selectNavbarPane).toHaveBeenCalledWith('social', webId)
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(getSocialPane).not.toHaveBeenCalled()
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href))
+ expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
+ expect(resolveSource(GotoSubject.mock.calls[0][6])).toBe('default')
+ expect(selectNavbarPane).toHaveBeenCalledWith('profile', urlProfilePane.subject.value)
+ expect(window.history.state).toEqual({
+ paneName: 'profile',
+ paneUri: urlProfilePane.subject.value,
+ other: 'kept'
+ })
+ expect(window.location.pathname).toBe('/other/profile/card')
})
- it('shows the URL\'s profile when the history entry has no saved pane', async () => {
- const GotoSubject = await setupLoaded({ paneName: 'social', paneUri: urlProfilePane.subject.value })
- window.history.replaceState({}, '', window.location.href)
+ it('shows a container\'s own default view on back or forward after a storage was selected', async () => {
+ const GotoSubject = await setupLoaded({}, false)
+ window.history.replaceState({ paneName: 'folder', paneUri: `${origin}/other/` }, '', window.location.href)
traverse()
- await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(false))
+ await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(true))
- expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href))
- expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
- expect(selectNavbarPane).toHaveBeenCalledWith('profile', urlProfilePane.subject.value)
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(window.history.state).toEqual({})
+ expect(selectNavbarPane).not.toHaveBeenCalled()
})
it('ignores entry changes that are not back or forward', async () => {
@@ -257,49 +266,27 @@ describe('browser navigation without a reload', () => {
traverse('replace')
traverse('push')
- await Promise.resolve()
+ await new Promise(resolve => setTimeout(resolve, 10))
expect(GotoSubject).not.toHaveBeenCalled()
})
- it('only renders the latest entry when navigating quickly', async () => {
+ it('only renders the latest when navigating quickly', async () => {
const GotoSubject = await setupLoaded()
- const webId = urlProfilePane.subject.value
let resolveFirst!: (pane: unknown) => void
- getSocialPane.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve }))
- window.history.replaceState({ paneName: 'social', paneUri: webId }, '', window.location.href)
+ getProfilePane.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve }))
traverse()
- await vi.waitFor(() => expect(getSocialPane).toHaveBeenCalled())
+ await vi.waitFor(() => expect(getProfilePane).toHaveBeenCalledOnce())
- window.history.replaceState({}, '', window.location.href)
traverse()
await vi.waitFor(() => expect(GotoSubject).toHaveBeenCalledOnce())
- resolveFirst({ name: 'social', paneName: 'social', subject: urlProfilePane.subject })
+ resolveFirst({ name: 'profile', paneName: 'profile', subject: urlProfilePane.subject })
await new Promise(resolve => setTimeout(resolve))
expect(GotoSubject).toHaveBeenCalledOnce()
expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
})
- it('renders after the page\'s own popstate handlers so the restored entry wins', async () => {
- const GotoSubject = await setupLoaded()
- const storage = `${origin}/storage/`
- getFolderPane.mockResolvedValue({ name: 'folder', paneName: 'folder', subject: store.sym(storage) })
- window.history.replaceState({ paneName: 'folder', paneUri: storage, viaAccountMenu: true }, '', window.location.href)
- let handlersFinished = false
- // mashlib's own handler re-renders the default pane, dropping the marker from the entry
- window.addEventListener('popstate', () => {
- window.history.replaceState({ paneName: 'folder', paneUri: storage }, '', window.location.href)
- handlersFinished = true
- }, { once: true })
- GotoSubject.mockImplementationOnce(() => { expect(handlersFinished).toBe(true) })
-
- traverse()
- window.dispatchEvent(new PopStateEvent('popstate'))
- await vi.waitFor(() => expect(GotoSubject).toHaveBeenCalledOnce())
- expect(GotoSubject.mock.calls[0][6]).toBe(false)
- })
-
it('keeps the URL and account-menu view when the render environment changes', async () => {
const GotoSubject = await setupLoaded()
const navbar = document.createElement('solid-panes-navbar')
@@ -314,10 +301,10 @@ describe('browser navigation without a reload', () => {
await refreshUI(outliner)
- const [subject, , pane, , , , showNavbar] = GotoSubject.mock.calls[0]
+ const [subject, , pane, , , , options] = GotoSubject.mock.calls[0]
expect(subject.value).toBe(pageUrl)
expect(pane).toBe(storagePane)
- expect(showNavbar).toBe(false)
+ expect(options).toEqual({ source: 'accountMenu' })
expect(window.location.href).toBe(pageUrl)
expect(window.history.state).toMatchObject({ paneName: 'folder', paneUri: storage, viaAccountMenu: true })
expect(navbar).toHaveClass('navbar--hidden')
diff --git a/test/unit/mainPage/navbar.test.ts b/test/unit/mainPage/navbar.test.ts
index 19aadc76..fc20738c 100644
--- a/test/unit/mainPage/navbar.test.ts
+++ b/test/unit/mainPage/navbar.test.ts
@@ -1,38 +1,68 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { authn } from 'solid-logic'
import { sym } from 'rdflib'
-import type { OutlineManager } from '../../../src/outline/manager'
+import type { NavigationController } from '../../../src/navigation/controller'
import type { NavbarMenuItem } from '../../../src/components/navbar/Navbar'
import { getFolderPanesFromURI } from '../../../src/utils/paneUtils'
+import { announceNavigation } from '../../../src/navigation/events'
+import { resetPodContexts } from '../../../src/navigation/podContext'
+import { getPodStorages } from '../../../src/utils/podUtils'
+
+const { loadProfile } = vi.hoisted(() => ({ loadProfile: vi.fn() }))
vi.mock('../../../src/utils/paneUtils', () => ({
- getProfilePaneFromURI: vi.fn(),
- getSocialPaneFromURI: vi.fn(),
getFolderPanesFromURI: vi.fn(async () => [])
}))
+vi.mock('../../../src/utils/podUtils', () => ({
+ getPodStorages: vi.fn(async () => [])
+}))
+
vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({
...await importOriginal(),
- loadProfileFromURI: async (subject: ReturnType) => subject
+ loadProfileFromURI: loadProfile
}))
import { createNavbar, selectNavbarPane } from '../../../src/mainPage/navbar'
+// Without a profile of its own, the lookup answers with the URL it was asked about
+const noProfile = async (subject: ReturnType) => subject
+
+function folderPane (storage: ReturnType) {
+ return {
+ name: 'folder',
+ paneName: 'folder',
+ subject: storage,
+ label: () => 'Storage',
+ render: vi.fn(),
+ shouldGetFocus: () => false,
+ requireQueryButton: false,
+ icon: ''
+ }
+}
+
afterEach(() => {
vi.restoreAllMocks()
+ resetPodContexts()
+ loadProfile.mockReset().mockImplementation(noProfile)
vi.mocked(getFolderPanesFromURI).mockResolvedValue([])
+ vi.mocked(getPodStorages).mockReset().mockResolvedValue([])
document.body.replaceChildren()
window.history.replaceState({}, '', '/')
})
describe('page navbar visibility', () => {
- const outliner = {} as OutlineManager
+ const navigation = { show: vi.fn().mockResolvedValue(undefined) } as unknown as NavigationController
+
+ beforeEach(() => {
+ loadProfile.mockImplementation(noProfile)
+ })
it('selects a menu item by pane name and ignores unknown panes', async () => {
vi.spyOn(authn, 'currentUser').mockReturnValue(null)
window.history.replaceState({ paneName: 'social' }, '', '/other/profile/card#me')
- const navbar = await createNavbar(outliner) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
const selected = () => navbar.navbarItems.filter(item => item.selected).map(item => item.paneName)
expect(selected()).toEqual(['social'])
@@ -45,19 +75,10 @@ describe('page navbar visibility', () => {
it('selects the storage item matching the pane URI', async () => {
const storages = [1, 2].map(index => sym(`${window.location.origin}/other/storage-${index}/`))
- vi.mocked(getFolderPanesFromURI).mockResolvedValue(storages.map(storage => ({
- name: 'folder',
- paneName: 'folder',
- subject: storage,
- label: () => 'Storage',
- render: vi.fn(),
- shouldGetFocus: () => false,
- requireQueryButton: false,
- icon: ''
- })))
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue(storages.map(folderPane))
window.history.replaceState({}, '', '/other/profile/card#me')
- const navbar = await createNavbar(outliner) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
const selected = () => navbar.navbarItems.filter(item => item.selected).map(item => item.paneUri)
selectNavbarPane('folder', storages[1].value)
@@ -70,26 +91,16 @@ describe('page navbar visibility', () => {
it('associates each storage menu item with its own pane URI', async () => {
const subject = sym(`${window.location.origin}/other/profile/card#me`)
const storages = [1, 2].map(index => sym(`${window.location.origin}/other/storage-${index}/`))
- vi.mocked(getFolderPanesFromURI).mockResolvedValue(storages.map(storage => ({
- name: 'folder',
- paneName: 'folder',
- subject: storage,
- label: () => 'Storage',
- render: vi.fn(),
- shouldGetFocus: () => false,
- requireQueryButton: false,
- icon: ''
- })))
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue(storages.map(folderPane))
window.history.replaceState({
paneName: 'folder',
paneUri: storages[1].value
}, '', subject.value)
- const navbar = await createNavbar(outliner) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
const storageItems = navbar.navbarItems.filter(item => item.paneName === 'folder')
expect(storageItems.map(item => item.paneUri)).toEqual(storages.map(storage => storage.value))
- expect(storageItems.map(item => item.profileSubjectUri)).toEqual([subject.value, subject.value])
expect(storageItems.map(item => item.selected)).toEqual([false, true])
})
@@ -97,7 +108,7 @@ describe('page navbar visibility', () => {
vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
window.history.replaceState({}, '', url)
- const navbar = await createNavbar(outliner)
+ const navbar = await createNavbar(navigation)
expect(navbar).toHaveClass('navbar--hidden')
})
@@ -113,9 +124,43 @@ describe('page navbar visibility', () => {
)
window.history.replaceState({}, '', url)
- const navbar = await createNavbar(outliner)
+ const navbar = await createNavbar(navigation)
+
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ })
+
+ it.each(['/storage/', '/storage/docs/', '/storage/docs/file.ttl'])('hides the navbar within an owned storage at %s', async url => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
+ vi.mocked(getPodStorages).mockResolvedValue([sym(`${window.location.origin}/storage/`)])
+ window.history.replaceState({}, '', url)
+
+ expect(await createNavbar(navigation)).toHaveClass('navbar--hidden')
+ })
+ it('shows the navbar for the same storage resource when logged out', async () => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(null)
+ window.history.replaceState({}, '', '/storage/docs/file.ttl')
+
+ expect(await createNavbar(navigation)).not.toHaveClass('navbar--hidden')
+ expect(getPodStorages).not.toHaveBeenCalled()
+ })
+
+ it('updates visibility for resource links even when the navbar items do not change', async () => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
+ vi.mocked(getPodStorages).mockResolvedValue([sym(`${window.location.origin}/storage/owned/`)])
+ window.history.replaceState({}, '', '/storage/other/file.ttl')
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const items = navbar.navbarItems
expect(navbar).not.toHaveClass('navbar--hidden')
+
+ window.history.replaceState({}, '', '/storage/owned/file.ttl')
+ announceNavigation(document)
+ await vi.waitFor(() => expect(navbar).toHaveClass('navbar--hidden'))
+ expect(navbar.navbarItems).toBe(items)
+
+ window.history.replaceState({}, '', '/storage/other/file.ttl')
+ announceNavigation(document)
+ await vi.waitFor(() => expect(navbar).not.toHaveClass('navbar--hidden'))
})
it('updates an existing navbar when moving to and from the own profile', async () => {
@@ -124,11 +169,11 @@ describe('page navbar visibility', () => {
document.body.appendChild(navbar)
window.history.replaceState({}, '', '/profile/card#me')
- expect(await createNavbar(outliner)).toBe(navbar)
+ expect(await createNavbar(navigation)).toBe(navbar)
expect(navbar).toHaveClass('navbar--hidden')
window.history.replaceState({}, '', '/other/profile/card#me')
- expect(await createNavbar(outliner)).toBe(navbar)
+ expect(await createNavbar(navigation)).toBe(navbar)
expect(navbar).not.toHaveClass('navbar--hidden')
})
@@ -136,8 +181,154 @@ describe('page navbar visibility', () => {
vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/identity#person`))
window.history.replaceState({}, '', '/identity#person')
- const navbar = await createNavbar(outliner)
+ const navbar = await createNavbar(navigation)
expect(navbar).toHaveClass('navbar--hidden')
})
+
+ describe('built from the pod the page is in', () => {
+ it('offers Profile and Friends only when the owner of the pod is known', async () => {
+ window.history.replaceState({}, '', '/other/container/')
+
+ const unknown = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(unknown.navbarItems.map(item => item.paneName)).toEqual([])
+
+ document.body.replaceChildren()
+ resetPodContexts()
+ window.history.replaceState({}, '', '/other/profile/card#me')
+
+ const known = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(known.navbarItems.map(item => item.paneName)).toEqual(['profile', 'social'])
+ })
+
+ it('selects Profile on the WebID, but nothing on the document that holds it', async () => {
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/timea2/profile/card#me`))
+
+ window.history.replaceState({}, '', '/timea2/profile/card#me')
+ const onWebId = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(onWebId.navbarItems.filter(item => item.selected).map(item => item.paneName)).toEqual(['profile'])
+
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/timea2/profile/card')
+ const onDocument = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(onDocument.navbarItems.map(item => item.paneName)).toEqual(['profile', 'social'])
+ expect(onDocument.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it('selects nothing on other pages of the pod', async () => {
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/timea2/profile/card#me`))
+ window.history.replaceState({}, '', '/timea2/docs/')
+
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+
+ expect(navbar.navbarItems.map(item => item.paneName)).toEqual(['profile', 'social'])
+ expect(navbar.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it('asks the controller to display what is chosen', async () => {
+ const show = vi.fn().mockResolvedValue(undefined)
+ const owner = sym(`${window.location.origin}/a/profile/card#me`)
+ const storage = folderPane(sym(`${window.location.origin}/a/`))
+ loadProfile.mockResolvedValue(owner)
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue([storage])
+ window.history.replaceState({}, '', '/a/docs/first')
+
+ const navbar = await createNavbar({ show } as unknown as NavigationController) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ for (const item of navbar.navbarItems) {
+ navbar.dispatchEvent(new CustomEvent('solid-ui-select', { detail: item, bubbles: true }))
+ }
+
+ await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(3))
+ expect(show.mock.calls.map(call => call[0])).toEqual([
+ { kind: 'profile', subject: owner.value },
+ { kind: 'friends', subject: owner.value },
+ { kind: 'storage', pane: storage }
+ ])
+ })
+
+ describe('when a navigation moves within or between pods', () => {
+ const ownerOf = (pod: string) => sym(`${window.location.origin}/${pod}/profile/card#me`)
+
+ beforeEach(() => {
+ loadProfile.mockImplementation(async (subject: ReturnType) => {
+ const [pod] = new URL(subject.uri).pathname.split('/').filter(Boolean)
+ return ownerOf(pod)
+ })
+ })
+
+ it('builds the items again for the new pod, with the selection of the new entry', async () => {
+ window.history.replaceState({}, '', '/a/profile/card#me')
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('a').value, ownerOf('a').value])
+
+ window.history.replaceState({ paneName: 'social', paneUri: ownerOf('b').value }, '', '/b/docs/')
+ announceNavigation(document)
+
+ await vi.waitFor(() => {
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('b').value, ownerOf('b').value])
+ })
+ expect(navbar.navbarItems.filter(item => item.selected).map(item => item.paneName)).toEqual(['social'])
+ })
+
+ it('keeps the items for another page in the same pod', async () => {
+ window.history.replaceState({}, '', '/a/profile/card#me')
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const items = navbar.navbarItems
+
+ window.history.replaceState({}, '', '/a/docs/')
+ announceNavigation(document)
+ await vi.waitFor(() => expect(navbar.navbarItems.filter(item => item.selected)).toEqual([]))
+
+ expect(navbar.navbarItems.map(item => item.onSelected)).toEqual(items.map(item => item.onSelected))
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual(items.map(item => item.paneUri))
+ })
+
+ it.each([
+ ['social', undefined, '/a/docs/', ['social']],
+ ['profile', undefined, '/a/docs/', ['profile']],
+ ['folder', '/a/storage-2/', '/a/docs/', ['folder']],
+ ['folder', '/a/unknown/', '/a/docs/', []],
+ ['unknown', undefined, '/a/docs/', []],
+ [undefined, undefined, '/a/docs/', []],
+ [undefined, undefined, '/a/profile/card#me', ['profile']]
+ ])('updates selection for a same-pod direct navigation to %s at %s (%s)', async (paneName, paneUri, url, selectedPanes) => {
+ const storages = [1, 2].map(index => sym(`${window.location.origin}/a/storage-${index}/`))
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue(storages.map(folderPane))
+ window.history.replaceState({ paneName: 'social' }, '', '/a/docs/')
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const items = navbar.navbarItems
+ expect(items.filter(item => item.selected).map(item => item.paneName)).toEqual(['social'])
+ vi.mocked(getFolderPanesFromURI).mockClear()
+
+ window.history.replaceState({
+ paneName,
+ paneUri: paneUri ? `${window.location.origin}${paneUri}` : undefined
+ }, '', url)
+ announceNavigation(document)
+
+ await vi.waitFor(() => {
+ expect(navbar.navbarItems.filter(item => item.selected).map(item => item.paneName)).toEqual(selectedPanes)
+ })
+ if (paneName === 'folder' && selectedPanes.length) {
+ expect(navbar.navbarItems.filter(item => item.selected).map(item => item.paneUri)).toEqual([storages[1].value])
+ }
+ expect(navbar.navbarItems.map(item => item.onSelected)).toEqual(items.map(item => item.onSelected))
+ expect(getFolderPanesFromURI).not.toHaveBeenCalled()
+ })
+
+ it('stops following navigations once the navbar is gone', async () => {
+ window.history.replaceState({}, '', '/a/profile/card#me')
+ await createNavbar(navigation)
+ document.body.replaceChildren()
+ loadProfile.mockClear()
+ resetPodContexts()
+
+ window.history.replaceState({}, '', '/b/docs/')
+ announceNavigation(document)
+ await new Promise(resolve => setTimeout(resolve, 20))
+
+ expect(loadProfile).not.toHaveBeenCalled()
+ })
+ })
+ })
})
diff --git a/test/unit/mainPage/profileHeading.test.ts b/test/unit/mainPage/profileHeading.test.ts
index 59de062d..48f96837 100644
--- a/test/unit/mainPage/profileHeading.test.ts
+++ b/test/unit/mainPage/profileHeading.test.ts
@@ -4,9 +4,16 @@ import { lit, sym } from 'rdflib'
import { ns } from 'solid-ui'
import type { ProfileHeadingEditContext } from '../../../src/components/profile-heading/ProfileHeading'
-const { getViewerModeMock, createHeadingEditDialogMock } = vi.hoisted(() => ({
+const { getViewerModeMock, createHeadingEditDialogMock, pod } = vi.hoisted(() => ({
getViewerModeMock: vi.fn(),
- createHeadingEditDialogMock: vi.fn()
+ createHeadingEditDialogMock: vi.fn(),
+ // What the page knows about the pod it is in
+ pod: { current: { owner: undefined as string | undefined, storagePanes: [] as Array<{ subject: { value: string } }>, viewerStorages: [] as string[] } }
+}))
+
+vi.mock('../../../src/navigation/podContext', async (importOriginal) => ({
+ ...await importOriginal(),
+ getPodContext: async () => pod.current
}))
vi.mock('../../../src/components/profile-heading/editor/sections/heading/HeadingEditDialog', () => ({
@@ -30,11 +37,17 @@ vi.mock('../../../src/utils/webIdUtils', async () => {
}
})
-import { initializeProfileHeading, refreshProfileHeading } from '../../../src/mainPage/profileHeading'
+import { initializeProfileHeading, placeProfileHeading, refreshProfileHeading } from '../../../src/mainPage/profileHeading'
+import { announceNavigation } from '../../../src/navigation/events'
+
+function podOf (owner: string | undefined, ...storages: string[]) {
+ pod.current = { owner, storagePanes: storages.map(value => ({ subject: { value } })), viewerStorages: [] }
+}
afterEach(() => {
document.body.replaceChildren()
window.history.replaceState({}, '', '/')
+ podOf(undefined)
})
describe('page-level profile heading', () => {
@@ -519,24 +532,16 @@ describe('page-level profile heading', () => {
}
})
- it('keeps another person\'s heading visible on their other navbar panes', async () => {
+ it('keeps another person\'s heading visible on their storage', async () => {
const subject = sym('https://another-profile-heading.example/profile/card#me')
const storage = sym('https://profile-heading.example/storage/')
const viewer = sym('https://viewer.example/profile/card#me')
store.add(subject, ns.vcard('fn'), lit('Another Person'), subject.doc())
+ podOf(subject.value, storage.value)
const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
const mainContent = document.createElement('main')
mainContent.id = 'MainContent'
- const navbar = document.createElement('solid-panes-navbar') as HTMLElement & {
- navbarItems?: Array<{ paneName: string, paneUri: string, profileSubjectUri: string }>
- }
- navbar.navbarItems = [{
- paneName: 'folder',
- paneUri: storage.value,
- profileSubjectUri: subject.value
- }]
- mainContent.appendChild(navbar)
document.body.appendChild(mainContent)
window.history.replaceState({
paneName: 'folder',
@@ -566,18 +571,12 @@ describe('page-level profile heading', () => {
it('hides another person\'s heading when the account menu opens the viewer\'s storage', async () => {
const subject = sym(`${window.location.origin}/other/profile/card#me`)
const viewer = sym(`${window.location.origin}/profile/card#me`)
+ const viewerStorage = `${window.location.origin}/storage/`
+ const ownStorages = [1, 2].map(index => `${window.location.origin}/other/storage-${index}/`)
+ podOf(subject.value, ...ownStorages)
const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
const mainContent = document.createElement('main')
mainContent.id = 'MainContent'
- const navbar = document.createElement('solid-panes-navbar') as HTMLElement & {
- navbarItems: Array<{ paneName: string, paneUri: string, profileSubjectUri: string }>
- }
- navbar.navbarItems = [1, 2].map(index => ({
- paneName: 'folder',
- paneUri: `${window.location.origin}/other/storage-${index}/`,
- profileSubjectUri: subject.value
- }))
- mainContent.appendChild(navbar)
document.body.appendChild(mainContent)
try {
@@ -586,25 +585,19 @@ describe('page-level profile heading', () => {
const mount = document.getElementById('profile-heading-mount')!
expect(mount.hidden).toBe(false)
- navbar.classList.add('navbar--hidden')
- window.history.replaceState({
- paneName: 'folder',
- paneUri: `${window.location.origin}/storage/`
- }, '', subject.value)
+ window.history.replaceState({ paneName: 'folder', paneUri: viewerStorage, viaAccountMenu: true }, '', subject.value)
await refreshProfileHeading()
expect(window.location.href).toBe(subject.value)
expect(mount.hidden).toBe(true)
expect(mount.firstElementChild).toBeNull()
- navbar.classList.remove('navbar--hidden')
+ // The same storage opened another way is just a view of a page in their pod
+ window.history.replaceState({ paneName: 'folder', paneUri: viewerStorage }, '', subject.value)
await refreshProfileHeading()
- expect(mount.hidden).toBe(true)
+ expect(mount.hidden).toBe(false)
- window.history.replaceState({
- paneName: 'folder',
- paneUri: navbar.navbarItems[1].paneUri
- }, '', subject.value)
+ window.history.replaceState({ paneName: 'folder', paneUri: ownStorages[1] }, '', subject.value)
await refreshProfileHeading()
expect(mount.hidden).toBe(false)
expect(mount.firstElementChild).not.toBeNull()
@@ -617,15 +610,10 @@ describe('page-level profile heading', () => {
const other = sym(`${window.location.origin}/other/profile/card#me`)
const viewer = sym(`${window.location.origin}/profile/card#me`)
store.add(other, ns.vcard('fn'), lit('Pod Owner'), other.doc())
+ podOf(other.value)
const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
const mainContent = document.createElement('main')
mainContent.id = 'MainContent'
- const navbar = document.createElement('solid-panes-navbar') as HTMLElement & { navbarItems: object[] }
- navbar.navbarItems = [
- { paneName: 'profile', paneUri: other.value, profileSubjectUri: other.value },
- { paneName: 'social', paneUri: other.value, profileSubjectUri: other.value }
- ]
- mainContent.appendChild(navbar)
document.body.appendChild(mainContent)
try {
@@ -653,21 +641,39 @@ describe('page-level profile heading', () => {
}
})
+ it('shows the heading even when the navbar does not exist yet', async () => {
+ const other = sym(`${window.location.origin}/other/profile/card#me`)
+ store.add(other, ns.vcard('fn'), lit('Early Owner'), other.doc())
+ podOf(other.value)
+ const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({}, '', '/other/container/')
+
+ try {
+ expect(document.querySelector('solid-panes-navbar')).toBeNull()
+ await refreshProfileHeading(true)
+
+ expect(document.getElementById('profile-heading-mount')?.hidden).toBe(false)
+ } finally {
+ currentUser.mockRestore()
+ }
+ })
+
it.each([
['the viewer\'s own pod', {}, 'viewer'],
['an account-menu view', { paneName: 'home', viaAccountMenu: true }, 'other'],
- ['an account-menu storage view', { paneName: 'folder', paneUri: 'https://x.example/s/', viaAccountMenu: true }, 'other']
+ ['an account-menu storage view', { paneName: 'folder', paneUri: 'https://x.example/s/', viaAccountMenu: true }, 'other'],
+ ['a pod without a known owner', {}, 'unknown']
])('hides the heading on a container for %s', async (_name, state, owner) => {
const other = sym(`${window.location.origin}/other/profile/card#me`)
const viewer = sym(`${window.location.origin}/profile/card#me`)
const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
const mainContent = document.createElement('main')
mainContent.id = 'MainContent'
- const navbar = document.createElement('solid-panes-navbar') as HTMLElement & { navbarItems: object[] }
- const ownerUri = owner === 'viewer' ? viewer.value : other.value
- navbar.navbarItems = [{ paneName: 'profile', paneUri: ownerUri, profileSubjectUri: ownerUri }]
- mainContent.appendChild(navbar)
document.body.appendChild(mainContent)
+ podOf(owner === 'viewer' ? viewer.value : owner === 'other' ? other.value : undefined)
try {
window.history.replaceState(state, '', '/other/container/')
@@ -857,4 +863,56 @@ describe('page-level profile heading', () => {
vi.unstubAllGlobals()
}
})
+
+ describe('following navigations', () => {
+ async function setup () {
+ const subject = sym('https://announced.example/profile/card#me')
+ store.add(subject, ns.vcard('fn'), lit('Announced Person'), subject.doc())
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ initializeProfileHeading()
+ await refreshProfileHeading(true)
+ const mount = document.getElementById('profile-heading-mount')!
+ expect(mount.hidden).toBe(true)
+ return { subject, mainContent, mount }
+ }
+
+ it('refreshes when a navigation is announced, whoever made it', async () => {
+ const { subject, mount } = await setup()
+
+ window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/')
+ announceNavigation(document)
+
+ await vi.waitFor(() => expect(mount.hidden).toBe(false))
+ const heading = mount.querySelector('solid-panes-profile-heading') as HTMLElement & { profileData?: { name: string } }
+ expect(heading.profileData?.name).toBe('Announced Person')
+ })
+
+ it('does not look at what the page has been changed to without a navigation', async () => {
+ const { subject, mainContent, mount } = await setup()
+ window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/')
+ announceNavigation(document)
+ await vi.waitFor(() => expect(mount.hidden).toBe(false))
+
+ window.history.replaceState({}, '', '/')
+ mainContent.appendChild(document.createElement('div'))
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ await new Promise(resolve => setTimeout(resolve, 20))
+
+ expect(mount.hidden).toBe(false)
+ })
+
+ it('places the heading above a navbar that was added after it', async () => {
+ const { mainContent, mount } = await setup()
+ const navbar = document.createElement('solid-panes-navbar')
+ mainContent.insertBefore(navbar, mainContent.firstChild)
+ expect(mount.nextElementSibling).not.toBe(navbar)
+
+ placeProfileHeading()
+
+ expect(mount.nextElementSibling).toBe(navbar)
+ expect(mainContent.firstElementChild).toBe(mount)
+ })
+ })
})
diff --git a/test/unit/mainPage/reload.test.ts b/test/unit/mainPage/reload.test.ts
index 6ec2f20d..b616efb0 100644
--- a/test/unit/mainPage/reload.test.ts
+++ b/test/unit/mainPage/reload.test.ts
@@ -1,10 +1,12 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { authn, authSession, store } from 'solid-logic'
import { lit, sym } from 'rdflib'
import { ns } from 'solid-ui'
-const { getViewerModeMock, byName } = vi.hoisted(() => ({
+const { getViewerModeMock, loadProfile, outlinerHolder, byName } = vi.hoisted(() => ({
getViewerModeMock: vi.fn(),
+ loadProfile: vi.fn(),
+ outlinerHolder: { current: undefined as undefined | { GotoSubject: (...args: unknown[]) => void } },
byName: (name: string) => ({
name,
icon: '',
@@ -25,11 +27,11 @@ vi.mock('../../../src/components/profile-heading/profileHeadingData', async (imp
}))
vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({
...await importOriginal(),
- loadProfileFromURI: async (subject: ReturnType) => subject
+ loadProfileFromURI: loadProfile
}))
-vi.mock('../../../src/utils/podUtils', () => ({
+vi.mock('../../../src/utils/podUtils', async (importOriginal) => ({
+ ...await importOriginal(),
getPodStorages: async () => [],
- isInViewerPod: async () => false,
loadContainerRepresentation: async () => undefined
}))
vi.mock('../../../src/mainPage/header', () => ({ createHeader: vi.fn() }))
@@ -37,14 +39,27 @@ vi.mock('../../../src/index', async () => {
const { default: OutlineManager } = await import('../../../src/outline/manager')
const paneRegistry = { list: [], byName }
return {
- getOutliner: () => new OutlineManager({ dom: document, session: { store, paneRegistry } })
+ getOutliner: () => {
+ const outliner = new OutlineManager({ dom: document, session: { store, paneRegistry } })
+ outlinerHolder.current = outliner
+ return outliner
+ }
}
})
import { initMainPage } from '../../../src/mainPage'
+import { resetPodContexts } from '../../../src/navigation/podContext'
+
+// Without a profile of its own, the lookup answers with the URL it was asked about
+const noProfile = async (subject: ReturnType) => subject
+
+beforeEach(() => {
+ loadProfile.mockImplementation(noProfile)
+})
afterEach(() => {
vi.restoreAllMocks()
+ resetPodContexts()
document.body.replaceChildren()
window.history.replaceState({}, '', '/')
})
@@ -77,6 +92,7 @@ describe('page reload of a WebID', () => {
navbarItems: Array<{ paneName?: string, selected?: boolean }>
}
+ expect(mount.nextElementSibling).toBe(navbar)
expect(window.location.href).toBe(other.value)
expect(window.history.state).toMatchObject({ paneName: 'profile', paneUri: other.value })
expect(window.history.state).not.toHaveProperty('viaAccountMenu')
@@ -86,3 +102,140 @@ describe('page reload of a WebID', () => {
expect(navbar.navbarItems.filter(item => item.selected).map(item => item.paneName)).toEqual(['profile'])
})
})
+
+describe('page reload of the document that holds a WebID', () => {
+ it('shows the document and its content like any other resource, not the profile', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ const webId = sym(`${window.location.origin}/other/profile/card#me`)
+ loadProfile.mockResolvedValue(webId)
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockImplementation(() => {})
+ window.history.replaceState({ paneName: 'social', paneUri: webId.value }, '', webId.doc().uri)
+
+ await initMainPage(store)
+ const provider = await vi.waitFor(() => {
+ const element = document.querySelector('file-explorer-provider')
+ expect(element).not.toBeNull()
+ return element!
+ })
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & {
+ navbarItems: Array<{ paneName?: string, selected?: boolean }>
+ }
+
+ const mount = document.getElementById('profile-heading-mount')!
+ await vi.waitFor(() => expect(mount.querySelector('solid-panes-profile-heading')).not.toBeNull())
+ expect(mount.hidden).toBe(false)
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ expect(window.location.href).toBe(webId.doc().uri)
+ expect(provider.subjectUri).toBe(webId.doc().uri)
+ expect(provider.showHeader).toBe(true)
+ expect(provider.pane?.name).not.toBe('profile')
+ expect(window.history.state?.paneName).toBeUndefined()
+ expect(navbar.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it('leaves other pages of the pod showing what they show', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/other/profile/card#me`))
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockImplementation(() => {})
+ window.history.replaceState({}, '', '/other/docs/')
+
+ await initMainPage(store)
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & {
+ navbarItems: Array<{ selected?: boolean }>
+ }
+
+ expect(window.history.state?.paneName).toBeUndefined()
+ expect(navbar.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it.each([
+ ['a container', '/other/docs/'],
+ ['a resource', '/other/docs/notes.ttl']
+ ])('shows %s with its file explorer header, even after a storage was selected', async (_name, url) => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/other/profile/card#me`))
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockImplementation(() => {})
+ window.history.replaceState({ paneName: 'folder', paneUri: `${window.location.origin}/other/` }, '', url)
+
+ await initMainPage(store)
+ const provider = await vi.waitFor(() => {
+ const element = document.querySelector('file-explorer-provider')
+ expect(element).not.toBeNull()
+ return element!
+ })
+
+ expect(provider.subjectUri).toBe(`${window.location.origin}${url}`)
+ expect(provider.showHeader).toBe(true)
+ expect(window.history.state?.paneName).toBeUndefined()
+ expect(window.location.pathname).toBe(url)
+ })
+})
+
+describe('a link into another pod, made by a package that does not use the controller', () => {
+ const ownerOf = (pod: string) => sym(`${window.location.origin}/${pod}/profile/card#me`)
+
+ it('brings the heading and the navbar along to the other pod', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ store.add(ownerOf('a'), ns.vcard('fn'), lit('Owner A'), ownerOf('a').doc())
+ store.add(ownerOf('b'), ns.vcard('fn'), lit('Owner B'), ownerOf('b').doc())
+ loadProfile.mockImplementation(async (subject: ReturnType) => {
+ const [pod] = new URL(subject.uri).pathname.split('/').filter(Boolean)
+ return ownerOf(pod)
+ })
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockImplementation(() => {})
+ getViewerModeMock.mockResolvedValue('visitor')
+ window.history.replaceState({}, '', ownerOf('a').uri)
+
+ await initMainPage(store)
+ const mount = document.getElementById('profile-heading-mount')!
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & { navbarItems: Array<{ paneUri?: string }> }
+ const heading = () => mount.querySelector('solid-panes-profile-heading') as HTMLElement & { profileData?: { name: string }, compact?: boolean }
+ await vi.waitFor(() => expect(heading()?.profileData?.name).toBe('Owner A'))
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('a').value, ownerOf('a').value])
+
+ // As the links and buttons in solid-ui do
+ outlinerHolder.current!.GotoSubject(store.sym(`${window.location.origin}/b/docs/`), true, undefined, true)
+
+ await vi.waitFor(() => expect(heading()?.profileData?.name).toBe('Owner B'))
+ expect(heading().compact).toBe(true)
+ await vi.waitFor(() => {
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('b').value, ownerOf('b').value])
+ })
+ expect(window.location.pathname).toBe('/b/docs/')
+ })
+
+ it('takes the navbar back to the first pod when going back', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ store.add(ownerOf('a'), ns.vcard('fn'), lit('Owner A'), ownerOf('a').doc())
+ store.add(ownerOf('b'), ns.vcard('fn'), lit('Owner B'), ownerOf('b').doc())
+ loadProfile.mockImplementation(async (subject: ReturnType) => {
+ const [pod] = new URL(subject.uri).pathname.split('/').filter(Boolean)
+ return ownerOf(pod)
+ })
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockImplementation(() => {})
+ getViewerModeMock.mockResolvedValue('visitor')
+ window.history.replaceState({}, '', `${window.location.origin}/a/`)
+
+ await initMainPage(store)
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & { navbarItems: Array<{ paneUri?: string }> }
+ const owners = () => navbar.navbarItems.map(item => item.paneUri)
+ await vi.waitFor(() => expect(owners()).toEqual([ownerOf('a').value, ownerOf('a').value]))
+
+ outlinerHolder.current!.GotoSubject(store.sym(`${window.location.origin}/b/docs/`), true, undefined, true)
+ await vi.waitFor(() => expect(owners()).toEqual([ownerOf('b').value, ownerOf('b').value]))
+
+ window.history.back()
+ await vi.waitFor(() => expect(window.location.pathname).toBe('/a/'))
+ await vi.waitFor(() => expect(owners()).toEqual([ownerOf('a').value, ownerOf('a').value]))
+ })
+})
diff --git a/test/unit/navigation/browserEvents.test.ts b/test/unit/navigation/browserEvents.test.ts
new file mode 100644
index 00000000..e9431dab
--- /dev/null
+++ b/test/unit/navigation/browserEvents.test.ts
@@ -0,0 +1,159 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { NavigationController } from '../../../src/navigation/controller'
+
+type Listener = (event: unknown) => void
+
+const listeners: Record = {}
+let show: ReturnType
+let controller: NavigationController
+
+function defineNavigationApi () {
+ Object.defineProperty(window, 'navigation', {
+ configurable: true,
+ value: { addEventListener: (type: string, listener: Listener) => { listeners[type] = listener } }
+ })
+}
+
+// The module keeps the listeners it added, so each test starts from a fresh copy of it
+async function freshModule () {
+ vi.resetModules()
+ return import('../../../src/navigation/browserEvents')
+}
+
+function enter (overrides: object = {}) {
+ const intercept = vi.fn()
+ listeners.navigate({
+ canIntercept: true,
+ userInitiated: true,
+ navigationType: 'replace',
+ destination: { url: window.location.href },
+ intercept,
+ ...overrides
+ })
+ return intercept
+}
+
+const traverse = (navigationType = 'traverse') => listeners.currententrychange({ navigationType })
+const nextTask = () => new Promise(resolve => setTimeout(resolve, 5))
+
+beforeEach(() => {
+ for (const key of Object.keys(listeners)) delete listeners[key]
+ show = vi.fn().mockResolvedValue(undefined)
+ controller = { show } as unknown as NavigationController
+ defineNavigationApi()
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://pod.example/', viaAccountMenu: true, other: 'kept' }, '', '/page')
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ Reflect.deleteProperty(window, 'navigation')
+ window.history.replaceState(null, '', '/')
+})
+
+describe('Enter on the current URL', () => {
+ it('shows what the URL is, after forgetting what the entry displayed', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ const intercept = enter()
+ expect(intercept).toHaveBeenCalledOnce()
+ expect(intercept.mock.calls[0][0]).toMatchObject({ scroll: 'manual', focusReset: 'manual' })
+ await intercept.mock.calls[0][0].handler()
+
+ expect(window.history.state).toEqual({ other: 'kept' })
+ expect(show).toHaveBeenCalledWith({ kind: 'restore', forceHeading: true })
+ })
+
+ it.each([
+ ['not started by the user', { userInitiated: false }],
+ ['not a replacement of the entry', { navigationType: 'push' }],
+ ['one that cannot be intercepted', { canIntercept: false }],
+ ['to another URL', { destination: { url: 'https://elsewhere.example/' } }]
+ ])('leaves a navigation alone when it is %s', async (_name, overrides) => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ expect(enter(overrides)).not.toHaveBeenCalled()
+ expect(show).not.toHaveBeenCalled()
+ expect(window.history.state).toMatchObject({ paneName: 'folder' })
+ })
+})
+
+describe('back and forward', () => {
+ it('shows what the URL is, after forgetting what the entry displayed', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ traverse()
+ await nextTask()
+
+ expect(window.history.state).toEqual({ other: 'kept' })
+ expect(show).toHaveBeenCalledWith({ kind: 'restore', forceHeading: true })
+ })
+
+ it.each(['replace', 'push', 'reload'])('ignores an entry change of type %s', async (type) => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ traverse(type)
+ await nextTask()
+
+ expect(show).not.toHaveBeenCalled()
+ expect(window.history.state).toMatchObject({ paneName: 'folder' })
+ })
+})
+
+describe('without the Navigation API', () => {
+ beforeEach(() => {
+ Reflect.deleteProperty(window, 'navigation')
+ })
+
+ it('shows what the URL is on popstate', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ await nextTask()
+
+ expect(window.history.state).toEqual({ other: 'kept' })
+ expect(show).toHaveBeenCalledOnce()
+ })
+})
+
+describe('whether the page handles browser navigation', () => {
+ it('says no before the listeners are registered and yes afterwards', async () => {
+ const { registerBrowserNavigation, isHandlingBrowserNavigation } = await freshModule()
+ expect(isHandlingBrowserNavigation()).toBe(false)
+
+ registerBrowserNavigation(controller)
+
+ expect(isHandlingBrowserNavigation()).toBe(true)
+ })
+})
+
+describe('registering', () => {
+ it('adds the listeners once, and uses the controller registered last', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ const addEventListener = vi.spyOn(window.navigation as never as { addEventListener: () => void }, 'addEventListener')
+ const second = { show: vi.fn().mockResolvedValue(undefined) }
+
+ registerBrowserNavigation(controller)
+ registerBrowserNavigation(second as unknown as NavigationController)
+
+ expect(addEventListener).toHaveBeenCalledTimes(2)
+ await enter().mock.calls[0][0].handler()
+ expect(show).not.toHaveBeenCalled()
+ expect(second.show).toHaveBeenCalledOnce()
+ })
+
+ it('reports a failure to show the page instead of leaving it unhandled', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ show.mockRejectedValue(new Error('no pane'))
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ await enter().mock.calls[0][0].handler()
+
+ expect(error).toHaveBeenCalledWith('Failed to show the page after browser navigation.', expect.any(Error))
+ })
+})
diff --git a/test/unit/navigation/chrome.test.ts b/test/unit/navigation/chrome.test.ts
new file mode 100644
index 00000000..944c0a29
--- /dev/null
+++ b/test/unit/navigation/chrome.test.ts
@@ -0,0 +1,208 @@
+import { describe, expect, it } from 'vitest'
+import {
+ deriveExplorerHeader,
+ deriveHeading,
+ deriveNavbarSelection,
+ explorerHeaderForPane,
+ isNavbarHidden,
+ isNavbarItemSelected,
+ isPersonPane,
+ pickNavbarItem,
+ type HeadingInput
+} from '../../../src/navigation/chrome'
+import { viewFromPane } from '../../../src/navigation/state'
+
+const viewerUri = 'https://pod.example/me/profile/card#me'
+const ownerUri = 'https://pod.example/owner/profile/card#me'
+const pageUrl = 'https://pod.example/owner/container/'
+
+const ownerStorage = 'https://pod.example/owner/'
+
+function heading (overrides: Partial & { pane?: string, subject?: string } = {}) {
+ const { pane, subject, ...rest } = overrides
+ return deriveHeading({
+ view: viewFromPane(pane, subject),
+ viaAccountMenu: false,
+ viewer: viewerUri,
+ pageUrl,
+ owner: ownerUri,
+ storageUris: [ownerStorage],
+ ...rest
+ })
+}
+
+describe('isPersonPane', () => {
+ it.each([['profile', true], ['social', true], ['folder', false], ['home', false], [undefined, false]])(
+ '%s -> %s', (pane, expected) => {
+ expect(isPersonPane(pane)).toBe(expected)
+ }
+ )
+})
+
+describe('deriveHeading', () => {
+ describe('profile and friends panes present their own subject', () => {
+ it('is full for the profile pane and compact for friends', () => {
+ expect(heading({ pane: 'profile', subject: ownerUri })).toEqual({ mode: 'full', subjectUri: ownerUri, paneName: 'profile' })
+ expect(heading({ pane: 'social', subject: ownerUri })).toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'social' })
+ })
+
+ it('presents the saved subject, else the navbar entry\'s profile, else the page', () => {
+ const other = 'https://pod.example/saved/profile/card#me'
+ expect(heading({ pane: 'profile', subject: other })).toMatchObject({ subjectUri: other })
+ expect(heading({ pane: 'profile' })).toMatchObject({ subjectUri: ownerUri })
+ expect(heading({ pane: 'profile', owner: undefined })).toMatchObject({ subjectUri: pageUrl })
+ })
+
+ it('does not depend on who is logged in or where the view came from', () => {
+ const own = heading({ pane: 'profile', subject: viewerUri, viewer: viewerUri, viaAccountMenu: true })
+
+ expect(own).toEqual({ mode: 'full', subjectUri: viewerUri, paneName: 'profile' })
+ })
+ })
+
+ describe('other panes show the owner of the pod the page is in', () => {
+ it.each([undefined, 'folder', 'source', 'dataContents'])('shows a compact heading for pane %s', pane => {
+ expect(heading({ pane })).toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: pane })
+ })
+
+ it('is hidden when the owner is the viewer', () => {
+ expect(heading({ viewer: ownerUri })).toEqual({ mode: 'none' })
+ })
+
+ it('shows the owner to a visitor who is not logged in, but nothing without an owner', () => {
+ expect(heading({ viewer: undefined })).toMatchObject({ mode: 'compact' })
+ expect(heading({ viewer: undefined, owner: undefined })).toEqual({ mode: 'none' })
+ })
+
+ it('is hidden for account-menu views, which show the viewer\'s content', () => {
+ expect(heading({ pane: 'home', viaAccountMenu: true })).toEqual({ mode: 'none' })
+ expect(heading({ viaAccountMenu: true })).toEqual({ mode: 'none' })
+ })
+
+ it('is hidden for the viewer\'s storage opened from the account menu', () => {
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/me/', viaAccountMenu: true })).toEqual({ mode: 'none' })
+ })
+
+ it('is hidden for the viewer\'s own storage opened from the account menu even when the pod has storages', () => {
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/me/', viaAccountMenu: true, storageUris: [ownerStorage] }))
+ .toEqual({ mode: 'none' })
+ })
+
+ it('shows the owner for a storage of the pod, whichever way it was opened', () => {
+ expect(heading({ pane: 'folder', subject: ownerStorage, viaAccountMenu: true, viewer: viewerUri }))
+ .toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'folder' })
+ expect(heading({ pane: 'folder', viaAccountMenu: true }))
+ .toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'folder' })
+ })
+
+ it('shows the owner for a storage the pod does not list, unless the account menu opened it', () => {
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/elsewhere/' }))
+ .toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'folder' })
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/elsewhere/', viaAccountMenu: true }))
+ .toEqual({ mode: 'none' })
+ })
+
+ it('is hidden without a known owner of the pod', () => {
+ expect(heading({ owner: undefined })).toEqual({ mode: 'none' })
+ expect(heading({ owner: undefined, storageUris: [] })).toEqual({ mode: 'none' })
+ })
+ })
+})
+
+describe('navbar', () => {
+ describe('deriveNavbarSelection', () => {
+ it('selects the displayed pane and its subject', () => {
+ expect(deriveNavbarSelection(viewFromPane('folder', 'https://pod.example/s/'), false))
+ .toEqual({ paneName: 'folder', paneUri: 'https://pod.example/s/' })
+ })
+
+ it('selects Profile on a WebID, and nothing on other pages', () => {
+ expect(deriveNavbarSelection(viewFromPane(), true)).toEqual({ paneName: 'profile', paneUri: undefined })
+ expect(deriveNavbarSelection(viewFromPane(), false)).toEqual({ paneName: undefined, paneUri: undefined })
+ })
+
+ it('prefers the displayed pane over the WebID default', () => {
+ expect(deriveNavbarSelection(viewFromPane('social', ownerUri), true).paneName).toBe('social')
+ })
+ })
+
+ describe('isNavbarItemSelected', () => {
+ const storage = { paneName: 'folder', paneUri: 'https://pod.example/a/' }
+
+ it('selects profile and friends by pane alone', () => {
+ expect(isNavbarItemSelected({ paneName: 'profile' }, { paneName: 'profile' }, 0)).toBe(true)
+ expect(isNavbarItemSelected({ paneName: 'social' }, { paneName: 'profile' }, 0)).toBe(false)
+ })
+
+ it('selects a storage by its URI', () => {
+ expect(isNavbarItemSelected(storage, { paneName: 'folder', paneUri: storage.paneUri }, 2)).toBe(true)
+ expect(isNavbarItemSelected(storage, { paneName: 'folder', paneUri: 'https://pod.example/b/' }, 2)).toBe(false)
+ })
+
+ it('selects a storage without a URI only when it is the single one', () => {
+ expect(isNavbarItemSelected(storage, { paneName: 'folder' }, 1)).toBe(true)
+ expect(isNavbarItemSelected(storage, { paneName: 'folder' }, 2)).toBe(false)
+ })
+
+ it('selects nothing when nothing is displayed', () => {
+ expect(isNavbarItemSelected({ paneName: 'profile' }, {}, 0)).toBe(false)
+ })
+ })
+
+ describe('pickNavbarItem', () => {
+ const items = [
+ { paneName: 'profile', paneUri: 'p' },
+ { paneName: 'folder', paneUri: 'a' },
+ { paneName: 'folder', paneUri: 'b' }
+ ]
+
+ it('prefers the item for that subject, else the first for that pane', () => {
+ expect(pickNavbarItem(items, 'folder', 'b')).toBe(items[2])
+ expect(pickNavbarItem(items, 'folder', 'unknown')).toBe(items[1])
+ expect(pickNavbarItem(items, 'folder')).toBe(items[1])
+ })
+
+ it('finds nothing for a pane the navbar does not have', () => {
+ expect(pickNavbarItem(items, 'social')).toBeUndefined()
+ })
+ })
+
+ describe('isNavbarHidden', () => {
+ it.each([
+ [false, false, false, false],
+ [true, false, false, true],
+ [false, true, false, true],
+ [true, true, false, true],
+ [false, false, true, true],
+ [true, false, true, true]
+ ])('account menu %s, own profile %s, own pod %s -> hidden %s', (viaAccountMenu, ownProfile, inViewerPod, hidden) => {
+ expect(isNavbarHidden({ viaAccountMenu, ownProfile, inViewerPod })).toBe(hidden)
+ })
+ })
+})
+
+describe('file explorer header', () => {
+ const resource = { isRootResource: false, isWebId: false, viewerPodOnly: false, inViewerPod: false }
+
+ it('is shown for an ordinary resource', () => {
+ expect(deriveExplorerHeader(resource)).toBe(true)
+ })
+
+ it('is never shown for root resources or WebIDs', () => {
+ expect(deriveExplorerHeader({ ...resource, isRootResource: true, inViewerPod: true })).toBe(false)
+ expect(deriveExplorerHeader({ ...resource, isWebId: true, inViewerPod: true })).toBe(false)
+ })
+
+ it('is shown for views of the viewer\'s own content only inside the viewer\'s pod', () => {
+ expect(deriveExplorerHeader({ ...resource, viewerPodOnly: true, inViewerPod: false })).toBe(false)
+ expect(deriveExplorerHeader({ ...resource, viewerPodOnly: true, inViewerPod: true })).toBe(true)
+ })
+
+ it('is never shown over profile or friends panes', () => {
+ expect(explorerHeaderForPane(true, 'profile')).toBe(false)
+ expect(explorerHeaderForPane(true, 'social')).toBe(false)
+ expect(explorerHeaderForPane(true, 'folder')).toBe(true)
+ expect(explorerHeaderForPane(true, undefined)).toBe(true)
+ expect(explorerHeaderForPane(false, 'folder')).toBe(false)
+ })
+})
diff --git a/test/unit/navigation/controller.test.ts b/test/unit/navigation/controller.test.ts
new file mode 100644
index 00000000..d5539b57
--- /dev/null
+++ b/test/unit/navigation/controller.test.ts
@@ -0,0 +1,270 @@
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import { store } from 'solid-logic'
+import type { OutlineManager } from '../../../src/outline/manager'
+import {
+ createNavigationController,
+ type NavigationController,
+ type NavigationDeps,
+ type NavigationEffects
+} from '../../../src/navigation/controller'
+import { resolveSource } from '../../../src/navigation/options'
+
+const { getProfilePane, getSocialPane, getFolderPane, isWebId } = vi.hoisted(() => ({
+ getProfilePane: vi.fn(),
+ getSocialPane: vi.fn(),
+ getFolderPane: vi.fn(),
+ isWebId: vi.fn()
+}))
+
+vi.mock('../../../src/utils/paneUtils', () => ({
+ getProfilePaneFromURI: getProfilePane,
+ getSocialPaneFromURI: getSocialPane,
+ getFolderPaneForStorage: getFolderPane
+}))
+vi.mock('../../../src/utils/webIdUtils', () => ({ isWebIdUri: isWebId }))
+
+const owner = 'https://pod.example/timea/profile/card#me'
+const profilePane = { name: 'profile', subject: store.sym(owner) }
+const socialPane = { name: 'social', subject: store.sym(owner) }
+const storagePane = { name: 'folder', paneName: 'folder', subject: store.sym('https://pod.example/timea/') }
+const homePane = { name: 'home' }
+
+let calls: string[]
+let GotoSubject: Mock<(...args: any[]) => void>
+let selectNavbarPane: Mock
+let refreshHeading: Mock
+let byName: Mock
+let controller: NavigationController
+let navbar: HTMLElement
+
+beforeEach(() => {
+ calls = []
+ // Records the displayed pane in history.state, as the outline manager does
+ GotoSubject = vi.fn((_subject, _expand, pane, _solo, _referrer, _table, options) => {
+ calls.push('GotoSubject')
+ const next: Record = { ...window.history.state }
+ if (pane) {
+ next.paneName = pane.name
+ if (pane.subject) next.paneUri = pane.subject.value
+ }
+ if (resolveSource(options) === 'accountMenu') next.viaAccountMenu = true
+ else delete next.viaAccountMenu
+ window.history.replaceState(next, '', window.location.href)
+ })
+ selectNavbarPane = vi.fn(() => { calls.push('select') })
+ refreshHeading = vi.fn(async () => { calls.push('heading') })
+ byName = vi.fn((name: string) => (name === 'home' ? homePane as never : null))
+ controller = createNavigationController({
+ store,
+ outliner: { GotoSubject } as unknown as OutlineManager,
+ byName,
+ effects: { selectNavbarPane, refreshHeading }
+ })
+ navbar = document.createElement('solid-panes-navbar')
+ document.body.appendChild(navbar)
+
+ getProfilePane.mockResolvedValue(profilePane)
+ getSocialPane.mockResolvedValue(socialPane)
+ getFolderPane.mockResolvedValue(storagePane)
+ isWebId.mockReturnValue(false)
+ window.history.replaceState({}, '', '/timea2/docs/')
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.resetAllMocks()
+ document.body.replaceChildren()
+ window.history.replaceState(null, '', '/')
+})
+
+const sourceOfCall = (index = 0) => resolveSource(GotoSubject.mock.calls[index][6])
+
+describe('showing a profile, friends or storage', () => {
+ it('displays the person\'s pane for the page URL, then selects it in the navbar and refreshes the heading', async () => {
+ await controller.show({ kind: 'profile', subject: owner })
+
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(owner))
+ expect(GotoSubject).toHaveBeenCalledWith(
+ store.sym(window.location.href), true, profilePane, true, undefined, undefined, { source: 'default' }
+ )
+ expect(selectNavbarPane).toHaveBeenCalledWith('profile', owner)
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ expect(calls).toEqual(['GotoSubject', 'select', 'heading'])
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ })
+
+ it('does the same for friends and for a storage', async () => {
+ await controller.show({ kind: 'friends', subject: owner })
+ await controller.show({ kind: 'storage', pane: storagePane as never })
+
+ expect(GotoSubject.mock.calls.map(call => call[2])).toEqual([socialPane, storagePane])
+ expect(selectNavbarPane.mock.calls).toEqual([['social', owner], ['folder', 'https://pod.example/timea/']])
+ })
+
+ it('displays a pane of the application by its registry name', async () => {
+ await controller.show({ kind: 'app', pane: 'home' })
+
+ expect(byName).toHaveBeenCalledWith('home')
+ expect(GotoSubject.mock.calls[0][2]).toBe(homePane)
+ expect(selectNavbarPane).toHaveBeenCalledWith('home', undefined)
+ })
+})
+
+describe('when the account menu asks', () => {
+ it('hides the navbar, remembers the source and does not select anything in it', async () => {
+ await controller.show({ kind: 'profile', subject: owner, source: 'accountMenu' })
+
+ expect(sourceOfCall()).toBe('accountMenu')
+ expect(window.history.state).toMatchObject({ viaAccountMenu: true })
+ expect(navbar).toHaveClass('navbar--hidden')
+ expect(selectNavbarPane).not.toHaveBeenCalled()
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ })
+
+ it('hides the navbar before the pane has been resolved', async () => {
+ let finish!: (pane: unknown) => void
+ getProfilePane.mockReturnValue(new Promise(resolve => { finish = resolve }))
+
+ const shown = controller.show({ kind: 'profile', subject: owner, source: 'accountMenu' })
+
+ expect(navbar).toHaveClass('navbar--hidden')
+ expect(GotoSubject).not.toHaveBeenCalled()
+ finish(profilePane)
+ await shown
+ })
+
+ it('shows the navbar again for the page\'s own navigation', async () => {
+ navbar.classList.add('navbar--hidden')
+
+ await controller.show({ kind: 'friends', subject: owner })
+ // The outline manager reveals the navbar; the fake outliner here does not, so only check intent
+ expect(sourceOfCall()).toBe('default')
+ })
+})
+
+describe('when a pane cannot be built', () => {
+ it('displays nothing rather than the page URL\'s default view, and says so', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ getProfilePane.mockResolvedValue(null)
+
+ await controller.show({ kind: 'profile', subject: owner, source: 'accountMenu' })
+
+ expect(GotoSubject).not.toHaveBeenCalled()
+ expect(refreshHeading).not.toHaveBeenCalled()
+ expect(warn).toHaveBeenCalledOnce()
+ })
+
+ it('treats an unknown application pane the same way', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+
+ await controller.show({ kind: 'app', pane: 'unknown' })
+
+ expect(GotoSubject).not.toHaveBeenCalled()
+ })
+})
+
+describe('restoring a history entry', () => {
+ it('displays what the current entry displays', async () => {
+ window.history.replaceState({ paneName: 'social', paneUri: owner }, '', '/timea2/docs/')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(getSocialPane).toHaveBeenCalledWith(store.sym(owner))
+ expect(GotoSubject.mock.calls[0][2]).toBe(socialPane)
+ expect(selectNavbarPane).toHaveBeenCalledWith('social', owner)
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ })
+
+ it.each([
+ ['home', 'a pane of the application', homePane],
+ ['source', 'a pane the registry does not know', undefined]
+ ])('restores the registry pane %s without a subject of its own (%s)', async (name, _what, expected) => {
+ byName.mockImplementation((requested: string) => requested === 'home' ? homePane as never : null)
+ window.history.replaceState({ paneName: name }, '', '/timea2/docs/')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(GotoSubject.mock.calls[0][2]).toBe(expected)
+ })
+
+ it('shows the profile on a WebID whose entry displayed nothing', async () => {
+ isWebId.mockReturnValue(true)
+ window.history.replaceState({}, '', '/timea/profile/card')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(isWebId).toHaveBeenCalledWith(store.sym(`${window.location.origin}/timea/profile/card`))
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(`${window.location.origin}/timea/profile/card`))
+ expect(GotoSubject.mock.calls[0][2]).toBe(profilePane)
+ })
+
+ it('shows the page\'s default view when nothing was displayed and it is not a WebID', async () => {
+ await controller.show({ kind: 'restore' })
+
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(selectNavbarPane).not.toHaveBeenCalled()
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ })
+
+ it('still shows the default view when the entry names a pane that cannot be built', async () => {
+ getFolderPane.mockResolvedValue(null)
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://pod.example/timea/' }, '', '/timea2/docs/')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ })
+
+ it('uses the subject it is given instead of the page URL', async () => {
+ isWebId.mockReturnValue(true)
+
+ await controller.show({ kind: 'restore', subject: 'https://other.example/profile/card#me' })
+
+ expect(isWebId).toHaveBeenCalledWith(store.sym('https://other.example/profile/card#me'))
+ expect(GotoSubject.mock.calls[0][0]).toEqual(store.sym('https://other.example/profile/card#me'))
+ })
+
+ it('redoes the heading only when asked to', async () => {
+ await controller.show({ kind: 'restore', forceHeading: true })
+ await controller.show({ kind: 'restore' })
+
+ expect(refreshHeading.mock.calls).toEqual([[true], [false]])
+ })
+})
+
+describe('when intents overlap', () => {
+ it('displays only the latest while an earlier one is still resolving its pane', async () => {
+ let finishFirst!: (pane: unknown) => void
+ getProfilePane.mockReturnValueOnce(new Promise(resolve => { finishFirst = resolve }))
+
+ const first = controller.show({ kind: 'profile', subject: owner })
+ const second = controller.show({ kind: 'friends', subject: owner })
+ await second
+ finishFirst(profilePane)
+ await first
+
+ expect(GotoSubject).toHaveBeenCalledOnce()
+ expect(GotoSubject.mock.calls[0][2]).toBe(socialPane)
+ expect(refreshHeading).toHaveBeenCalledOnce()
+ })
+
+ it('lets an account-menu choice win over a restore that was already under way', async () => {
+ let finishRestore!: (pane: unknown) => void
+ getProfilePane.mockReturnValueOnce(new Promise(resolve => { finishRestore = resolve }))
+ isWebId.mockReturnValue(true)
+
+ const restore = controller.show({ kind: 'restore' })
+ await controller.show({ kind: 'storage', pane: storagePane as never, source: 'accountMenu' })
+ finishRestore(profilePane)
+ await restore
+
+ expect(GotoSubject.mock.calls.map(call => call[2])).toEqual([storagePane])
+ })
+
+ it('does not hold up an intent because of an earlier one that already finished', async () => {
+ await controller.show({ kind: 'profile', subject: owner })
+ await controller.show({ kind: 'friends', subject: owner })
+
+ expect(GotoSubject).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/test/unit/navigation/events.test.ts b/test/unit/navigation/events.test.ts
new file mode 100644
index 00000000..a2781851
--- /dev/null
+++ b/test/unit/navigation/events.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { announceNavigation, NAVIGATED_EVENT, onNavigated, type NavigatedDetail } from '../../../src/navigation/events'
+
+afterEach(() => {
+ window.history.replaceState(null, '', '/')
+})
+
+describe('navigation events', () => {
+ it('announces the page URL and what the entry displays', () => {
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://a.example/', viaAccountMenu: true }, '', '/some/page')
+ const seen: NavigatedDetail[] = []
+ document.addEventListener(NAVIGATED_EVENT, event => seen.push((event as CustomEvent).detail), { once: true })
+
+ announceNavigation(document)
+
+ expect(seen).toEqual([{
+ url: `${window.location.origin}/some/page`,
+ state: { view: { kind: 'storage', pane: 'folder', subject: 'https://a.example/' }, viaAccountMenu: true }
+ }])
+ })
+
+ it('announces an entry that displays nothing in particular', () => {
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ announceNavigation(document)
+
+ expect(listener).toHaveBeenCalledWith({
+ url: window.location.href,
+ state: { view: { kind: 'default' }, viaAccountMenu: false }
+ })
+ stop()
+ })
+
+ it('stops calling a listener once it has been removed', () => {
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ announceNavigation(document)
+ stop()
+ announceNavigation(document)
+
+ expect(listener).toHaveBeenCalledOnce()
+ })
+})
diff --git a/test/unit/navigation/navbarVisibility.test.ts b/test/unit/navigation/navbarVisibility.test.ts
new file mode 100644
index 00000000..fd2b8531
--- /dev/null
+++ b/test/unit/navigation/navbarVisibility.test.ts
@@ -0,0 +1,48 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { isPageInViewerPod } from '../../../src/navigation/podContext'
+import { refreshNavbarVisibility, setNavbarHidden } from '../../../src/navigation/navbarVisibility'
+
+vi.mock('../../../src/navigation/podContext', () => ({
+ isPageInViewerPod: vi.fn()
+}))
+vi.mock('../../../src/utils/webIdUtils', () => ({
+ isOwnWebIdProfile: () => false
+}))
+
+beforeEach(() => {
+ document.body.appendChild(document.createElement('solid-panes-navbar'))
+})
+
+afterEach(() => {
+ setNavbarHidden(false)
+ vi.resetAllMocks()
+ document.body.replaceChildren()
+})
+
+describe('navbar ownership lookup races', () => {
+ it('ignores a lookup completed after a newer navigation', async () => {
+ let finishFirst!: (owned: boolean) => void
+ vi.mocked(isPageInViewerPod)
+ .mockReturnValueOnce(new Promise(resolve => { finishFirst = resolve }))
+ .mockResolvedValueOnce(true)
+
+ const first = refreshNavbarVisibility('https://other.example/file')
+ await refreshNavbarVisibility('https://own.example/file')
+ finishFirst(false)
+ await first
+
+ expect(document.querySelector('solid-panes-navbar')).toHaveClass('navbar--hidden')
+ })
+
+ it('does not undo account-menu hiding when an earlier lookup finishes', async () => {
+ let finish!: (owned: boolean) => void
+ vi.mocked(isPageInViewerPod).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
+
+ const pending = refreshNavbarVisibility('https://other.example/file')
+ setNavbarHidden(true)
+ finish(false)
+ await pending
+
+ expect(document.querySelector('solid-panes-navbar')).toHaveClass('navbar--hidden')
+ })
+})
diff --git a/test/unit/navigation/options.test.ts b/test/unit/navigation/options.test.ts
new file mode 100644
index 00000000..4b673a36
--- /dev/null
+++ b/test/unit/navigation/options.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { resolveSource } from '../../../src/navigation/options'
+
+describe('resolveSource', () => {
+ it.each([
+ [undefined, 'default'],
+ [{}, 'default'],
+ [{ source: 'default' }, 'default'],
+ [{ source: 'accountMenu' }, 'accountMenu']
+ ] as const)('reads the options %j as %s', (argument, expected) => {
+ expect(resolveSource(argument)).toBe(expected)
+ })
+
+ it('still reads the old showNavbar boolean', () => {
+ expect(resolveSource(true)).toBe('default')
+ expect(resolveSource(false)).toBe('accountMenu')
+ })
+
+ it('reads any other falsy value like the old default parameter did', () => {
+ expect(resolveSource(null)).toBe('accountMenu')
+ })
+})
diff --git a/test/unit/navigation/podContext.test.ts b/test/unit/navigation/podContext.test.ts
new file mode 100644
index 00000000..4d03f0bf
--- /dev/null
+++ b/test/unit/navigation/podContext.test.ts
@@ -0,0 +1,213 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { sym } from 'rdflib'
+import { authn } from 'solid-logic'
+import type { PaneItem } from '../../../src/utils/paneUtils'
+
+const { loadProfile, getFolderPanes, getStorages } = vi.hoisted(() => ({
+ loadProfile: vi.fn(),
+ getFolderPanes: vi.fn(),
+ getStorages: vi.fn()
+}))
+
+vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({
+ ...await importOriginal(),
+ loadProfileFromURI: loadProfile
+}))
+vi.mock('../../../src/utils/paneUtils', () => ({ getFolderPanesFromURI: getFolderPanes }))
+vi.mock('../../../src/utils/podUtils', () => ({ getPodStorages: getStorages }))
+
+import {
+ getPodContext,
+ isInViewerPod,
+ isPageInViewerPod,
+ isSamePod,
+ resetPodContexts,
+ type PodContext
+} from '../../../src/navigation/podContext'
+
+const owner = sym('https://pod.example/timea/profile/card#me')
+const viewer = sym('https://pod.example/other/profile/card#me')
+const storage = (uri: string) => ({ subject: sym(uri), paneName: 'folder' }) as unknown as PaneItem
+
+beforeEach(() => {
+ resetPodContexts()
+ loadProfile.mockResolvedValue(owner)
+ getFolderPanes.mockResolvedValue([storage('https://pod.example/timea/')])
+ getStorages.mockResolvedValue([sym('https://pod.example/other/')])
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.resetAllMocks()
+})
+
+describe('getPodContext', () => {
+ it('describes the pod the page is in and the viewer\'s own storages', async () => {
+ const context = await getPodContext('https://pod.example/timea/docs/')
+
+ expect(context).toMatchObject({
+ viewer: viewer.value,
+ owner: owner.value,
+ viewerStorages: ['https://pod.example/other/']
+ })
+ expect(context.storagePanes.map(pane => pane.subject.value)).toEqual(['https://pod.example/timea/'])
+ })
+
+ it('looks up the storages of a pod and of the viewer once, however many of its pages are asked about', async () => {
+ await Promise.all([
+ getPodContext('https://pod.example/timea/a'),
+ getPodContext('https://pod.example/timea/b/c'),
+ getPodContext('https://pod.example/timea/')
+ ])
+
+ expect(getFolderPanes).toHaveBeenCalledOnce()
+ expect(getStorages).toHaveBeenCalledOnce()
+ })
+
+ it('looks up the owner once per document, whatever fragment is asked about', async () => {
+ await Promise.all([
+ getPodContext('https://pod.example/timea/a'),
+ getPodContext('https://pod.example/timea/a#one'),
+ getPodContext('https://pod.example/timea/a#two')
+ ])
+ await getPodContext('https://pod.example/timea/b')
+
+ expect(loadProfile).toHaveBeenCalledTimes(2)
+ })
+
+ it('gives each page its own owner, since the lookup can end with that page\'s own primary topic', async () => {
+ const alice = sym('https://example.org/people/alice#me')
+ const bob = sym('https://example.org/people/bob#me')
+ loadProfile.mockImplementation(async (subject: typeof alice) => subject.uri.includes('alice') ? alice : bob)
+
+ const first = await getPodContext('https://example.org/people/alice')
+ const second = await getPodContext('https://example.org/people/bob')
+
+ expect([first.owner, second.owner]).toEqual([alice.value, bob.value])
+ expect(isSamePod(first, second)).toBe(false)
+ })
+
+ it('looks up the storages again for another pod, on the same host or another one', async () => {
+ await getPodContext('https://pod.example/timea/')
+ await getPodContext('https://pod.example/timea2/')
+ await getPodContext('https://elsewhere.example/timea/')
+
+ expect(getFolderPanes).toHaveBeenCalledTimes(3)
+ expect(loadProfile).toHaveBeenCalledTimes(3)
+ })
+
+ it('looks up the viewer\'s storages once per viewer, and the rest is unaffected by who is logged in', async () => {
+ await getPodContext('https://pod.example/timea/')
+ vi.mocked(authn.currentUser).mockReturnValue(null)
+
+ const loggedOut = await getPodContext('https://pod.example/timea/')
+
+ expect(loggedOut.viewer).toBeUndefined()
+ expect(loggedOut.viewerStorages).toEqual([])
+ expect(loggedOut.owner).toBe(owner.value)
+ expect(loadProfile).toHaveBeenCalledOnce()
+ expect(getStorages).toHaveBeenCalledOnce()
+ })
+
+ it('does not remember an unbounded number of documents', async () => {
+ for (let index = 0; index < 520; index++) await getPodContext(`https://pod.example/timea/doc-${index}`)
+ loadProfile.mockClear()
+
+ await getPodContext('https://pod.example/timea/doc-519')
+ expect(loadProfile).not.toHaveBeenCalled()
+ await getPodContext('https://pod.example/timea/doc-0')
+ expect(loadProfile).toHaveBeenCalledOnce()
+ })
+
+ it('uses the page URL by default', async () => {
+ window.history.replaceState({}, '', '/timea/docs/')
+
+ await getPodContext()
+
+ expect(loadProfile).toHaveBeenCalledWith(sym(window.location.href))
+ window.history.replaceState({}, '', '/')
+ })
+
+ describe('when a lookup finds no profile', () => {
+ it('does not take the URL itself for the owner', async () => {
+ const page = sym('https://pod.example/files/notes.ttl')
+ loadProfile.mockResolvedValue(page)
+
+ expect((await getPodContext(page.uri)).owner).toBeUndefined()
+ })
+
+ it('does take a WebID for its own owner', async () => {
+ loadProfile.mockResolvedValue(owner)
+
+ expect((await getPodContext(owner.uri)).owner).toBe(owner.value)
+ })
+ })
+
+ describe('when a lookup fails', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ })
+
+ it('still describes what could be loaded', async () => {
+ loadProfile.mockRejectedValue(new Error('offline'))
+
+ const context = await getPodContext('https://pod.example/timea/')
+
+ expect(context.owner).toBeUndefined()
+ expect(context.storagePanes).toHaveLength(1)
+ })
+
+ it('asks again next time instead of remembering the gap', async () => {
+ loadProfile.mockRejectedValueOnce(new Error('offline'))
+
+ await getPodContext('https://pod.example/timea/')
+ const retried = await getPodContext('https://pod.example/timea/')
+
+ expect(retried.owner).toBe(owner.value)
+ expect(loadProfile).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not look up the viewer\'s storages when nobody is logged in', async () => {
+ vi.mocked(authn.currentUser).mockReturnValue(null)
+ getStorages.mockRejectedValue(new Error('not called'))
+
+ const context = await getPodContext('https://pod.example/timea/')
+
+ expect(context.viewerStorages).toEqual([])
+ expect(getStorages).not.toHaveBeenCalled()
+ })
+ })
+})
+
+describe('what a context says about a URL', () => {
+ const context: PodContext = {
+ viewer: viewer.value,
+ owner: owner.value,
+ storagePanes: [],
+ viewerStorages: ['https://pod.example/other/']
+ }
+
+ it.each([
+ ['https://pod.example/other/', true],
+ ['https://pod.example/other/docs/file.ttl', true],
+ ['https://pod.example/timea/docs/', false],
+ ['https://pod.example/others/', false]
+ ])('puts %s in the viewer\'s pod: %s', (url, expected) => {
+ expect(isInViewerPod(context, url)).toBe(expected)
+ })
+
+ it('compares pods by owner, viewer and storages', () => {
+ const same = { ...context, storagePanes: [storage('https://pod.example/timea/')] }
+
+ expect(isSamePod(same, { ...same })).toBe(true)
+ expect(isSamePod(same, { ...same, owner: 'https://elsewhere.example/profile/card#me' })).toBe(false)
+ expect(isSamePod(same, { ...same, viewer: undefined })).toBe(false)
+ expect(isSamePod(same, { ...same, storagePanes: [] })).toBe(false)
+ })
+
+ it('answers for a page URL directly', async () => {
+ expect(await isPageInViewerPod('https://pod.example/other/docs/')).toBe(true)
+ expect(await isPageInViewerPod('https://pod.example/timea/')).toBe(false)
+ })
+})
diff --git a/test/unit/navigation/state.test.ts b/test/unit/navigation/state.test.ts
new file mode 100644
index 00000000..d220067c
--- /dev/null
+++ b/test/unit/navigation/state.test.ts
@@ -0,0 +1,140 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ clearPaneState,
+ nextHistoryState,
+ parseNavigationState,
+ readNavigationState,
+ viewFromPane,
+ withoutPaneState
+} from '../../../src/navigation/state'
+
+afterEach(() => {
+ window.history.replaceState(null, '', '/')
+})
+
+describe('viewFromPane', () => {
+ it.each([
+ [undefined, undefined, { kind: 'default' }],
+ ['profile', 'https://a.example/card#me', { kind: 'profile', pane: 'profile', subject: 'https://a.example/card#me' }],
+ ['social', 'https://a.example/card#me', { kind: 'friends', pane: 'social', subject: 'https://a.example/card#me' }],
+ ['folder', 'https://a.example/', { kind: 'storage', pane: 'folder', subject: 'https://a.example/' }],
+ ['folder', undefined, { kind: 'storage', pane: 'folder' }],
+ ['home', undefined, { kind: 'pane', pane: 'home' }],
+ ['source', 'https://a.example/file', { kind: 'pane', pane: 'source', subject: 'https://a.example/file' }]
+ ])('describes pane %s with subject %s', (pane, subject, expected) => {
+ expect(viewFromPane(pane, subject)).toEqual(expected)
+ })
+})
+
+describe('parseNavigationState', () => {
+ it.each([null, undefined, 'text', 42, {}])('reads %j as nothing displayed', raw => {
+ expect(parseNavigationState(raw)).toEqual({ view: { kind: 'default' }, viaAccountMenu: false })
+ })
+
+ it('reads the stored pane fields', () => {
+ expect(parseNavigationState({ paneName: 'social', paneUri: 'https://a.example/card#me', other: 1 })).toEqual({
+ view: { kind: 'friends', pane: 'social', subject: 'https://a.example/card#me' },
+ viaAccountMenu: false
+ })
+ })
+
+ it('ignores empty or mistyped pane fields', () => {
+ expect(parseNavigationState({ paneName: '', paneUri: 'x' }).view).toEqual({ kind: 'default' })
+ expect(parseNavigationState({ paneName: 'profile', paneUri: 5 }).view).toEqual({ kind: 'profile', pane: 'profile' })
+ })
+
+ it('only trusts the account-menu marker when it is exactly true', () => {
+ expect(parseNavigationState({ viaAccountMenu: true }).viaAccountMenu).toBe(true)
+ expect(parseNavigationState({ viaAccountMenu: 'yes' }).viaAccountMenu).toBe(false)
+ })
+
+ it('reads the current history entry', () => {
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://a.example/', viaAccountMenu: true }, '', '/')
+
+ expect(readNavigationState()).toEqual({
+ view: { kind: 'storage', pane: 'folder', subject: 'https://a.example/' },
+ viaAccountMenu: true
+ })
+ })
+})
+
+describe('nextHistoryState', () => {
+ const storage = { name: 'folder', subject: 'https://a.example/' }
+
+ it('records the displayed pane and its subject', () => {
+ expect(nextHistoryState({}, { pane: storage, leavingPage: false, viaAccountMenu: false })).toEqual({
+ paneName: 'folder',
+ paneUri: 'https://a.example/'
+ })
+ })
+
+ it('keeps other keys of the entry', () => {
+ expect(nextHistoryState({ other: 'kept' }, { pane: storage, leavingPage: false, viaAccountMenu: false }))
+ .toMatchObject({ other: 'kept', paneName: 'folder' })
+ })
+
+ it('treats a missing or non-object entry as empty', () => {
+ for (const current of [null, undefined, 'text']) {
+ expect(nextHistoryState(current, { leavingPage: false, viaAccountMenu: false })).toEqual({})
+ }
+ })
+
+ it('drops a saved pane URI when the new pane has no subject of its own', () => {
+ expect(nextHistoryState({ paneName: 'folder', paneUri: 'https://a.example/' }, {
+ pane: { name: 'home' }, leavingPage: false, viaAccountMenu: false
+ })).toEqual({ paneName: 'home' })
+ expect(nextHistoryState({ paneUri: 'https://a.example/' }, {
+ pane: { name: 'home', subject: '' }, leavingPage: false, viaAccountMenu: false
+ })).toEqual({ paneName: 'home' })
+ })
+
+ it('keeps the pane when the same page is rendered again without one', () => {
+ const current = { paneName: 'profile', paneUri: 'https://a.example/card#me' }
+
+ expect(nextHistoryState(current, { leavingPage: false, viaAccountMenu: false })).toEqual(current)
+ })
+
+ it('does not carry pane details into the entry of a different page', () => {
+ expect(nextHistoryState(
+ { paneName: 'profile', paneUri: 'https://a.example/card#me', other: 'kept' },
+ { leavingPage: true, viaAccountMenu: false }
+ )).toEqual({ other: 'kept' })
+ })
+
+ it('records a pane for a different page', () => {
+ expect(nextHistoryState({ paneName: 'profile' }, { pane: storage, leavingPage: true, viaAccountMenu: false }))
+ .toEqual({ paneName: 'folder', paneUri: 'https://a.example/' })
+ })
+
+ it('marks and unmarks account-menu views', () => {
+ expect(nextHistoryState({}, { pane: storage, leavingPage: false, viaAccountMenu: true }))
+ .toMatchObject({ viaAccountMenu: true })
+ expect(nextHistoryState({ viaAccountMenu: true }, { pane: storage, leavingPage: false, viaAccountMenu: false }))
+ .not.toHaveProperty('viaAccountMenu')
+ })
+
+ it('does not change the entry it was given', () => {
+ const current = { paneName: 'profile', viaAccountMenu: true }
+
+ nextHistoryState(current, { leavingPage: true, viaAccountMenu: false })
+
+ expect(current).toEqual({ paneName: 'profile', viaAccountMenu: true })
+ })
+})
+
+describe('forgetting the displayed pane', () => {
+ it('removes only the pane fields', () => {
+ expect(withoutPaneState({ paneName: 'x', paneUri: 'y', viaAccountMenu: true, other: 'kept' })).toEqual({ other: 'kept' })
+ expect(withoutPaneState(null)).toEqual({})
+ })
+
+ it('clears them from the current entry without changing the URL', () => {
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://a.example/', viaAccountMenu: true, other: 1 }, '', '/some/page')
+
+ clearPaneState()
+
+ expect(window.history.state).toEqual({ other: 1 })
+ expect(window.location.pathname).toBe('/some/page')
+ expect(readNavigationState()).toEqual({ view: { kind: 'default' }, viaAccountMenu: false })
+ })
+})
diff --git a/test/unit/outline/manager.test.ts b/test/unit/outline/manager.test.ts
index 096c79d1..62fd1c72 100644
--- a/test/unit/outline/manager.test.ts
+++ b/test/unit/outline/manager.test.ts
@@ -3,9 +3,9 @@ import { authn, store } from 'solid-logic'
const { isInViewerPodMock } = vi.hoisted(() => ({ isInViewerPodMock: vi.fn() }))
-vi.mock('../../../src/utils/podUtils', async (importOriginal) => ({
- ...await importOriginal(),
- isInViewerPod: isInViewerPodMock
+vi.mock('../../../src/navigation/podContext', async (importOriginal) => ({
+ ...await importOriginal(),
+ isPageInViewerPod: isInViewerPodMock
}))
vi.mock('solid-ui', async () => {
@@ -20,6 +20,7 @@ vi.mock('solid-ui', async () => {
})
import OutlineManager from '../../../src/outline/manager'
+import { onNavigated, type NavigatedDetail } from '../../../src/navigation/events'
import { lit, NamedNode, sym, blankNode } from 'rdflib'
import { findByText, getByText } from '@testing-library/dom'
@@ -41,6 +42,10 @@ const mockPaneRegistry = {
describe('manager', () => {
describe('navbar visibility during navigation', () => {
+ beforeEach(() => {
+ isInViewerPodMock.mockReset().mockResolvedValue(false)
+ })
+
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
@@ -51,7 +56,7 @@ describe('manager', () => {
['https://owner.example/profile/card', true],
['https://owner.example/storage/file.ttl', false],
['https://visitor.example/profile/card#me', false]
- ])('sets navbar visibility for %s', (uri, hidden) => {
+ ])('sets navbar visibility for %s', async (uri, hidden) => {
vi.spyOn(authn, 'currentUser').mockReturnValue(sym('https://owner.example/profile/card#me'))
const navbar = document.createElement('solid-panes-navbar')
navbar.classList.toggle('navbar--hidden', !hidden)
@@ -60,7 +65,23 @@ describe('manager', () => {
manager.GotoSubject(sym(uri), false)
- expect(navbar.classList.contains('navbar--hidden')).toBe(hidden)
+ await vi.waitFor(() => expect(navbar.classList.contains('navbar--hidden')).toBe(hidden))
+ })
+
+ it('keeps the navbar hidden when a storage link navigates to an owned resource', async () => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
+ isInViewerPodMock.mockResolvedValue(true)
+ const navbar = document.createElement('solid-panes-navbar')
+ document.body.appendChild(navbar)
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const uri = `${window.location.origin}/storage/file.ttl`
+
+ manager.GotoSubject(sym(uri), false, undefined, true)
+
+ expect(window.location.href).toBe(uri)
+ await vi.waitFor(() => expect(navbar).toHaveClass('navbar--hidden'))
+ expect(isInViewerPodMock).toHaveBeenCalledWith(uri)
+ window.history.replaceState({}, '', '/')
})
it('preserves account-menu hiding when automatic reveal is suppressed', () => {
@@ -82,6 +103,68 @@ describe('manager', () => {
window.history.replaceState({}, '', '/')
})
+ it.each([
+ ['the options form', { source: 'accountMenu' }, true],
+ ['the old boolean form', false, true],
+ ['default options', {}, false],
+ ['the old default', true, false],
+ ['no options', undefined, false]
+ ])('marks the entry as opened by the account menu with %s: %s', (_name, options, marked) => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const pane = { ...MockPane, subject: sym(`${window.location.origin}/profile/card#me`) }
+ window.history.replaceState({ viaAccountMenu: !marked }, '', '/')
+
+ manager.GotoSubject(sym(window.location.href), false, pane, true, undefined, undefined, options as never)
+
+ expect(Boolean(window.history.state.viaAccountMenu)).toBe(marked)
+ })
+
+ describe('announcing the navigation', () => {
+ it('announces once after a solo display, with the entry it wrote', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const pane = { ...MockPane, subject: sym(`${window.location.origin}/profile/card#me`) }
+ const seen: NavigatedDetail[] = []
+ const stop = onNavigated(detail => seen.push(detail))
+
+ manager.GotoSubject(sym(window.location.href), false, pane, true, undefined, undefined, { source: 'accountMenu' })
+ stop()
+
+ expect(seen).toEqual([{
+ url: window.location.href,
+ state: {
+ view: { kind: 'pane', pane: 'mock', subject: `${window.location.origin}/profile/card#me` },
+ viaAccountMenu: true
+ }
+ }])
+ })
+
+ it('announces the new URL when a different subject is displayed', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const target = sym(`${window.location.origin}/elsewhere/`)
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ manager.GotoSubject(target, false, undefined, true)
+ stop()
+
+ expect(listener).toHaveBeenCalledWith({
+ url: target.uri,
+ state: { view: { kind: 'default' }, viaAccountMenu: false }
+ })
+ })
+
+ it('does not announce displays inside the page that do not change what is shown as a whole', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ manager.GotoSubject(sym(window.location.href), false, MockPane, false, undefined, document.createElement('table'))
+ stop()
+
+ expect(listener).not.toHaveBeenCalled()
+ })
+ })
+
it('marks account-menu navigation and clears the mark on other navigation', () => {
const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
const pane = { ...MockPane, subject: sym(`${window.location.origin}/profile/card#me`) }
diff --git a/test/unit/utils/podUtils.test.ts b/test/unit/utils/podUtils.test.ts
deleted file mode 100644
index 06d3169e..00000000
--- a/test/unit/utils/podUtils.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { authn, store } from 'solid-logic'
-import { sym } from 'rdflib'
-import { ns } from 'solid-ui'
-import { isInViewerPod } from '../../../src/utils/podUtils'
-
-afterEach(() => {
- vi.restoreAllMocks()
-})
-
-describe('isInViewerPod', () => {
- const viewer = sym('https://pod.example/timea/profile/card#me')
- const storage = sym('https://pod.example/timea/')
-
- function viewerWithStorage () {
- vi.spyOn(store.fetcher, 'load').mockResolvedValue(undefined as never)
- store.add(viewer, ns.space('storage'), storage, viewer.doc())
- store.add(storage, ns.rdf('type'), ns.space('Storage'), storage.doc())
- // Known contents mean the container does not need fetching again
- store.add(storage, ns.ldp('contains'), sym('https://pod.example/timea/docs/'), storage.doc())
- }
-
- it('is true for URLs inside the viewer\'s storage', async () => {
- viewerWithStorage()
- vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
-
- expect(await isInViewerPod('https://pod.example/timea/docs/file.ttl')).toBe(true)
- expect(await isInViewerPod('https://pod.example/timea/')).toBe(true)
- })
-
- it('is false for URLs in other pods or outside the storage', async () => {
- viewerWithStorage()
- vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
-
- expect(await isInViewerPod('https://pod.example/timea2/profile/card')).toBe(false)
- expect(await isInViewerPod('https://other.example/timea/docs/')).toBe(false)
- })
-
- it('is false when nobody is logged in', async () => {
- viewerWithStorage()
- vi.spyOn(authn, 'currentUser').mockReturnValue(null)
-
- expect(await isInViewerPod('https://pod.example/timea/docs/')).toBe(false)
- })
-})