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) - }) -})