diff --git a/README.md b/README.md index a86143c1..943ec881 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,64 @@ 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 + +The `solid-panes-profile-heading` component accepts presentation data through +`profileData`. Its boolean `compact` property (also available as an attribute) +halves the responsive avatar size and shows only the photo, name, and job title. +The page controller uses the full heading on the profile pane and compact mode +on every other pane where the heading is visible. Owner editing remains exclusive +to the profile pane. On storage panes, the heading is shown only when the active +storage URI matches the viewed profile's navbar entry. Opening the logged-in +user's storage from the header menu hides the heading, even when the URL still +points to someone else's profile. Reloading a WebID URL always shows its profile +with Profile selected in the navbar. On a container or resource URL (not a WebID) the page +shows the resource as usual with the navbar visible and nothing selected; choosing Storage +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, 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 +`profile-heading-saved` event with `detail.subjectUri`, which profile-pane uses to +rerender sections such as More contacts. +The heading editor includes its own dialog and responsive styles. +On mobile, the edit icon sits closer to the heading's top-right edge while +preserving its full touch target. + +On the profile pane, logged-in visitors viewing someone else's profile also see +an "Add as friend" action to the left of Edit (when available). The page controller +sets `friendSubject` only in that context. The action loads the viewer's profile, +checks `foaf:knows`, and saves the new friendship in the viewer's profile document. +Existing friends have a disabled "Already a friend" button (hidden on mobile). +Success and error messages are centered below the button, announced and focused, +and dismissed after 10 seconds; failed operations can be +retried. Anonymous visitors, owners, and other panes do not show this action. +On mobile, friend actions sit in the top-right corner beside the name, which +wraps as needed. + +Navigation reuses the mounted heading, with a 300ms CSS transition for the avatar, +padding, and detail rows in both directions. Transitions are disabled when the +user prefers reduced motion. +When navigating to a pane without a heading, the entire heading fades and +collapses (including its outer spacing) over 300ms before removal. Navigating +back during that animation cancels it and reuses the existing component. +The reverse navigation fades and expands the full or compact heading and its +outer spacing over 300ms, after the component has rendered. Reduced-motion users +get immediate appearance and removal instead. + +The navbar is hidden when the loaded URL is the logged-in user's WebID or its +fragment-free profile document URL. Other URLs in the same pod and other users' +profiles keep the navbar. Account-menu navigation continues to hide it. ## Development @@ -65,6 +121,15 @@ npm start 3. You can change the `subject` in the sandbox to determine which pane gets rendered. For example, the default subject loads the `profile-pane`. +### Using a local profile-pane + +When working inside the SolidOS monorepo, `profile-pane` (and the other local +workspaces) are symlinked by `npx lerna bootstrap --force-local` (run from the +monorepo root, as `npm run watch-pivot` does), even if the version in +`package.json` is not published to npm. Use a plain version in `package.json` +(not a `file:` path, which lerna does not link) and avoid a bare `npm install` +here, which fails for unpublished versions and removes the links. + ## Contributing panes When you created a pane, you can either add it as an npm dependency of this repo (for instance meeting-pane, issue-pane, chat-pane are all diff --git a/docs/navigation-design.md b/docs/navigation-design.md new file mode 100644 index 00000000..5a2f3b57 --- /dev/null +++ b/docs/navigation-design.md @@ -0,0 +1,316 @@ +# 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 | 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 +- Shown for any URL except the viewer's own profile (WebID or its document); hidden by the account + menu. +- 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. +- 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 615f1c2c..84c2be0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,11 @@ "marked": "^18.0.3", "meeting-pane": "4.0.0", "mime-types": "^3.0.2", - "pane-registry": "5.0.1", - "profile-pane": "4.0.0", + "package-lock.json": "^1.0.0", + "pane-registry": "5.0.2-0", + "profile-pane": "4.1.0-0", "rdflib": "2.4.1", - "solid-logic": "6.0.0", + "solid-logic": "6.0.1-1", "solid-namespace": "^0.5.4", "solid-ui": "5.0.1", "source-pane": "4.0.0" @@ -47,9 +48,9 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "version": "0.9.32", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz", + "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==", "devOptional": true, "license": "MIT" }, @@ -2064,12 +2065,13 @@ } }, "node_modules/@codemirror/language": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.0.tgz", - "integrity": "sha512-NP+YbEGM5g07C/d+W58a881Msq9p/SR4QsWqapelhPeNMlkce1ZIZUzD0IpVYIjgQbeWfbBx/NFyOqiGYYTZEA==", + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", @@ -2343,6 +2345,17 @@ "node": ">=18.17" } }, + "node_modules/@dokieli/web-access-control": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dokieli/web-access-control/-/web-access-control-1.1.0.tgz", + "integrity": "sha512-LId2cNz0pKafc6dwohaX6j+mr886uEV2o8L0VrAWUDxU0LPN4+8Yc8Yx3D94zrsRSJuBWRtQzSjgtEqhYg5y/w==", + "license": "Apache-2.0", + "dependencies": { + "@rdfjs/data-model": "^2.1.1", + "@rdfjs/types": "^2.0.1", + "n3": "^2.2.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.7.3.tgz", @@ -3030,9 +3043,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.152.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", - "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/oxc-project" @@ -3071,6 +3084,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/@rdfjs/data-model": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-2.1.2.tgz", + "integrity": "sha512-yoSVlyCltqbaCTse3uSdf5Vo3q4UskcTm+3klaUQTwOq7Z5aCZLtLPZatXTPzr+S0WEcjTZvD++A+r3QmgamsA==", + "license": "MIT", + "bin": { + "rdfjs-data-model-test": "bin/test.js" + } + }, "node_modules/@rdfjs/types": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", @@ -3081,9 +3103,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", - "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "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==", "cpu": [ "arm" ], @@ -3097,9 +3119,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", - "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "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==", "cpu": [ "arm64" ], @@ -3113,9 +3135,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", - "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "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==", "cpu": [ "arm64" ], @@ -3129,9 +3151,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", - "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "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==", "cpu": [ "x64" ], @@ -3145,9 +3167,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", - "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "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==", "cpu": [ "x64" ], @@ -3161,9 +3183,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", - "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "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==", "cpu": [ "arm" ], @@ -3177,9 +3199,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", - "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "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==", "cpu": [ "arm64" ], @@ -3196,9 +3218,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", - "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "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==", "cpu": [ "arm64" ], @@ -3215,9 +3237,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", - "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "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==", "cpu": [ "ppc64" ], @@ -3234,9 +3256,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", - "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "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==", "cpu": [ "s390x" ], @@ -3253,9 +3275,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", - "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "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==", "cpu": [ "x64" ], @@ -3272,9 +3294,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", - "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "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==", "cpu": [ "x64" ], @@ -3291,9 +3313,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", - "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "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==", "cpu": [ "arm64" ], @@ -3307,9 +3329,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", - "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "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==", "cpu": [ "arm64" ], @@ -3323,9 +3345,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", - "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "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==", "cpu": [ "x64" ], @@ -3597,17 +3619,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", - "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/type-utils": "8.71.1", - "@typescript-eslint/utils": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3620,7 +3642,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.1", + "@typescript-eslint/parser": "^8.71.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3636,15 +3658,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", - "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -3660,13 +3682,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", - "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.1", - "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -3681,13 +3703,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", - "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3698,9 +3720,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", - "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3714,15 +3736,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", - "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3739,9 +3761,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", - "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3752,15 +3774,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", - "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.1", - "@typescript-eslint/tsconfig-utils": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3779,16 +3801,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", - "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3803,12 +3825,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", - "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4092,6 +4114,31 @@ "timeago.js": "^4.0.2" } }, + "node_modules/activitystreams-pane/node_modules/pane-registry": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz", + "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==", + "license": "MIT", + "dependencies": { + "rdflib": "2.4.1", + "solid-logic": "6.0.0" + } + }, + "node_modules/activitystreams-pane/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4440,9 +4487,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", - "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4610,9 +4657,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001815", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", - "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", "dev": true, "funding": [ { @@ -4680,6 +4727,31 @@ "vitest-axe": "^0.1.0" } }, + "node_modules/chat-pane/node_modules/pane-registry": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz", + "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==", + "license": "MIT", + "dependencies": { + "rdflib": "2.4.1", + "solid-logic": "6.0.0" + } + }, + "node_modules/chat-pane/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -4767,6 +4839,31 @@ "solid-ui": "5.0.1" } }, + "node_modules/contacts-pane/node_modules/pane-registry": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz", + "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==", + "license": "MIT", + "dependencies": { + "rdflib": "2.4.1", + "solid-logic": "6.0.0" + } + }, + "node_modules/contacts-pane/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5078,9 +5175,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5101,9 +5198,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.449", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.449.tgz", - "integrity": "sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==", + "version": "1.5.454", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.454.tgz", + "integrity": "sha512-NqD5GhkXnxJpDQVCmmpc546qjH6kei2VI9m45CTF+sQU7bcS7YeVt7ba0cnjueMH1AXGRu/driZUP9iYVSv7vg==", "dev": true, "license": "ISC" }, @@ -5922,6 +6019,21 @@ "vitest-axe": "^0.1.0" } }, + "node_modules/folder-pane/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -6922,6 +7034,21 @@ "node": ">=20.19.2" } }, + "node_modules/issue-pane/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -7772,6 +7899,12 @@ "node": "20 || >=22" } }, + "node_modules/lucide": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.56.0.tgz", + "integrity": "sha512-BOrr2xku0dGUzWvdBZK3NlEdEzcEvXoLRyGvC27lyFB1tSL7Lc7vW61CKeVcw92K2L9y6aNKS7fJ8fVzw/MXIw==", + "license": "ISC" + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -7820,9 +7953,9 @@ } }, "node_modules/marked": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz", - "integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==", + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz", + "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -7859,6 +7992,21 @@ "solid-ui": "5.0.1" } }, + "node_modules/meeting-pane/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7981,9 +8129,9 @@ "license": "MIT" }, "node_modules/n3": { - "version": "2.13.9", - "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.9.tgz", - "integrity": "sha512-in/z8EVU9QY3Q0K8+59sfcb+V44aaki9hTkfDLcbtudKS9ut40w+WjLkxBJWj54sMg0qZ/uN4sfeAbp4MyPdIA==", + "version": "2.13.12", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.12.tgz", + "integrity": "sha512-z944NsyJi58WXcYevtNkUL2JhVr0KP6nvED9AgymQq5V35TDaNn1N8kO+OWJk/kj4PR0L/ilWB3d7/bWceg1CQ==", "license": "MIT", "dependencies": { "buffer": "^6.0.3", @@ -8214,9 +8362,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.57", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, "license": "MIT", "engines": { @@ -8433,6 +8581,11 @@ "node": ">=6" } }, + "node_modules/package-lock.json": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-lock.json/-/package-lock.json-1.0.0.tgz", + "integrity": "sha512-+yEXtNdlCs5N0Zy/9uvkifgf/RqnGu0WqP4j9Wu1Us4YReFe1YNBh2Krmf8B1xGxjpYnta63K55QP8bkafnOzA==" + }, "node_modules/package-manager-detector": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", @@ -8441,13 +8594,29 @@ "license": "MIT" }, "node_modules/pane-registry": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz", - "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==", + "version": "5.0.2-0", + "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.2-0.tgz", + "integrity": "sha512-6fU9IyEAttMJwFThbGjLvyrXXNYx0+BmNc4InKA7kFJdVq4ozyojMcrpq4lT2g7xzR1o5xt+Ah+urTxEMQdpcQ==", "license": "MIT", "dependencies": { "rdflib": "2.4.1", - "solid-logic": "6.0.0" + "solid-logic": "6.0.1-0" + } + }, + "node_modules/pane-registry/node_modules/solid-logic": { + "version": "6.0.1-0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-0.tgz", + "integrity": "sha512-ZEJ4aRK5Q16sYoNTiH8aJ2ctvpZPwvsPYKawL9pmPC7qvF6wVnOucuNLp1EHHYhdX1Wkp9a25mp/my1IsQgofA==", + "license": "MIT", + "dependencies": { + "@dokieli/web-access-control": "^1.0.0", + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" } }, "node_modules/parent-module": { @@ -8603,9 +8772,9 @@ } }, "node_modules/postcss": { - "version": "8.5.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", - "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "funding": [ { "type": "opencollective", @@ -8622,7 +8791,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.19", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" }, @@ -8724,19 +8893,20 @@ } }, "node_modules/profile-pane": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/profile-pane/-/profile-pane-4.0.0.tgz", - "integrity": "sha512-0pM+jz5kLj3duOsLeHc4xwomQJ92sgAx0QMmYZaC+meY+gkT09qWhTzWqRKur7/HbOBJH1bPH/cu7UI0TBAxHw==", + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/profile-pane/-/profile-pane-4.1.0-0.tgz", + "integrity": "sha512-bepwaxX6TaP5jpzKBLL1JRUYDETwWxBVbVDCqHTMZQGxlIs7x9I20R0dS3Y4shHOIkJOcyiKpSZvlfRUbDtuWQ==", "license": "MIT", "dependencies": { "@solid-data-modules/contacts-rdflib": "^0.7.1", "chat-pane": "4.0.0", "lit-html": "^3.3.3", - "pane-registry": "5.0.1", + "lucide": "^1.52.0", + "pane-registry": "5.0.2-0", "patch-package": "^8.0.1", "qrcode": "^1.5.4", "rdflib": "2.4.1", - "solid-logic": "6.0.0", + "solid-logic": "6.0.1-1", "solid-ui": "5.0.1", "validate-color": "^2.2.4", "vite": "^8.0.16", @@ -8889,9 +9059,9 @@ } }, "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -9088,12 +9258,12 @@ } }, "node_modules/rolldown": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", - "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.152.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -9103,21 +9273,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.12", - "@rolldown/binding-android-arm64": "1.2.12", - "@rolldown/binding-darwin-arm64": "1.2.12", - "@rolldown/binding-darwin-x64": "1.2.12", - "@rolldown/binding-freebsd-x64": "1.2.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", - "@rolldown/binding-linux-arm64-gnu": "1.2.12", - "@rolldown/binding-linux-arm64-musl": "1.2.12", - "@rolldown/binding-linux-ppc64-gnu": "1.2.12", - "@rolldown/binding-linux-s390x-gnu": "1.2.12", - "@rolldown/binding-linux-x64-gnu": "1.2.12", - "@rolldown/binding-linux-x64-musl": "1.2.12", - "@rolldown/binding-openharmony-arm64": "1.2.12", - "@rolldown/binding-win32-arm64-msvc": "1.2.12", - "@rolldown/binding-win32-x64-msvc": "1.2.12" + "@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" } }, "node_modules/safe-array-concat": { @@ -9415,11 +9585,12 @@ } }, "node_modules/solid-logic": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", - "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "version": "6.0.1-1", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-1.tgz", + "integrity": "sha512-k0EB26f3whZWfvePl6qHcHhOa4TWfnjPLphAa++s2ZR7+FvruM9Cc9eyvzJqEF6b3HF72jH8V7ENiRseFI51Ow==", "license": "MIT", "dependencies": { + "@dokieli/web-access-control": "^1.1.0", "@uvdsl/solid-oidc-client-browser": "^0.2.3", "solid-namespace": "^0.5.4", "vite": "^8.0.16", @@ -9471,10 +9642,35 @@ "fsevents": "*" } }, + "node_modules/solid-ui/node_modules/pane-registry": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz", + "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==", + "license": "MIT", + "dependencies": { + "rdflib": "2.4.1", + "solid-logic": "6.0.0" + } + }, + "node_modules/solid-ui/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/solidos-toolkit": { - "version": "0.0.0-dev.998a984ddf8fda587eedb0c8125cb952ac28d191", - "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-0.0.0-dev.998a984ddf8fda587eedb0c8125cb952ac28d191.tgz", - "integrity": "sha512-m2b3sZkKbjV5X/J8N25h3UIbsiQARdnXCGs1AeGqFiueoq8cWm+pdOyPpqiVuqRnnA5osmKG2wovUcBBYumL0Q==", + "version": "1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c", + "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c.tgz", + "integrity": "sha512-i5FOuvpBBbXfYpRKBv4ygdccO5EUUpfo2rp1HoOspG+AOKMVeFqizuPokl+RZX6AVn3AulqRfnlo+NUEWkidEA==", "dev": true, "dependencies": { "@babel/core": "^7.29.0", @@ -9493,10 +9689,10 @@ "vite-plugin-lit-css": "^3.0.0" }, "peerDependencies": { - "pane-registry": "^3.1.1 || ^3.1.2-0 || ^5.0.0 || ^5.0.0-0", + "pane-registry": "^3.1.1 || ^3.1.2-0 || >=5.0.2-0 <6.0.0", "rdflib": "^2.3.6", - "solid-logic": "^4.0.7 || ^4.0.8-0 || ^6.0.0 || ^6.0.0-0", - "solid-ui": "^3.1.2 || ^3.1.3-0 || ^5.0.0 || ^5.0.0-0", + "solid-logic": "^4.0.7 || ^4.0.8-0 || >=6.0.1-0 <7.0.0", + "solid-ui": "^3.1.2 || ^3.1.3-0 || >=5.0.1-0 <6.0.0", "vite": "^8.0.0", "vitest": "^4.0.0" }, @@ -9548,6 +9744,31 @@ "vitest-axe": "^0.1.0" } }, + "node_modules/source-pane/node_modules/pane-registry": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz", + "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==", + "license": "MIT", + "dependencies": { + "rdflib": "2.4.1", + "solid-logic": "6.0.0" + } + }, + "node_modules/source-pane/node_modules/solid-logic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz", + "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -9863,22 +10084,22 @@ } }, "node_modules/tldts": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", - "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", "devOptional": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.16" + "tldts-core": "^7.4.18" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", - "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", "devOptional": true, "license": "MIT" }, @@ -10067,16 +10288,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", - "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.71.1", - "@typescript-eslint/parser": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/utils": "8.71.1" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10307,9 +10528,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -10347,9 +10568,9 @@ } }, "node_modules/uuid": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", - "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz", + "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -10366,15 +10587,15 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", - "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.11", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/package.json b/package.json index 92d69c55..34515d6b 100644 --- a/package.json +++ b/package.json @@ -67,10 +67,11 @@ "marked": "^18.0.3", "meeting-pane": "4.0.0", "mime-types": "^3.0.2", - "pane-registry": "5.0.1", - "profile-pane": "4.0.0", + "package-lock.json": "^1.0.0", + "pane-registry": "5.0.2-0", + "profile-pane": "4.1.0-0", "rdflib": "2.4.1", - "solid-logic": "6.0.0", + "solid-logic": "6.0.1-1", "solid-namespace": "^0.5.4", "solid-ui": "5.0.1", "source-pane": "4.0.0" diff --git a/src/components/file-explorer-header/FileExplorerProvider.ts b/src/components/file-explorer-header/FileExplorerProvider.ts index 3696b3e5..2b46dc12 100644 --- a/src/components/file-explorer-header/FileExplorerProvider.ts +++ b/src/components/file-explorer-header/FileExplorerProvider.ts @@ -10,6 +10,7 @@ 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 @@ -240,9 +241,11 @@ export default class FileExplorerProvider extends WebComponent { render () { 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 = explorerHeaderForPane(this.showHeader, this.pane?.name) return html`
- ${this.showHeader + ${showHeader ? html` } diff --git a/src/components/profile-heading/ProfileHeading.styles.css b/src/components/profile-heading/ProfileHeading.styles.css new file mode 100644 index 00000000..b1c04103 --- /dev/null +++ b/src/components/profile-heading/ProfileHeading.styles.css @@ -0,0 +1,417 @@ +:host { + --profile-avatar-size: 8.75rem; + --profile-fallback-padding: 36.4px; + display: block; + width: 100%; + color: var(--color-text-secondary, #4a5565); + font-family: inherit; +} + +.profile-heading { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + "avatar info edit" + "avatar details details"; + align-items: start; + column-gap: 20px; + row-gap: 20px; + width: 100%; + box-sizing: border-box; + padding: 25px; + overflow-x: clip; + background: var(--solid-ui-color-white, #fff); + border: 1px solid var(--solid-ui-color-slate-200, #e2e8f0); + border-radius: 5px; + transition: box-shadow 0.2s ease, transform 0.2s ease, padding 0.3s ease, row-gap 0.3s ease; +} + +.profile-heading:hover { + box-shadow: 0 8px 10px rgba(124, 77, 255, 0.1); +} + +.avatar { + grid-area: avatar; +} + +.image-frame { + position: relative; + display: grid; + place-items: center; + width: var(--profile-avatar-size); + aspect-ratio: 1; + overflow: hidden; + transition: width 0.3s ease; +} + +.image-frame--fallback { + border: 1px dashed var(--solid-ui-color-slate-200, #e2e8f0); + background: var(--solid-ui-color-slate-50, #f8fafc); + box-sizing: border-box; +} + +.hero { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 1px solid var(--solid-ui-color-primary, #7c4dff); + background: var(--color-surface-placeholder, var(--gray-300, #d1d5dc)) 50% / cover no-repeat; + object-fit: cover; + object-position: center; + box-sizing: border-box; + border-radius: 10px; +} + +.hero-fallback { + display: none; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 0 var(--profile-fallback-padding); + box-sizing: border-box; + transition: padding 0.3s ease; +} + +.hero-fallback icon-lucide-circle-user-round { + display: block; + width: 100%; + height: 100%; + color: #cbd5e1; +} + +.image-frame--fallback .hero-fallback { + display: flex; +} + +.info { + grid-area: info; + min-width: 0; +} + +.profile__heading-edit-action { + grid-area: edit; + margin-left: auto; + margin-right: 0; + margin-bottom: 0; + align-self: flex-start; + justify-self: end; + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 8px; + padding: 0; +} + +.profile-friends-button__section { + display: grid; + gap: 8px; + max-width: 16rem; +} + +.profile__btn-friends-icon { + display: block; + width: 14px; + height: 14px; + align-self: center; + transform: translateY(-3px); +} + +.friend-message { + text-align: center; + font-size: var(--solid-ui-font-size-sm, 0.875rem); + overflow-wrap: anywhere; +} + +.friend-message[role="alert"] { + color: var(--solid-ui-color-red-700, #b91c1c); +} + +.friend-message[role="status"] { + color: var(--solid-ui-color-green-700, #15803d); +} + +.profile-section-collapsible__edit-button { + min-width: 0; +} + +.mobile-edit-button { + display: none; + color: var(--solid-ui-color-primary, #7c4dff); +} + +.profile-section-collapsible__action-label { + font-size: var(--solid-ui-font-size-sm, 0.875rem); +} + +.profile__add-more-icon { + margin-right: 0; + line-height: 0; + position: relative; + top: 0; + transform: translateY(0); +} + +.profile__add-more-icon icon-lucide-pencil, +.profile-section-collapsible__edit-icon icon-lucide-pencil { + display: block; + width: 14px; + height: 14px; +} + +.header-bar { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + width: 100%; + margin: 0; + padding: 0; +} + +.identity { + display: flex; + align-items: baseline; + gap: 7px; + width: 100%; + flex-wrap: wrap; +} + +.name { + display: block; + margin: 0; + color: var(--color-text-heading, #000); + font-size: 1.75rem; + font-weight: 600; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.pronouns { + color: var(--color-text-secondary, #4a5565); + font-size: var(--solid-ui-font-size-sm, 0.875rem); + font-weight: 400; + line-height: 1.2; +} + +.role { + width: 100%; + color: var(--solid-ui-color-gray-800, #101828); + font-size: var(--solid-ui-font-size-lg, 1.125rem); + font-weight: 500; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.details-collapse { + grid-area: details; + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.3s ease, opacity 0.3s ease; +} + +.details { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + align-items: flex-start; + gap: 12px 32px; + min-width: 0; + min-height: 0; + overflow: hidden; + width: 100%; + font-size: var(--font-size-sm, 0.875rem); +} + +:host([compact]) .profile-heading { + padding: 12.5px; + row-gap: 0; +} + +:host([compact]) .image-frame { + width: calc(var(--profile-avatar-size) / 2); +} + +:host([compact]) .hero-fallback { + padding-inline: calc(var(--profile-fallback-padding) / 2); +} + +:host([compact]) .info { + align-self: center; +} + +:host([compact]) .name { + white-space: normal; + overflow-wrap: anywhere; +} + +:host([compact]) .details-collapse { + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; +} + +.pronouns[hidden] { + display: none; +} + +.detail-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + column-gap: clamp(12px, 2vw, 24px); + row-gap: 12px; + width: min(100%, 544px); +} + +.detail-item { + display: flex; + align-items: flex-start; + gap: 7px; + min-width: 0; +} + +.detail-icon { + display: inline-flex; + flex: 0 0 16px; + width: 16px; + height: 16px; + margin-top: 1px; +} + +.detail-icon > icon-lucide-cake, +.detail-icon > icon-lucide-map-pin, +.detail-icon > icon-lucide-phone, +.detail-icon > icon-lucide-mail { + display: block; + width: 16px; + height: 16px; + color: currentColor; +} + +.detail-value { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +@media (max-width: 1200px) { + :host { + --profile-avatar-size: 6.5rem; + --profile-fallback-padding: 27px; + } + + .profile-heading { + column-gap: 15px; + } + +} + +@media (max-width: 768px) { + .profile-heading:has(.profile-friends-button__section) .name { + white-space: normal; + overflow-wrap: anywhere; + } + + .profile__btn-friends-icon { + display: none; + } + + .profile__btn-friends[disabled]:not([aria-busy="true"]) { + display: none; + } + + :host { + --profile-avatar-size: 4.375rem; + --profile-fallback-padding: 0px; + } + + .profile-heading { + grid-template-areas: + "avatar info edit" + "details details details"; + column-gap: 8px; + row-gap: 12px; + } + + .profile-section-collapsible__edit-button { + width: var(--solid-ui-clickable-area, 42px); + height: var(--solid-ui-clickable-area, 42px); + } + + .desktop-edit-button { + display: none; + } + + .mobile-edit-button { + display: inline-flex; + position: relative; + top: -12px; + right: -12px; + } + + .profile-section-collapsible__edit-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + } + + .profile-heading { + column-gap: 10px; + } + + .hero-fallback icon-lucide-circle-user-round { + width: 3rem; + height: 3rem; + transition: width 0.3s ease, height 0.3s ease; + } + + :host([compact]) .hero-fallback icon-lucide-circle-user-round { + width: 1.5rem; + height: 1.5rem; + } + + .name { + display: inline; + font-size: 1.375rem; + line-height: 1.15; + white-space: normal; + overflow-wrap: anywhere; + } + + .identity { + gap: 0; + } + + .pronouns { + margin-left: 2px; + font-size: 0.6875rem; + line-height: 1.1; + white-space: nowrap; + } + + .role { + font-size: var(--solid-ui-font-size-sm, 0.875rem); + } + + .details { + gap: 12px; + font-size: 0.75rem; + } + + .detail-row { + column-gap: 2px; + } +} + +@media (prefers-reduced-motion: reduce) { + .profile-heading, + .image-frame, + .hero-fallback, + .hero-fallback icon-lucide-circle-user-round, + .details-collapse { + transition: none; + } +} diff --git a/src/components/profile-heading/ProfileHeading.ts b/src/components/profile-heading/ProfileHeading.ts new file mode 100644 index 00000000..1198fc5c --- /dev/null +++ b/src/components/profile-heading/ProfileHeading.ts @@ -0,0 +1,344 @@ +import { authn, store } from 'solid-logic' +import type { NamedNode, Node } from 'rdflib' +import { customElement, WebComponent } from 'solid-ui' +import { html, nothing, type PropertyValues, type TemplateResult } from 'lit' +import { property, state } from 'lit/decorators.js' +import { createHeadingEditDialog } from './editor/sections/heading/HeadingEditDialog' +import type { ProfileDetails, ProfilePresentation } from './editor/sections/heading/types' +import type { PointDetails } from './editor/sections/contactInfo/types' +import type { ViewerMode } from '../../types' +import { addFriend, checkFriend, friendTexts } from './friendActions' +import { formatDisplayError } from './editor/utils/errorDisplay' +import 'solid-ui/components/button' +import '~icons/lucide/cake' +import '~icons/lucide/circle-user-round' +import '~icons/lucide/mail' +import '~icons/lucide/map-pin' +import '~icons/lucide/pencil' +import '~icons/lucide/phone' +import '~icons/lucide/user-round-plus' +import styles from './ProfileHeading.styles.css' + +export type ProfileHeadingPoint = Pick + +// The heading renders a subset of the shared presentation model used by the editor. +export type ProfileHeadingData = + Pick & { + primaryPhone?: ProfileHeadingPoint + primaryEmail?: ProfileHeadingPoint + } + +export interface ProfileHeadingEditContext { + subject: NamedNode + profileData: ProfileDetails + viewerMode: ViewerMode + onSaved: () => Promise | void +} + +@customElement('solid-panes-profile-heading') +export default class ProfileHeading extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor profileData: ProfileHeadingData | undefined = undefined + + @property({ attribute: false }) + accessor editContext: ProfileHeadingEditContext | undefined = undefined + + @property({ attribute: false }) + accessor friendSubject: NamedNode | undefined = undefined + + @property({ type: Boolean, reflect: true }) + accessor compact = false + + @state() + accessor failedImageSrc: string | undefined = undefined + + @state() + accessor friendState: 'loading' | 'available' | 'exists' | 'saving' | 'error' = 'loading' + + @state() + accessor friendMessage = '' + + @state() + accessor friendError = false + + private friendRequest = 0 + private friendRequestInterrupted = false + private friendMessageTimer: ReturnType | undefined + + connectedCallback (): void { + super.connectedCallback() + if (this.friendRequestInterrupted) { + this.friendRequestInterrupted = false + this.startFriendCheck() + } + } + + disconnectedCallback (): void { + super.disconnectedCallback() + this.friendRequest++ + if (this.friendState === 'loading' || this.friendState === 'saving') { + this.friendRequestInterrupted = true + } + this.clearFriendMessage() + } + + private clearFriendMessage (): void { + clearTimeout(this.friendMessageTimer) + this.friendMessageTimer = undefined + this.friendMessage = '' + } + + protected willUpdate (changed: PropertyValues): void { + super.willUpdate(changed) + if (changed.has('profileData') && changed.get('profileData')?.imageSrc !== this.profileData?.imageSrc) { + this.failedImageSrc = undefined + } + if (!changed.has('friendSubject')) return + this.friendRequestInterrupted = false + this.startFriendCheck() + } + + private startFriendCheck (): void { + const request = ++this.friendRequest + this.friendState = 'loading' + this.clearFriendMessage() + this.friendError = false + const subject = this.friendSubject + const viewer = authn.currentUser() + if (!subject || !viewer || viewer.sameTerm(subject)) return + + checkFriend(viewer, subject).then((exists) => { + if (request !== this.friendRequest || this.friendSubject !== subject) return + this.friendState = exists ? 'exists' : 'available' + }).catch((error: unknown) => { + if (request !== this.friendRequest || this.friendSubject !== subject) return + this.friendState = 'error' + this.showFriendMessage(error, true) + }) + } + + private async showFriendMessage (message: unknown, error: boolean): Promise { + this.clearFriendMessage() + this.friendMessage = formatDisplayError(message) + this.friendError = error + this.friendMessageTimer = setTimeout(() => { + this.clearFriendMessage() + }, 10000) + await this.updateComplete + this.shadowRoot?.querySelector('.friend-message')?.focus() + } + + private handleAddFriend = async (event: Event): Promise => { + event.preventDefault() + if (this.friendState !== 'available' && this.friendState !== 'error') return + const subject = this.friendSubject + if (!subject) return + const request = this.friendRequest + this.friendState = 'saving' + this.clearFriendMessage() + try { + const viewer = authn.currentUser() + if (!viewer) throw new Error(friendTexts.loginRequired) + await addFriend(viewer, subject) + if (request !== this.friendRequest || this.friendSubject !== subject) return + this.friendState = 'exists' + await this.showFriendMessage(friendTexts.success, false) + } catch (error: unknown) { + if (request !== this.friendRequest || this.friendSubject !== subject) return + this.friendState = 'error' + await this.showFriendMessage(error, true) + } + } + + private handleEdit = (event: Event) => { + const context = this.editContext + if (!context) return + + createHeadingEditDialog( + event, + store, + context.subject, + context.profileData, + context.viewerMode, + async () => { + this.dispatchEvent(new CustomEvent('profile-heading-saved', { + bubbles: true, + composed: true, + detail: { subjectUri: context.subject.value } + })) + await context.onSaved() + } + ).catch((error: unknown) => { + console.error('Failed to edit profile heading.', error) + }) + } + + private handleImageError = (event: Event) => { + this.failedImageSrc = (event.currentTarget as HTMLImageElement).getAttribute('src') ?? undefined + } + + render (): TemplateResult<1> { + const profile = this.profileData + if (!profile) return html`` + + const phone = textValue(profile.primaryPhone?.valueNode).replace(/^tel:/i, '') + const email = textValue(profile.primaryEmail?.valueNode).replace(/^mailto:/i, '') + const dateOfBirth = displayDate(profile.dateOfBirth) + const showImage = Boolean(profile.imageSrc && this.failedImageSrc !== profile.imageSrc) + const viewer = authn.currentUser() + const showFriend = Boolean(this.friendSubject && viewer && !viewer.sameTerm(this.friendSubject)) + + return html` +
+
+
+ ${showImage + ? html`${profile.name}` + : nothing} + +
+
+ +
+
+
+

${profile.name}

+ ${profile.pronouns ? html`(${profile.pronouns})` : nothing} +
+ ${profile.jobTitle ? html`
${profile.jobTitle}
` : nothing} +
+
+ + ${(this.editContext || showFriend) && !this.compact + ? html` +
+ ${showFriend + ? html` +
+ ${this.friendState === 'exists' + ? nothing + : html``}${this.friendState === 'exists' ? friendTexts.exists : friendTexts.add} + ${this.friendMessage + ? html` +
+ ${this.friendMessage} +
` + : nothing} +
+ ` + : nothing} + ${this.editContext + ? html` + + + Edit + + + + + ` + : nothing} +
+ ` + : nothing} + +
+
+
+ ${this.renderLine(dateOfBirth, 'birthday')} + ${this.renderLine(profile.location, 'location')} +
+
+ ${this.renderLine(phone, 'phone')} + ${this.renderLine(email, 'email')} +
+
+
+
+ ` + } + + private renderLine (value: string | undefined, icon: 'birthday' | 'location' | 'phone' | 'email') { + if (!value) return nothing + + return html` +
+ + ${value} +
+ ` + } + + private renderIcon (icon: 'birthday' | 'location' | 'phone' | 'email'): TemplateResult { + switch (icon) { + case 'birthday': + return html`` + case 'location': + return html`` + case 'phone': + return html`` + case 'email': + return html`` + } + } + + private renderEditIcon (): TemplateResult { + return html`` + } +} + +function textValue (value: Node | undefined): string { + return value?.value ?? '' +} + +function displayDate (value?: string): string { + const raw = (value || '').trim() + const isoDate = raw.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (isoDate) { + const [, year, month, day] = isoDate + return `${day}-${month}-${year}` + } + + const dmyDate = raw.match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/) + if (dmyDate) { + const [, day, month, year] = dmyDate + return `${day}-${month}-${year}` + } + + return raw +} diff --git a/src/components/profile-heading/editor/index.ts b/src/components/profile-heading/editor/index.ts new file mode 100644 index 00000000..a90608ef --- /dev/null +++ b/src/components/profile-heading/editor/index.ts @@ -0,0 +1,2 @@ +export { createHeadingEditDialog } from './sections/heading/HeadingEditDialog' +export type { ProfileDetails } from './sections/heading/types' diff --git a/src/components/profile-heading/editor/sections/contactInfo/types.ts b/src/components/profile-heading/editor/sections/contactInfo/types.ts new file mode 100644 index 00000000..3c4dd374 --- /dev/null +++ b/src/components/profile-heading/editor/sections/contactInfo/types.ts @@ -0,0 +1,50 @@ +import { Node } from 'rdflib' +import { MutationOps } from '../shared/types' +/* using Node instead of NamedNode because the data could +also be a blank node because it's coming from user-edited profile data */ +export interface PointDetails { + entryNode: Node, + type: Node, + valueNode: Node +} +export interface AddressDetails { + entryNode: Node, + type?: Node, + streetAddress?: string, + locality?: string, + region?: string, + postalCode?: string, + countryName?: string +} + +export interface ContactInfo { + emails: PointDetails[], + phones: PointDetails[], + addresses: AddressDetails[] +} + +export type ContactRowStatus = 'existing' | 'new' | 'modified' | 'deleted' + +export type ContactPointRow = { + value: string + type: string + entryNode: string + status: ContactRowStatus +} + +export type ContactAddressRow = { + streetAddress?: string, + locality?: string, + region?: string, + postalCode?: string, + countryName?: string + type: string + entryNode: string + status: ContactRowStatus +} + +export type ContactMutationPlan = { + phoneOps: MutationOps + emailOps: MutationOps + addressOps: MutationOps +} diff --git a/src/components/profile-heading/editor/sections/heading/HeadingEditDialog.ts b/src/components/profile-heading/editor/sections/heading/HeadingEditDialog.ts new file mode 100644 index 00000000..7d42fac7 --- /dev/null +++ b/src/components/profile-heading/editor/sections/heading/HeadingEditDialog.ts @@ -0,0 +1,933 @@ +import { openInputDialog } from '../../ui/dialog' +import { html, render, TemplateResult } from 'lit-html' +import 'solid-ui/components/button' +import 'solid-ui/components/combobox' +import 'solid-ui/components/combobox-option' +import { type ComboboxChangeEvent } from 'solid-ui/components/combobox' +import 'solid-ui/components/photo-capture' +import type { PhotoCapture } from 'solid-ui/components/photo-capture' +import { ProfileDetails, HeadingMutationPlan, ProfileBasicRow } from './types' +import { Image } from './imagePreview' +import '../../styles/EditDialogs.css' +import { LiveStore, NamedNode } from 'rdflib' +import { processHeadingMutations } from './mutations' +import { ViewerMode } from '../../../../../types' +import { + combinePhoneValue, + splitPhoneValue +} from '../shared/phoneCountries' +import { + normalizeEmailTypeForEdit, + normalizePhoneTypeForEdit, + toSavedHeadingEmailType, + toSavedHeadingPhoneType +} from '../shared/contactTypeUtils' +import { applyRowFieldChange, applyRowSelectChange, summarizeRowOps } from '../shared/rowState' +import { hasNonEmptyText, sanitizeTextValue, toText, toTypeLabel } from '../../textUtils' +import { + dialogCancelLabelText, + dialogSubmitLabelText, + editHeadingDialogTitleText, + ownerLoginRequiredDialogMessageText, + saveHeadingUpdatesFailedMessageText +} from '../../texts' +import '~icons/lucide/camera' +import { ContactAddressRow, ContactPointRow } from '../contactInfo/types' +import { sanitizeAddressFieldValue, sanitizeBasicInputFieldValue, sanitizeEmailValue, sanitizePhoneLocalValue } from '../shared/sanitizeUtils' +import { toStorageDateISO } from './dateHelpers' +import { invalidateResolvedPhotoDisplaySrc, resolvePhotoDisplaySrc, uploadPhotoFile } from './imageHelpers' +/* Note: new design - has address type in More Edit Contacts for now we will leave + out Address Type, but a ticket will be created to add type later + so I will keep the code and just comment it out for now. + new design - has country code, will comment out code for now and create a ticket to add later. */ + +type HeadingFormState = { + basicInfo: ProfileBasicRow + email: ContactPointRow + phone: ContactPointRow + address: ContactAddressRow + emailTypeWasMissing: boolean + phoneTypeWasMissing: boolean + pendingImageFile?: File | null + imagePreviewSrc: string + clearImagePreview: () => void +} + +type HeadingContactTypeOption = { + label: string + value: string +} + +type HeadingPronounsOption = { + label: string + value: string +} + +type HeadingContactTypeKind = 'phone' | 'email' + +const HEADING_PHONE_TYPE_OPTIONS: HeadingContactTypeOption[] = [ + { label: 'Mobile', value: 'Mobile' }, + { label: 'Home', value: 'Home' }, + { label: 'Work', value: 'Work' } +] + +const HEADING_EMAIL_TYPE_OPTIONS: HeadingContactTypeOption[] = [ + { label: 'Personal', value: 'Personal' }, + { label: 'Office', value: 'Office' } +] + +const HEADING_PRONOUN_OPTIONS: HeadingPronounsOption[] = [ + { label: 'He/Him', value: 'He/Him' }, + { label: 'She/Her', value: 'She/Her' }, + { label: 'They/Them', value: 'They/Them' } +] + +type Row = ProfileBasicRow | ContactPointRow | ContactAddressRow + +function isContactPointRow (row: Row): row is ContactPointRow { + return 'value' in row +} + +function isAddressRow (row: Row): row is ContactAddressRow { + return 'streetAddress' in row +} + +function isProfileBasicRow (row: Row): row is ProfileBasicRow { + return 'name' in row +} + +function normalizeHeadingContactTypeValue (value: string, options: HeadingContactTypeOption[]): string { + return options.some((option) => option.value === value) ? value : options[0]?.value || '' +} + +function getHeadingContactTypeOptions (kind: HeadingContactTypeKind): HeadingContactTypeOption[] { + return kind === 'phone' ? HEADING_PHONE_TYPE_OPTIONS : HEADING_EMAIL_TYPE_OPTIONS +} + +function withDefaultHeadingContactType ( + row: ContactPointRow, + kind: HeadingContactTypeKind +): ContactPointRow { + return { + ...row, + type: normalizeHeadingContactTypeValue(row.type || '', getHeadingContactTypeOptions(kind)) + } +} + +function rowHasContent (row: Row): boolean { + if (isContactPointRow(row)) { + return hasNonEmptyText(row.value) + } + if (isAddressRow(row)) { + return [ + row.streetAddress, + row.locality, + row.region, + row.postalCode, + row.countryName + ].some(hasNonEmptyText) + } + if (isProfileBasicRow(row)) { + return [ + row.name, + row.nickname, + row.imageSrc, + row.location, + row.pronouns, + row.dateOfBirth + ].some(hasNonEmptyText) + } + return false +} + +function normalizePronounsValue (value: string | undefined): string { + const normalized = sanitizeTextValue(value || '').toLowerCase().replace(/\s+/g, '') + if (!normalized) return '' + if (normalized === 'he' || normalized === 'he/him') return 'He/Him' + if (normalized === 'she' || normalized === 'she/her') return 'She/Her' + if (normalized === 'they' || normalized === 'they/them') return 'They/Them' + return value || '' +} + +function toFormState (profileData: ProfileDetails): HeadingFormState { + const basicInfo: ProfileBasicRow = { + name: sanitizeTextValue(toText(profileData.name)), + nickname: sanitizeTextValue(toText(profileData.nickname || '')), + imageSrc: sanitizeTextValue(toText(profileData.imageSrc || '')), + location: sanitizeTextValue(toText(profileData.location || '')), + pronouns: normalizePronounsValue(toText(profileData.pronouns || '')), + dateOfBirth: sanitizeTextValue(toText(profileData.dateOfBirth || '')), + entryNode: toText(profileData.entryNode), + status: toText(profileData.entryNode) ? 'existing' as const : 'new' as const + } + const primaryEmail = profileData.primaryEmail + const primaryPhone = profileData.primaryPhone + const primaryAddress = profileData.primaryAddress + const emailTypeWasMissing = !normalizeEmailTypeForEdit(primaryEmail?.type) + const phoneTypeWasMissing = !normalizePhoneTypeForEdit(primaryPhone?.type) + + const normalizedEmailType = normalizeEmailTypeForEdit(primaryEmail?.type) + const normalizedPhoneType = normalizePhoneTypeForEdit(primaryPhone?.type) + + const email: ContactPointRow = { + value: sanitizeEmailValue(toText(primaryEmail?.valueNode).replace(/^mailto:/i, '')), + type: normalizedEmailType, + entryNode: toText(primaryEmail?.entryNode), + status: toText(primaryEmail?.entryNode) ? 'existing' as const : 'new' as const + } + const phone: ContactPointRow = { + value: sanitizeTextValue( + toText(primaryPhone?.valueNode || primaryPhone?.entryNode || '').replace(/^tel:/i, '') + ), + type: normalizedPhoneType, + entryNode: toText(primaryPhone?.entryNode || ''), + status: toText(primaryPhone?.entryNode || '') ? 'existing' as const : 'new' as const + } + const address: ContactAddressRow = { + streetAddress: sanitizeAddressFieldValue(toText(primaryAddress?.streetAddress)), + locality: sanitizeAddressFieldValue(toText(primaryAddress?.locality)), + region: sanitizeAddressFieldValue(toText(primaryAddress?.region)), + postalCode: sanitizeAddressFieldValue(toText(primaryAddress?.postalCode)), + countryName: sanitizeAddressFieldValue(toText(primaryAddress?.countryName)), + type: toTypeLabel(primaryAddress?.type), + entryNode: toText(primaryAddress?.entryNode), + status: toText(primaryAddress?.entryNode) ? 'existing' as const : 'new' as const + } + + return { + basicInfo: (basicInfo) || { name: '', nickname: '', imageSrc: '', location: '', pronouns: '', dateOfBirth: '', entryNode: '', status: 'new' }, + email: (email) || { value: '', type: '', entryNode: '', status: 'new' }, + phone: (phone) || { value: '', type: '', entryNode: '', status: 'new' }, + address: (address) || { streetAddress: '', locality: '', region: '', postalCode: '', countryName: '', type: '', entryNode: '', status: 'new' }, + emailTypeWasMissing, + phoneTypeWasMissing, + imagePreviewSrc: '', + clearImagePreview: () => undefined + } +} + +function setHeadingImagePreview (formState: HeadingFormState, file: File) { + formState.clearImagePreview() + + if (typeof URL.createObjectURL !== 'function') { + formState.imagePreviewSrc = '' + formState.clearImagePreview = () => undefined + return + } + + const previewUrl = URL.createObjectURL(file) + formState.imagePreviewSrc = previewUrl + formState.clearImagePreview = () => { + URL.revokeObjectURL(previewUrl) + formState.imagePreviewSrc = '' + formState.clearImagePreview = () => undefined + } +} + +function setResolvedHeadingPreview (formState: HeadingFormState, resolvedImageSrc?: string) { + formState.clearImagePreview() + + if (!resolvedImageSrc) { + return + } + + formState.imagePreviewSrc = resolvedImageSrc + + formState.clearImagePreview = () => { + formState.imagePreviewSrc = '' + formState.clearImagePreview = () => undefined + } +} + +type ProfileBasicEditableField = + | 'name' + | 'nickname' + | 'imageSrc' + | 'pronouns' + | 'dateOfBirth' + +type ContactAddressEditableField = + | 'streetAddress' + | 'locality' + | 'region' + | 'postalCode' + | 'countryName' + +type ContactPhoneInputRowProps = { + phone: ContactPointRow +} + +type ContactEmailInputRowProps = { + email: ContactPointRow +} + +type ContactAddressInputRowProps = { + address: ContactAddressRow +} + +function mapEmailOpsForSave (ops: { create: ContactPointRow[], update: ContactPointRow[], remove: ContactPointRow[] }) { + const mapRow = (row: ContactPointRow): ContactPointRow => ({ + ...withDefaultHeadingContactType(row, 'email'), + type: toSavedHeadingEmailType(withDefaultHeadingContactType(row, 'email').type) + }) + + return { + create: ops.create.map(mapRow), + update: ops.update.map(mapRow), + remove: ops.remove.map(mapRow) + } +} + +function mapPhoneOpsForSave (ops: { create: ContactPointRow[], update: ContactPointRow[], remove: ContactPointRow[] }) { + const mapRow = (row: ContactPointRow): ContactPointRow => ({ + ...withDefaultHeadingContactType(row, 'phone'), + type: toSavedHeadingPhoneType(withDefaultHeadingContactType(row, 'phone').type) + }) + + return { + create: ops.create.map(mapRow), + update: ops.update.map(mapRow), + remove: ops.remove.map(mapRow) + } +} + +function summarizeHeadingContactOps ( + row: ContactPointRow, + kind: HeadingContactTypeKind, + typeWasMissing: boolean +) { + const ops = summarizeRowOps([row], rowHasContent) + + if ( + typeWasMissing && + row.entryNode && + row.status === 'existing' && + rowHasContent(row) && + ops.create.length === 0 && + ops.update.length === 0 && + ops.remove.length === 0 + ) { + return { + create: ops.create, + update: [withDefaultHeadingContactType({ ...row, status: 'modified' }, kind)], + remove: ops.remove + } + } + + return ops +} + +function renderContactPhoneInput ({ + phone +}: ContactPhoneInputRowProps) { + const label = 'Mobile Number' + const typeLabel = 'Phone Type 1' + const inputName = 'phone-value' + const splitValue = splitPhoneValue(phone?.value || '') + const selectedDialCode = splitValue.dialCode + + const handleValueInput = (e: Event) => { + const target = e.target as HTMLInputElement + const nextValue = sanitizePhoneLocalValue(target.value) + if (phone) { + applyRowFieldChange(phone, 'value', combinePhoneValue(selectedDialCode, nextValue), rowHasContent) + } + } + + const handleTypeInput = (e: Event) => { + const event = e as ComboboxChangeEvent + if (!event.detail.option) return + const nextType = String(event.detail.option.value) + if (phone) { + applyRowSelectChange(phone, 'type', nextType) + } + } + + return html` +
+
+ +
+ +
+ ` +} + +function renderContactEmailInputRow ({ + email +}: ContactEmailInputRowProps) { + const label = 'Email' + const typeLabel = 'Email Type' + const inputName = 'email-value' + + const handleValueInput = (e: Event) => { + const target = e.target as HTMLInputElement + const nextValue = sanitizeEmailValue(target.value) + if (email) { + applyRowFieldChange(email, 'value', nextValue, rowHasContent) + } + } + + const handleTypeInput = (e: Event) => { + const event = e as ComboboxChangeEvent + if (!event.detail.option) return + const nextType = String(event.detail.option.value) + if (email) { + applyRowSelectChange(email, 'type', nextType) + } + } + + return html` +
+ + +
+ ` +} + +function renderContactAddressInput ({ + address +}: ContactAddressInputRowProps) { + const label = 'Address' + /* const typeLabel = 'Address Type' */ + /* const typeInputName = 'address-type' */ + /* const addressTypeSelectId = 'address-type-select' */ + const streetAddressName = 'address-street' + const localityName = 'address-locality' + const regionName = 'address-region' + const postalCodeName = 'address-postal' + const countryName = 'address-country' + + const handleAddressInput = (field: ContactAddressEditableField) => (e: Event) => { + const target = e.target as HTMLInputElement + const nextValue = sanitizeAddressFieldValue(target.value) + if (address) { + applyRowFieldChange(address, field, nextValue, rowHasContent) + } + } + + /* const handleTypeInput = (e: Event) => { + const target = e.target as HTMLInputElement + const nextType = target.value + if (address) { + applyRowSelectChange(address, 'type', nextType) + } + } */ + + return html` + + +
+ + +
+ +
+ + +
+ ` +} + +function renderHeadingInfoInput ( + formState: HeadingFormState, + rerender: () => void +): TemplateResult { + const { basicInfo, phone, email, imagePreviewSrc } = formState + const imageSrcLabel = 'Profile Photo' + const recommendedImageToLoad = 'Recommended: Square JPG, PNG. Max 2MB.' + const nameLabel = 'Full Name' + const nicknameLabel = 'Nickname' + const pronounsLabel = 'Pronouns' + const dateOfBirthLabel = 'DOB' + + const handleBasicInfoInput = (field: ProfileBasicEditableField) => (e: Event) => { + const target = e.target as HTMLInputElement + const nextValue = sanitizeBasicInputFieldValue(target.value) + if (basicInfo) { + applyRowFieldChange(basicInfo, field, nextValue, rowHasContent) + } + } + + const handleDateOfBirthInput = (e: Event) => { + const target = e.target as HTMLInputElement + const nextValue = sanitizeBasicInputFieldValue(target.value) + if (basicInfo) { + applyRowFieldChange(basicInfo, 'dateOfBirth', toStorageDateISO(nextValue), rowHasContent) + } + } + const handlePronounsInput = (e: Event) => { + const event = e as ComboboxChangeEvent + if (!event.detail.option) return + const nextValue = normalizePronounsValue(String(event.detail.option.value)) + if (basicInfo) { + applyRowFieldChange(basicInfo, 'pronouns', nextValue, rowHasContent) + } + } + + const handleUpload = async (e: Event) => { + const button = e.currentTarget as HTMLButtonElement | null + const dom = button?.ownerDocument || document + const fileInput = dom.createElement('input') + fileInput.type = 'file' + fileInput.accept = 'image/*' + fileInput.hidden = true + + const cleanupFileInput = () => { + fileInput.remove() + } + + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0] + cleanupFileInput() + if (!file || !basicInfo) return + + try { + formState.pendingImageFile = file + setHeadingImagePreview(formState, file) + basicInfo.status = basicInfo.entryNode ? 'modified' : 'new' + rerender() + } catch (error) { + console.error('Profile image upload failed', error) + } + }, { once: true }) + + dom.body.appendChild(fileInput) + fileInput.click() + } + + const handleCameraInput = async (event: InputEvent) => { + const file = (event.target as PhotoCapture).value + + if (!file || !basicInfo) return + + try { + formState.pendingImageFile = file + setHeadingImagePreview(formState, file) + basicInfo.status = basicInfo.entryNode ? 'modified' : 'new' + rerender() + } catch (error) { + console.error('Profile camera upload failed', error) + } + } + + const handleDelete = async () => { + if (!basicInfo) return + formState.pendingImageFile = null + formState.clearImagePreview() + applyRowFieldChange(basicInfo, 'imageSrc', '', rowHasContent) + rerender() + } + + return html` +
+
+
+ ${Image(imagePreviewSrc || basicInfo.imageSrc, basicInfo.name)} + + + +
+
+ +
+

${imageSrcLabel}

+

${recommendedImageToLoad}

+ +
+ + Upload + + + Remove + +
+
+
+
+ +
+
+
+ + +
+
+
+ + + ${HEADING_PRONOUN_OPTIONS.map((option) => html`${option.label}`)} + +
+
+ + +
+
+
+
+ ${renderContactPhoneInput({ phone })} +
+
+ ${renderContactEmailInputRow({ email })} +
+
+
+ ` +} + +function renderHeadingEditTemplate ( + form: HTMLFormElement, + formState: HeadingFormState, + viewerMode: ViewerMode +) { + const rerender = () => renderHeadingEditTemplate(form, formState, viewerMode) + + render(html` + ${renderHeadingInfoInput(formState, rerender)} + ${renderContactAddressInput({ address: formState.address })} + ${viewerMode !== 'owner' + ? html`` + : null} + `, form) +} + +function createHeadingEditForm ( + profileData: ProfileDetails, + viewerMode: ViewerMode +) { + const form = document.createElement('form') + form.classList.add('profile__edit-form', 'profile__edit-form--heading', 'profile-edit-dialog--heading') + form.autocomplete = 'off' + form.setAttribute('data-lpignore', 'true') + form.setAttribute('data-1p-ignore', 'true') + form.setAttribute('data-bwignore', 'true') + + const formState = toFormState(profileData) + renderHeadingEditTemplate(form, formState, viewerMode) + + return { form, formState } +} + +function isValidHeadingEmailAddress (value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) +} + +function isValidHeadingPhoneNumber (value: string): boolean { + return /^\d+$/.test(value) +} + +function validateHeadingDataBeforeSave (formState: HeadingFormState): string | null { + if (!hasNonEmptyText(formState.basicInfo.name)) { + return 'Full Name is required.' + } + + const { localNumber } = splitPhoneValue(formState.phone?.value || '') + if (rowHasContent(formState.phone) && !isValidHeadingPhoneNumber(localNumber)) { + return 'Phone Number 1 should contain only numbers.' + } + + if (rowHasContent(formState.email) && !isValidHeadingEmailAddress(formState.email?.value || '')) { + return 'Email address must be a valid email address.' + } + + return null +} + +export async function createHeadingEditDialog ( + _event: Event, + store: LiveStore, + subject: NamedNode, + profileData: ProfileDetails, + viewerMode: ViewerMode, + onSaved?: () => Promise | void +) { + const dom = document + const originalPhotoUri = sanitizeTextValue(toText(profileData.imageSrc || '')) + const { form, formState } = createHeadingEditForm(profileData, viewerMode) + + if (formState.basicInfo.imageSrc) { + const resolvedImageSrc = await resolvePhotoDisplaySrc(store, formState.basicInfo.imageSrc) + if (resolvedImageSrc && resolvedImageSrc !== formState.basicInfo.imageSrc) { + setResolvedHeadingPreview(formState, resolvedImageSrc) + renderHeadingEditTemplate(form, formState, viewerMode) + } + } + + const result = await openInputDialog({ + title: editHeadingDialogTitleText, + dom, + form, + headerAction: { type: 'none' }, + submitLabel: dialogSubmitLabelText, + cancelLabel: dialogCancelLabelText, + shouldCloseWithoutSave: () => { + const basicInfoOps = summarizeRowOps([formState.basicInfo], rowHasContent) + const phoneOps = summarizeHeadingContactOps(formState.phone, 'phone', formState.phoneTypeWasMissing) + const emailOps = summarizeHeadingContactOps(formState.email, 'email', formState.emailTypeWasMissing) + const addressOps = summarizeRowOps([formState.address], rowHasContent) + + return ( + basicInfoOps.create.length === 0 && basicInfoOps.update.length === 0 && basicInfoOps.remove.length === 0 && + phoneOps.create.length === 0 && phoneOps.update.length === 0 && phoneOps.remove.length === 0 && + emailOps.create.length === 0 && emailOps.update.length === 0 && emailOps.remove.length === 0 && + addressOps.create.length === 0 && addressOps.update.length === 0 && addressOps.remove.length === 0 + ) + }, + validate: () => { + if (viewerMode !== 'owner') { + return ownerLoginRequiredDialogMessageText + } + return validateHeadingDataBeforeSave(formState) + }, + onSave: async () => { + if (formState.pendingImageFile) { + const uploadedUri = await uploadPhotoFile(store, subject, formState.pendingImageFile) + applyRowFieldChange(formState.basicInfo, 'imageSrc', uploadedUri, rowHasContent) + } + + const phoneOps = summarizeHeadingContactOps(formState.phone, 'phone', formState.phoneTypeWasMissing) + const emailOps = summarizeHeadingContactOps(formState.email, 'email', formState.emailTypeWasMissing) + const plan: HeadingMutationPlan = { + basicOps: summarizeRowOps([formState.basicInfo], rowHasContent), + phoneOps: mapPhoneOpsForSave(phoneOps), + emailOps: mapEmailOpsForSave(emailOps), + addressOps: summarizeRowOps([formState.address], rowHasContent) + } + await processHeadingMutations(store, subject, plan) + + const nextPhotoUri = sanitizeTextValue(formState.basicInfo.imageSrc || '') + if (originalPhotoUri && originalPhotoUri !== nextPhotoUri) { + try { + invalidateResolvedPhotoDisplaySrc(originalPhotoUri) + } catch (error) { + console.warn('Failed to invalidate resolved photo cache', error) + } + } + }, + formatSaveError: (error: unknown) => { + return error instanceof Error ? error.message : saveHeadingUpdatesFailedMessageText + } + }) + + if (!result) { + return + } + + if (onSaved) { + await onSaved() + } +} diff --git a/src/components/profile-heading/editor/sections/heading/dateHelpers.ts b/src/components/profile-heading/editor/sections/heading/dateHelpers.ts new file mode 100644 index 00000000..186c0ed6 --- /dev/null +++ b/src/components/profile-heading/editor/sections/heading/dateHelpers.ts @@ -0,0 +1,52 @@ +function normalizeDmy (value: string): string { + const dmyMatch = value.match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/) + if (!dmyMatch) return '' + + const [, day, month, year] = dmyMatch + return `${day}-${month}-${year}` +} + +export function toDisplayDateDMY (value: string | undefined, emptyFallback = ''): string { + const raw = (value || '').trim() + if (!raw) return emptyFallback + + const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (isoMatch) { + const [, year, month, day] = isoMatch + return `${day}-${month}-${year}` + } + + const normalizedDmy = normalizeDmy(raw) + if (normalizedDmy) return normalizedDmy + + return raw +} + +export function toEditableDateDMY (value: string | undefined): string { + const raw = (value || '').trim() + if (!raw) return '' + + const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (isoMatch) { + const [, year, month, day] = isoMatch + return `${day}-${month}-${year}` + } + + return normalizeDmy(raw) +} + +export function toStorageDateISO (value: string | undefined): string { + const raw = (value || '').trim() + if (!raw) return '' + + const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (isoMatch) return raw + + const dmyMatch = raw.match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/) + if (dmyMatch) { + const [, day, month, year] = dmyMatch + return `${year}-${month}-${day}` + } + + return '' +} diff --git a/src/components/profile-heading/editor/sections/heading/imageHelpers.ts b/src/components/profile-heading/editor/sections/heading/imageHelpers.ts new file mode 100644 index 00000000..dd3dcb9b --- /dev/null +++ b/src/components/profile-heading/editor/sections/heading/imageHelpers.ts @@ -0,0 +1,134 @@ +import { LiveStore, NamedNode, sym } from 'rdflib' +import { ns } from 'solid-ui' +import { authn, solidLogicSingleton } from 'solid-logic' + +const resolvedHeadingImageCache = new Map() + +export function invalidateResolvedPhotoDisplaySrc (imageSrc?: string): void { + if (!imageSrc) return + + const cachedImageSrc = resolvedHeadingImageCache.get(imageSrc) + if (!cachedImageSrc) return + + resolvedHeadingImageCache.delete(imageSrc) + if (cachedImageSrc.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(cachedImageSrc) + } +} +/* Code copied from contact-pane/src/mugshotGallery and modified to fit the needs of the new design */ +const mimeMap: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tiff', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'text/html': 'html', + 'application/json': 'json', + 'application/octet-stream': 'bin' +} + +const extMap = Object.fromEntries( + Object.entries(mimeMap).map(([contentType, extension]) => [extension, contentType]) +) as Record + +const mime = { + extension: (contentType: string): string | false => mimeMap[contentType] || false, + lookup: (filename: string): string | false => { + const ext = filename.split('.').pop()?.toLowerCase() || '' + return extMap[ext] || false + } +} + +function subjectDirectoryUri (subject: NamedNode): string { + const docUri = subject.doc().uri + const lastSlash = docUri.lastIndexOf('/') + return lastSlash >= 0 ? docUri.slice(0, lastSlash + 1) : docUri +} + +export async function resolvePhotoDisplaySrc (store: LiveStore, imageSrc?: string): Promise { + if (!imageSrc || imageSrc.startsWith('blob:') || imageSrc.startsWith('data:')) { + return imageSrc + } + + const cachedImageSrc = resolvedHeadingImageCache.get(imageSrc) + if (cachedImageSrc) { + return cachedImageSrc + } + + const fetcher = store.fetcher as { _fetch?: (url: string) => Promise } | undefined + if (!fetcher?._fetch || typeof URL.createObjectURL !== 'function') { + return imageSrc + } + + try { + const response = await fetcher._fetch(imageSrc) + if (!response.ok) { + return imageSrc + } + + const imageBlob = await response.blob() + const resolvedImageSrc = URL.createObjectURL(imageBlob) + resolvedHeadingImageCache.set(imageSrc, resolvedImageSrc) + return resolvedImageSrc + } catch { + return imageSrc + } +} + +export async function uploadPhotoFile (store: LiveStore, subject: NamedNode, file: File): Promise { + if (!store.fetcher) { + throw new Error('Store has no fetcher.') + } + + const detectedContentType = file.type || mime.lookup(file.name) || 'application/octet-stream' + if (!detectedContentType.startsWith('image/')) { + throw new Error('Selected file is not an image.') + } + if (file.size > 2 * 1024 * 1024) { + throw new Error('Selected image must be 2 MB or smaller.') + } + + const extension = mime.extension(detectedContentType) || 'bin' + let filename = encodeURIComponent(file.name || `image.${extension}`) + + if (detectedContentType !== mime.lookup(file.name || '')) { + filename += `_.${extension}` + } + + const directoryUri = subjectDirectoryUri(subject) + let candidateUri = `${directoryUri}${filename}` + + for (let index = 0; store.holds(subject, ns.vcard('hasPhoto'), sym(candidateUri)); index++) { + const fallbackName = `image_${index}.${extension}` + candidateUri = `${directoryUri}${fallbackName}` + } + try { + const data = await file.arrayBuffer() + const response = await store.fetcher.webOperation('PUT', candidateUri, { + data: data as unknown as string, + contentType: detectedContentType + } as any) + if (!response.ok) { + throw new Error(`Error uploading picture: ${response.status} ${response.statusText}`) + } + } catch (error) { + throw new Error(`Error uploading picture: ${error}`) + } + + const currentUser = authn.currentUser() + if (currentUser) { + try { + await solidLogicSingleton.acl.setACLUserPublic(candidateUri, currentUser, { + public: ['Read'] as unknown as [] + }) + } catch (error) { + console.error(`Error setting uploaded picture permissions: ${error}`) + } + } + + return candidateUri +} diff --git a/src/components/profile-heading/editor/sections/heading/imagePreview.ts b/src/components/profile-heading/editor/sections/heading/imagePreview.ts new file mode 100644 index 00000000..6effcdb6 --- /dev/null +++ b/src/components/profile-heading/editor/sections/heading/imagePreview.ts @@ -0,0 +1,34 @@ +import { html, nothing } from 'lit-html' +import '~icons/lucide/circle-user-round' + +const personInCircleIcon = html`` + +const showHeadingImageFallback = (event: Event) => { + const image = event.currentTarget as HTMLImageElement | null + const frame = image?.parentElement + if (!image || !frame) return + + image.hidden = true + frame.classList.add('profile__image-frame--fallback') +} + +export const Image = (src?: string, alt?: string) => html` +
+ ${src + ? html` + ${alt + ` + : nothing} + +
+` diff --git a/src/components/profile-heading/editor/sections/heading/mutations.ts b/src/components/profile-heading/editor/sections/heading/mutations.ts new file mode 100644 index 00000000..7e74b5e9 --- /dev/null +++ b/src/components/profile-heading/editor/sections/heading/mutations.ts @@ -0,0 +1,318 @@ +import { LiveStore, NamedNode, Node, st, literal, sym } from 'rdflib' +import { ns } from 'solid-ui' +import { ProfileBasicRow, HeadingMutationPlan } from './types' +import { MutationOps } from '../shared/types' +import { MutationDocumentTextCache, collectLinkedNodeStatements, collectNodeStatements, findExistingNode, replacePredicateStatements, runUpdateTransport, shouldForceDocumentPutForStatements } from '../shared/rdfMutationHelpers' +import { createIdNode } from '../shared/idNodeFactory' +import { ContactAddressRow, ContactPointRow } from '../contactInfo/types' +import { headingMutationSaveFailedDebugText, saveHeadingUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' + +function splitPronouns (value?: string): { subjectPronoun?: string, objectPronoun?: string } { + const normalized = (value || '').trim() + if (!normalized) return {} + + const [subjectPronounRaw, objectPronounRaw] = normalized.split('/').map((part) => part.trim()) + const subjectPronoun = subjectPronounRaw || undefined + const objectPronoun = objectPronounRaw || undefined + + return { subjectPronoun, objectPronoun } +} + +function buildPhoneStatements (subject: NamedNode, doc: NamedNode, node: Node, phone: ContactPointRow) { + const normalizedValue = phone.value.startsWith('tel:') ? phone.value : `tel:${phone.value}` + const valueNode = sym(normalizedValue) + const inserts = [ + st(subject, ns.vcard('hasTelephone'), node as any, doc), + st(node as any, ns.vcard('value'), valueNode as any, doc) + ] + + if (phone.type) { + inserts.push(st(node as any, ns.rdf('type'), ns.vcard(phone.type), doc)) + } + + return inserts +} + +function buildEmailStatements (subject: NamedNode, doc: NamedNode, node: Node, email: ContactPointRow) { + const normalizedValue = email.value.startsWith('mailto:') ? email.value : `mailto:${email.value}` + const valueNode = sym(normalizedValue) + const inserts = [ + st(subject, ns.vcard('hasEmail'), node as any, doc), + st(node as any, ns.vcard('value'), valueNode as any, doc) + ] + + if (email.type) { + inserts.push(st(node as any, ns.rdf('type'), ns.vcard(email.type), doc)) + } + + return inserts +} + +function buildAddressStatements (subject: NamedNode, doc: NamedNode, node: Node, address: ContactAddressRow) { + const inserts = [st(subject, ns.vcard('hasAddress'), node as any, doc)] + + if (address.type) inserts.push(st(node as any, ns.rdf('type'), ns.vcard(address.type), doc)) + if (address.streetAddress) inserts.push(st(node as any, ns.vcard('street-address'), literal(address.streetAddress), doc)) + if (address.locality) inserts.push(st(node as any, ns.vcard('locality'), literal(address.locality), doc)) + if (address.region) inserts.push(st(node as any, ns.vcard('region'), literal(address.region), doc)) + if (address.postalCode) inserts.push(st(node as any, ns.vcard('postal-code'), literal(address.postalCode), doc)) + if (address.countryName) inserts.push(st(node as any, ns.vcard('country-name'), literal(address.countryName), doc)) + + return inserts +} + +function findOpWithExistingEntry (ops: T[], existingNodes: Node[]): T | undefined { + return ops.find((op) => Boolean(op.entryNode) && Boolean(findExistingNode(existingNodes, op.entryNode))) +} + +function findCreateOp (ops: T[]): T | undefined { + return ops.find((op) => !op.entryNode) +} + +async function mutatePhoneEntry (store: LiveStore, subject: NamedNode, phoneOps: MutationOps, documentTextCache?: MutationDocumentTextCache) { + const doc = subject.doc() + const existingPhoneNodes = store.each(subject, ns.vcard('hasTelephone'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + const removePhone = findOpWithExistingEntry(phoneOps.remove, existingPhoneNodes) + const updatePhone = findOpWithExistingEntry(phoneOps.update, existingPhoneNodes) + const createPhone = findCreateOp(phoneOps.create) || phoneOps.create[0] + + if (removePhone?.entryNode) { + const existingNode = findExistingNode(existingPhoneNodes, removePhone.entryNode) + if (existingNode) { + const linkedPhoneStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasTelephone'), doc) + const matchingLinkStatement = linkedPhoneStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + } + + if (updatePhone) { + const existingNode = findExistingNode(existingPhoneNodes, updatePhone.entryNode) + if (existingNode) { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildPhoneStatements(subject, doc, existingNode, updatePhone)) + } + } + + if (createPhone) { + insertions.push(...buildPhoneStatements(subject, doc, createIdNode(doc), createPhone)) + } + + const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: 'Failed to save heading updates', + documentTextCache, + useDavFallback: false, + usePutFallback: shouldSerializeDocument, + forcePut: shouldSerializeDocument + }) +} + +async function mutateEmailEntry (store: LiveStore, subject: NamedNode, emailOps: MutationOps, documentTextCache?: MutationDocumentTextCache) { + const doc = subject.doc() + const existingEmailNodes = store.each(subject, ns.vcard('hasEmail'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + const removeEmail = findOpWithExistingEntry(emailOps.remove, existingEmailNodes) + const updateEmail = findOpWithExistingEntry(emailOps.update, existingEmailNodes) + const createEmail = findCreateOp(emailOps.create) || emailOps.create[0] + + if (removeEmail?.entryNode) { + const existingNode = findExistingNode(existingEmailNodes, removeEmail.entryNode) + if (existingNode) { + const linkedEmailStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasEmail'), doc) + const matchingLinkStatement = linkedEmailStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + } + + if (updateEmail) { + const existingNode = findExistingNode(existingEmailNodes, updateEmail.entryNode) + if (existingNode) { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildEmailStatements(subject, doc, existingNode, updateEmail)) + } + } + + if (createEmail) { + insertions.push(...buildEmailStatements(subject, doc, createIdNode(doc), createEmail)) + } + + const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: 'Failed to save heading updates', + documentTextCache, + useDavFallback: false, + usePutFallback: shouldSerializeDocument, + forcePut: shouldSerializeDocument + }) +} + +async function mutateAddressEntry (store: LiveStore, subject: NamedNode, addressOps: MutationOps, documentTextCache?: MutationDocumentTextCache) { + const doc = subject.doc() + const existingAddressNodes = store.each(subject, ns.vcard('hasAddress'), null, doc) as Node[] + const deletions: any[] = [] + const insertions: any[] = [] + + const removeAddress = findOpWithExistingEntry(addressOps.remove, existingAddressNodes) + const updateAddress = findOpWithExistingEntry(addressOps.update, existingAddressNodes) + const createAddress = findCreateOp(addressOps.create) || addressOps.create[0] + + if (removeAddress?.entryNode) { + const existingNode = findExistingNode(existingAddressNodes, removeAddress.entryNode) + if (existingNode) { + const linkedAddressStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasAddress'), doc) + const matchingLinkStatement = linkedAddressStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value) + if (matchingLinkStatement) { + deletions.push(matchingLinkStatement) + } + deletions.push(...collectNodeStatements(store, existingNode, doc)) + } + } + + if (updateAddress) { + const existingNode = findExistingNode(existingAddressNodes, updateAddress.entryNode) + if (existingNode) { + deletions.push(...collectNodeStatements(store, existingNode, doc)) + insertions.push(...buildAddressStatements(subject, doc, existingNode, updateAddress)) + } + } + + if (createAddress) { + insertions.push(...buildAddressStatements(subject, doc, createIdNode(doc), createAddress)) + } + + const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: 'Failed to save heading updates', + documentTextCache, + useDavFallback: false, + usePutFallback: shouldSerializeDocument, + forcePut: shouldSerializeDocument + }) +} + +async function mutateBasicProfileEntry (store: LiveStore, subject: NamedNode, basicOps: MutationOps, documentTextCache?: MutationDocumentTextCache) { + const doc = subject.doc() + const deletions: any[] = [] + const insertions: any[] = [] + + const replaceLiteralField = (predicate: NamedNode, value?: string) => { + const normalized = (value || '').trim() + const nextObject = normalized ? literal(normalized) : null + replacePredicateStatements(store, subject, predicate, doc, deletions, insertions, nextObject) + } + + const replacePhotoField = (value?: string) => { + const normalized = (value || '').trim() + const nextObject = !normalized + ? null + : (normalized.startsWith('http://') || normalized.startsWith('https://')) + ? sym(normalized) + : literal(normalized) + replacePredicateStatements(store, subject, ns.vcard('hasPhoto'), doc, deletions, insertions, nextObject) + } + + const replacePronounFields = (value?: string) => { + const { subjectPronoun, objectPronoun } = splitPronouns(value) + + replacePredicateStatements( + store, + subject, + ns.solid('preferredSubjectPronoun'), + doc, + deletions, + insertions, + subjectPronoun ? literal(subjectPronoun) : null + ) + replacePredicateStatements( + store, + subject, + ns.solid('preferredObjectPronoun'), + doc, + deletions, + insertions, + objectPronoun ? literal(objectPronoun) : null + ) + replacePredicateStatements( + store, + subject, + ns.solid('preferredRelativePronoun'), + doc, + deletions, + insertions, + null + ) + } + + const applyBasics = (basic: ProfileBasicRow, clearAll = false) => { + const data = clearAll + ? { + name: '', + nickname: '', + pronouns: '', + dateOfBirth: '', + imageSrc: '' + } + : basic + + replaceLiteralField(ns.vcard('fn'), data.name) + // Keep foaf:nick in sync where available in existing data. + replaceLiteralField(ns.foaf('nick'), data.nickname) + replaceLiteralField(ns.vcard('nickname'), data.nickname) + replaceLiteralField(ns.vcard('bday'), data.dateOfBirth) + replacePronounFields(data.pronouns) + replacePhotoField(data.imageSrc) + } + + const removeBasic = basicOps.remove[0] + const updateBasic = basicOps.update[0] + const createBasic = basicOps.create[0] + + const selectedBasic = updateBasic || createBasic || removeBasic + if (selectedBasic) { + applyBasics(selectedBasic, Boolean(removeBasic && !updateBasic && !createBasic)) + } + + const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache }) + + await runUpdateTransport(store, doc, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: 'Failed to save heading updates', + documentTextCache, + useDavFallback: false, + usePutFallback: shouldSerializeDocument, + forcePut: shouldSerializeDocument + }) +} + +export async function processHeadingMutations (store: LiveStore, subject: NamedNode, mutationPlan: HeadingMutationPlan) { + try { + const documentTextCache: MutationDocumentTextCache = new Map() + await mutateBasicProfileEntry(store, subject, mutationPlan.basicOps, documentTextCache) + await mutatePhoneEntry(store, subject, mutationPlan.phoneOps, documentTextCache) + await mutateEmailEntry(store, subject, mutationPlan.emailOps, documentTextCache) + await mutateAddressEntry(store, subject, mutationPlan.addressOps, documentTextCache) + } catch (error) { + const rootError = error instanceof Error ? error : new Error(String(error)) + console.error(headingMutationSaveFailedDebugText, rootError) + const saveError = new Error(saveHeadingUpdatesFailedMessageText) + Object.defineProperty(saveError, 'cause', { value: rootError }) + throw saveError + } +} diff --git a/src/components/profile-heading/editor/sections/heading/types.ts b/src/components/profile-heading/editor/sections/heading/types.ts new file mode 100644 index 00000000..5f9bd6b7 --- /dev/null +++ b/src/components/profile-heading/editor/sections/heading/types.ts @@ -0,0 +1,43 @@ +import { Node } from 'rdflib' +import { RowStatus } from '../shared/types' +import { MutationOps } from '../shared/types' +import type { AddressDetails, ContactAddressRow, ContactPointRow, PointDetails } from 'profile-pane' + +export interface ProfilePresentation { + name: string; + nickname?: string; + imageSrc?: string; + location?: string; + pronouns?: string; + dateOfBirth?: string; + jobTitle?: string; + primaryPhone?: PointDetails; + primaryEmail?: PointDetails; + primaryAddress?: AddressDetails; +} + +export interface ProfileDetails extends ProfilePresentation { + entryNode: Node +} + +export interface ProfileRow extends ProfilePresentation { + entryNode: string + status: RowStatus +} + +export type ProfileBasicFields = Omit< + ProfilePresentation, + 'jobTitle' | 'primaryPhone' | 'primaryEmail' | 'primaryAddress' +> + +export type ProfileBasicRow = ProfileBasicFields & { + entryNode: string + status: RowStatus +} + +export type HeadingMutationPlan = { + basicOps: MutationOps + phoneOps: MutationOps + emailOps: MutationOps + addressOps: MutationOps +} diff --git a/src/components/profile-heading/editor/sections/shared/contactTypeUtils.ts b/src/components/profile-heading/editor/sections/shared/contactTypeUtils.ts new file mode 100644 index 00000000..73270400 --- /dev/null +++ b/src/components/profile-heading/editor/sections/shared/contactTypeUtils.ts @@ -0,0 +1,47 @@ +import { toTypeLabel } from '../../textUtils' + +export function normalizePhoneTypeForEdit (value: unknown): string { + const label = toTypeLabel(value).trim().toLowerCase() + if (!label) return '' + + if (label === 'cell' || label === 'mobile') return 'Mobile' + if (label === 'home') return 'Home' + if (label === 'work' || label === 'office') return 'Work' + return '' +} + +export function normalizePhoneTypeForContactInfoEdit (value: unknown): string { + const label = toTypeLabel(value).trim().toLowerCase() + if (!label) return '' + + if (label === 'cell' || label === 'mobile') return 'Cell' + if (label === 'home') return 'Home' + if (label === 'work' || label === 'office') return 'Work' + return '' +} + +export function normalizeEmailTypeForEdit (value: unknown): string { + const label = toTypeLabel(value).trim().toLowerCase() + if (!label) return '' + + if (label === 'personal' || label === 'home') return 'Personal' + if (label === 'office' || label === 'work') return 'Office' + return '' +} + +export function normalizeEmailTypeForContactInfoEdit (value: unknown): string { + const label = toTypeLabel(value).trim().toLowerCase() + if (!label) return '' + + if (label === 'personal' || label === 'home') return 'Home' + if (label === 'office' || label === 'work') return 'Office' + return '' +} + +export function toSavedHeadingEmailType (type: string | undefined): string { + return type === 'Personal' ? 'Home' : (type || '') +} + +export function toSavedHeadingPhoneType (type: string | undefined): string { + return type === 'Mobile' ? 'Cell' : (type || '') +} diff --git a/src/components/profile-heading/editor/sections/shared/idNodeFactory.ts b/src/components/profile-heading/editor/sections/shared/idNodeFactory.ts new file mode 100644 index 00000000..834d58e0 --- /dev/null +++ b/src/components/profile-heading/editor/sections/shared/idNodeFactory.ts @@ -0,0 +1,10 @@ +import { NamedNode, sym } from 'rdflib' + +let lastIssuedTimestamp = 0 + +export function createIdNode (doc: NamedNode): NamedNode { + const now = Date.now() + const timestampId = now > lastIssuedTimestamp ? now : lastIssuedTimestamp + 1 + lastIssuedTimestamp = timestampId + return sym(`${doc.uri}#id${timestampId}`) as NamedNode +} diff --git a/src/components/profile-heading/editor/sections/shared/phoneCountries.ts b/src/components/profile-heading/editor/sections/shared/phoneCountries.ts new file mode 100644 index 00000000..58bb6073 --- /dev/null +++ b/src/components/profile-heading/editor/sections/shared/phoneCountries.ts @@ -0,0 +1,354 @@ +// Not in use now. + +export type CountryDialOption = { + iso2: string + dialCode: string + name: string +} + +export const DEFAULT_DIAL_CODE = '' + +export const COUNTRY_DIAL_OPTIONS: CountryDialOption[] = [ + { iso2: 'EG', dialCode: '+20', name: 'Egypt' }, + { iso2: 'ZA', dialCode: '+27', name: 'South Africa' }, + { iso2: 'GR', dialCode: '+30', name: 'Greece' }, + { iso2: 'NL', dialCode: '+31', name: 'Netherlands' }, + { iso2: 'BE', dialCode: '+32', name: 'Belgium' }, + { iso2: 'FR', dialCode: '+33', name: 'France' }, + { iso2: 'ES', dialCode: '+34', name: 'Spain' }, + { iso2: 'HU', dialCode: '+36', name: 'Hungary' }, + { iso2: 'IT', dialCode: '+39', name: 'Italy' }, + { iso2: 'RO', dialCode: '+40', name: 'Romania' }, + { iso2: 'CH', dialCode: '+41', name: 'Switzerland' }, + { iso2: 'AT', dialCode: '+43', name: 'Austria' }, + { iso2: 'GG', dialCode: '+44', name: 'Guernsey' }, + { iso2: 'IM', dialCode: '+44', name: 'Isle of Man' }, + { iso2: 'JE', dialCode: '+44', name: 'Jersey' }, + { iso2: 'GB', dialCode: '+44', name: 'United Kingdom' }, + { iso2: 'DK', dialCode: '+45', name: 'Denmark' }, + { iso2: 'SE', dialCode: '+46', name: 'Sweden' }, + { iso2: 'BV', dialCode: '+47', name: 'Bouvet Island' }, + { iso2: 'NO', dialCode: '+47', name: 'Norway' }, + { iso2: 'PL', dialCode: '+48', name: 'Poland' }, + { iso2: 'DE', dialCode: '+49', name: 'Germany' }, + { iso2: 'PE', dialCode: '+51', name: 'Peru' }, + { iso2: 'MX', dialCode: '+52', name: 'Mexico' }, + { iso2: 'CU', dialCode: '+53', name: 'Cuba' }, + { iso2: 'AR', dialCode: '+54', name: 'Argentina' }, + { iso2: 'BR', dialCode: '+55', name: 'Brazil' }, + { iso2: 'CL', dialCode: '+56', name: 'Chile' }, + { iso2: 'CO', dialCode: '+57', name: 'Colombia' }, + { iso2: 'VE', dialCode: '+58', name: 'Venezuela' }, + { iso2: 'MY', dialCode: '+60', name: 'Malaysia' }, + { iso2: 'AU', dialCode: '+61', name: 'Australia' }, + { iso2: 'CX', dialCode: '+61', name: 'Christmas Island' }, + { iso2: 'CC', dialCode: '+61', name: 'Cocos (Keeling) Islands' }, + { iso2: 'ID', dialCode: '+62', name: 'Indonesia' }, + { iso2: 'PH', dialCode: '+63', name: 'Philippines' }, + { iso2: 'NZ', dialCode: '+64', name: 'New Zealand' }, + { iso2: 'PN', dialCode: '+64', name: 'Pitcairn Islands' }, + { iso2: 'SG', dialCode: '+65', name: 'Singapore' }, + { iso2: 'TH', dialCode: '+66', name: 'Thailand' }, + { iso2: 'RU', dialCode: '+73', name: 'Russia' }, + { iso2: 'KZ', dialCode: '+76', name: 'Kazakhstan' }, + { iso2: 'JP', dialCode: '+81', name: 'Japan' }, + { iso2: 'KR', dialCode: '+82', name: 'South Korea' }, + { iso2: 'VN', dialCode: '+84', name: 'Vietnam' }, + { iso2: 'CN', dialCode: '+86', name: 'China' }, + { iso2: 'TR', dialCode: '+90', name: 'Turkiye' }, + { iso2: 'IN', dialCode: '+91', name: 'India' }, + { iso2: 'PK', dialCode: '+92', name: 'Pakistan' }, + { iso2: 'AF', dialCode: '+93', name: 'Afghanistan' }, + { iso2: 'LK', dialCode: '+94', name: 'Sri Lanka' }, + { iso2: 'MM', dialCode: '+95', name: 'Myanmar' }, + { iso2: 'IR', dialCode: '+98', name: 'Iran' }, + { iso2: 'SS', dialCode: '+211', name: 'South Sudan' }, + { iso2: 'MA', dialCode: '+212', name: 'Morocco' }, + { iso2: 'DZ', dialCode: '+213', name: 'Algeria' }, + { iso2: 'TN', dialCode: '+216', name: 'Tunisia' }, + { iso2: 'LY', dialCode: '+218', name: 'Libya' }, + { iso2: 'GM', dialCode: '+220', name: 'Gambia' }, + { iso2: 'SN', dialCode: '+221', name: 'Senegal' }, + { iso2: 'MR', dialCode: '+222', name: 'Mauritania' }, + { iso2: 'ML', dialCode: '+223', name: 'Mali' }, + { iso2: 'GN', dialCode: '+224', name: 'Guinea' }, + { iso2: 'CI', dialCode: '+225', name: 'Ivory Coast' }, + { iso2: 'BF', dialCode: '+226', name: 'Burkina Faso' }, + { iso2: 'NE', dialCode: '+227', name: 'Niger' }, + { iso2: 'TG', dialCode: '+228', name: 'Togo' }, + { iso2: 'BJ', dialCode: '+229', name: 'Benin' }, + { iso2: 'MU', dialCode: '+230', name: 'Mauritius' }, + { iso2: 'LR', dialCode: '+231', name: 'Liberia' }, + { iso2: 'SL', dialCode: '+232', name: 'Sierra Leone' }, + { iso2: 'GH', dialCode: '+233', name: 'Ghana' }, + { iso2: 'NG', dialCode: '+234', name: 'Nigeria' }, + { iso2: 'TD', dialCode: '+235', name: 'Chad' }, + { iso2: 'CF', dialCode: '+236', name: 'Central African Republic' }, + { iso2: 'CM', dialCode: '+237', name: 'Cameroon' }, + { iso2: 'CV', dialCode: '+238', name: 'Cape Verde' }, + { iso2: 'ST', dialCode: '+239', name: 'Sao Tome and Principe' }, + { iso2: 'GQ', dialCode: '+240', name: 'Equatorial Guinea' }, + { iso2: 'GA', dialCode: '+241', name: 'Gabon' }, + { iso2: 'CG', dialCode: '+242', name: 'Congo' }, + { iso2: 'CD', dialCode: '+243', name: 'DR Congo' }, + { iso2: 'AO', dialCode: '+244', name: 'Angola' }, + { iso2: 'GW', dialCode: '+245', name: 'Guinea-Bissau' }, + { iso2: 'IO', dialCode: '+246', name: 'British Indian Ocean Territory' }, + { iso2: 'SC', dialCode: '+248', name: 'Seychelles' }, + { iso2: 'SD', dialCode: '+249', name: 'Sudan' }, + { iso2: 'RW', dialCode: '+250', name: 'Rwanda' }, + { iso2: 'ET', dialCode: '+251', name: 'Ethiopia' }, + { iso2: 'SO', dialCode: '+252', name: 'Somalia' }, + { iso2: 'DJ', dialCode: '+253', name: 'Djibouti' }, + { iso2: 'KE', dialCode: '+254', name: 'Kenya' }, + { iso2: 'TZ', dialCode: '+255', name: 'Tanzania' }, + { iso2: 'UG', dialCode: '+256', name: 'Uganda' }, + { iso2: 'BI', dialCode: '+257', name: 'Burundi' }, + { iso2: 'MZ', dialCode: '+258', name: 'Mozambique' }, + { iso2: 'ZM', dialCode: '+260', name: 'Zambia' }, + { iso2: 'MG', dialCode: '+261', name: 'Madagascar' }, + { iso2: 'TF', dialCode: '+262', name: 'French Southern and Antarctic Lands' }, + { iso2: 'YT', dialCode: '+262', name: 'Mayotte' }, + { iso2: 'RE', dialCode: '+262', name: 'Reunion' }, + { iso2: 'ZW', dialCode: '+263', name: 'Zimbabwe' }, + { iso2: 'NA', dialCode: '+264', name: 'Namibia' }, + { iso2: 'MW', dialCode: '+265', name: 'Malawi' }, + { iso2: 'LS', dialCode: '+266', name: 'Lesotho' }, + { iso2: 'BW', dialCode: '+267', name: 'Botswana' }, + { iso2: 'SZ', dialCode: '+268', name: 'Eswatini' }, + { iso2: 'UM', dialCode: '+268', name: 'United States Minor Outlying Islands' }, + { iso2: 'KM', dialCode: '+269', name: 'Comoros' }, + { iso2: 'SH', dialCode: '+290', name: 'Saint Helena, Ascension and Tristan da Cunha' }, + { iso2: 'ER', dialCode: '+291', name: 'Eritrea' }, + { iso2: 'AW', dialCode: '+297', name: 'Aruba' }, + { iso2: 'FO', dialCode: '+298', name: 'Faroe Islands' }, + { iso2: 'GL', dialCode: '+299', name: 'Greenland' }, + { iso2: 'GI', dialCode: '+350', name: 'Gibraltar' }, + { iso2: 'PT', dialCode: '+351', name: 'Portugal' }, + { iso2: 'LU', dialCode: '+352', name: 'Luxembourg' }, + { iso2: 'IE', dialCode: '+353', name: 'Ireland' }, + { iso2: 'IS', dialCode: '+354', name: 'Iceland' }, + { iso2: 'AL', dialCode: '+355', name: 'Albania' }, + { iso2: 'MT', dialCode: '+356', name: 'Malta' }, + { iso2: 'CY', dialCode: '+357', name: 'Cyprus' }, + { iso2: 'FI', dialCode: '+358', name: 'Finland' }, + { iso2: 'BG', dialCode: '+359', name: 'Bulgaria' }, + { iso2: 'LT', dialCode: '+370', name: 'Lithuania' }, + { iso2: 'LV', dialCode: '+371', name: 'Latvia' }, + { iso2: 'EE', dialCode: '+372', name: 'Estonia' }, + { iso2: 'MD', dialCode: '+373', name: 'Moldova' }, + { iso2: 'AM', dialCode: '+374', name: 'Armenia' }, + { iso2: 'BY', dialCode: '+375', name: 'Belarus' }, + { iso2: 'AD', dialCode: '+376', name: 'Andorra' }, + { iso2: 'MC', dialCode: '+377', name: 'Monaco' }, + { iso2: 'SM', dialCode: '+378', name: 'San Marino' }, + { iso2: 'UA', dialCode: '+380', name: 'Ukraine' }, + { iso2: 'RS', dialCode: '+381', name: 'Serbia' }, + { iso2: 'ME', dialCode: '+382', name: 'Montenegro' }, + { iso2: 'XK', dialCode: '+383', name: 'Kosovo' }, + { iso2: 'HR', dialCode: '+385', name: 'Croatia' }, + { iso2: 'SI', dialCode: '+386', name: 'Slovenia' }, + { iso2: 'BA', dialCode: '+387', name: 'Bosnia and Herzegovina' }, + { iso2: 'MK', dialCode: '+389', name: 'North Macedonia' }, + { iso2: 'CZ', dialCode: '+420', name: 'Czechia' }, + { iso2: 'SK', dialCode: '+421', name: 'Slovakia' }, + { iso2: 'LI', dialCode: '+423', name: 'Liechtenstein' }, + { iso2: 'FK', dialCode: '+500', name: 'Falkland Islands' }, + { iso2: 'GS', dialCode: '+500', name: 'South Georgia' }, + { iso2: 'BZ', dialCode: '+501', name: 'Belize' }, + { iso2: 'GT', dialCode: '+502', name: 'Guatemala' }, + { iso2: 'SV', dialCode: '+503', name: 'El Salvador' }, + { iso2: 'HN', dialCode: '+504', name: 'Honduras' }, + { iso2: 'NI', dialCode: '+505', name: 'Nicaragua' }, + { iso2: 'CR', dialCode: '+506', name: 'Costa Rica' }, + { iso2: 'PA', dialCode: '+507', name: 'Panama' }, + { iso2: 'PM', dialCode: '+508', name: 'Saint Pierre and Miquelon' }, + { iso2: 'HT', dialCode: '+509', name: 'Haiti' }, + { iso2: 'GP', dialCode: '+590', name: 'Guadeloupe' }, + { iso2: 'BL', dialCode: '+590', name: 'Saint Barthelemy' }, + { iso2: 'MF', dialCode: '+590', name: 'Saint Martin' }, + { iso2: 'BO', dialCode: '+591', name: 'Bolivia' }, + { iso2: 'GY', dialCode: '+592', name: 'Guyana' }, + { iso2: 'EC', dialCode: '+593', name: 'Ecuador' }, + { iso2: 'GF', dialCode: '+594', name: 'French Guiana' }, + { iso2: 'PY', dialCode: '+595', name: 'Paraguay' }, + { iso2: 'MQ', dialCode: '+596', name: 'Martinique' }, + { iso2: 'SR', dialCode: '+597', name: 'Suriname' }, + { iso2: 'UY', dialCode: '+598', name: 'Uruguay' }, + { iso2: 'BQ', dialCode: '+599', name: 'Caribbean Netherlands' }, + { iso2: 'CW', dialCode: '+599', name: 'Curacao' }, + { iso2: 'TL', dialCode: '+670', name: 'Timor-Leste' }, + { iso2: 'NF', dialCode: '+672', name: 'Norfolk Island' }, + { iso2: 'BN', dialCode: '+673', name: 'Brunei' }, + { iso2: 'NR', dialCode: '+674', name: 'Nauru' }, + { iso2: 'PG', dialCode: '+675', name: 'Papua New Guinea' }, + { iso2: 'TO', dialCode: '+676', name: 'Tonga' }, + { iso2: 'SB', dialCode: '+677', name: 'Solomon Islands' }, + { iso2: 'VU', dialCode: '+678', name: 'Vanuatu' }, + { iso2: 'FJ', dialCode: '+679', name: 'Fiji' }, + { iso2: 'PW', dialCode: '+680', name: 'Palau' }, + { iso2: 'WF', dialCode: '+681', name: 'Wallis and Futuna' }, + { iso2: 'CK', dialCode: '+682', name: 'Cook Islands' }, + { iso2: 'NU', dialCode: '+683', name: 'Niue' }, + { iso2: 'WS', dialCode: '+685', name: 'Samoa' }, + { iso2: 'KI', dialCode: '+686', name: 'Kiribati' }, + { iso2: 'NC', dialCode: '+687', name: 'New Caledonia' }, + { iso2: 'TV', dialCode: '+688', name: 'Tuvalu' }, + { iso2: 'PF', dialCode: '+689', name: 'French Polynesia' }, + { iso2: 'TK', dialCode: '+690', name: 'Tokelau' }, + { iso2: 'FM', dialCode: '+691', name: 'Micronesia' }, + { iso2: 'MH', dialCode: '+692', name: 'Marshall Islands' }, + { iso2: 'KP', dialCode: '+850', name: 'North Korea' }, + { iso2: 'HK', dialCode: '+852', name: 'Hong Kong' }, + { iso2: 'MO', dialCode: '+853', name: 'Macau' }, + { iso2: 'KH', dialCode: '+855', name: 'Cambodia' }, + { iso2: 'LA', dialCode: '+856', name: 'Laos' }, + { iso2: 'BD', dialCode: '+880', name: 'Bangladesh' }, + { iso2: 'TW', dialCode: '+886', name: 'Taiwan' }, + { iso2: 'MV', dialCode: '+960', name: 'Maldives' }, + { iso2: 'LB', dialCode: '+961', name: 'Lebanon' }, + { iso2: 'JO', dialCode: '+962', name: 'Jordan' }, + { iso2: 'SY', dialCode: '+963', name: 'Syria' }, + { iso2: 'IQ', dialCode: '+964', name: 'Iraq' }, + { iso2: 'KW', dialCode: '+965', name: 'Kuwait' }, + { iso2: 'SA', dialCode: '+966', name: 'Saudi Arabia' }, + { iso2: 'YE', dialCode: '+967', name: 'Yemen' }, + { iso2: 'OM', dialCode: '+968', name: 'Oman' }, + { iso2: 'PS', dialCode: '+970', name: 'Palestine' }, + { iso2: 'AE', dialCode: '+971', name: 'United Arab Emirates' }, + { iso2: 'IL', dialCode: '+972', name: 'Israel' }, + { iso2: 'BH', dialCode: '+973', name: 'Bahrain' }, + { iso2: 'QA', dialCode: '+974', name: 'Qatar' }, + { iso2: 'BT', dialCode: '+975', name: 'Bhutan' }, + { iso2: 'MN', dialCode: '+976', name: 'Mongolia' }, + { iso2: 'NP', dialCode: '+977', name: 'Nepal' }, + { iso2: 'TJ', dialCode: '+992', name: 'Tajikistan' }, + { iso2: 'TM', dialCode: '+993', name: 'Turkmenistan' }, + { iso2: 'AZ', dialCode: '+994', name: 'Azerbaijan' }, + { iso2: 'GE', dialCode: '+995', name: 'Georgia' }, + { iso2: 'KG', dialCode: '+996', name: 'Kyrgyzstan' }, + { iso2: 'UZ', dialCode: '+998', name: 'Uzbekistan' }, + { iso2: 'US', dialCode: '+1201', name: 'United States' }, + { iso2: 'CA', dialCode: '+1204', name: 'Canada' }, + { iso2: 'BS', dialCode: '+1242', name: 'Bahamas' }, + { iso2: 'BB', dialCode: '+1246', name: 'Barbados' }, + { iso2: 'AI', dialCode: '+1264', name: 'Anguilla' }, + { iso2: 'AG', dialCode: '+1268', name: 'Antigua and Barbuda' }, + { iso2: 'VG', dialCode: '+1284', name: 'British Virgin Islands' }, + { iso2: 'VI', dialCode: '+1340', name: 'United States Virgin Islands' }, + { iso2: 'KY', dialCode: '+1345', name: 'Cayman Islands' }, + { iso2: 'BM', dialCode: '+1441', name: 'Bermuda' }, + { iso2: 'GD', dialCode: '+1473', name: 'Grenada' }, + { iso2: 'TC', dialCode: '+1649', name: 'Turks and Caicos Islands' }, + { iso2: 'MS', dialCode: '+1664', name: 'Montserrat' }, + { iso2: 'MP', dialCode: '+1670', name: 'Northern Mariana Islands' }, + { iso2: 'GU', dialCode: '+1671', name: 'Guam' }, + { iso2: 'AS', dialCode: '+1684', name: 'American Samoa' }, + { iso2: 'SX', dialCode: '+1721', name: 'Sint Maarten' }, + { iso2: 'LC', dialCode: '+1758', name: 'Saint Lucia' }, + { iso2: 'DM', dialCode: '+1767', name: 'Dominica' }, + { iso2: 'VC', dialCode: '+1784', name: 'Saint Vincent and the Grenadines' }, + { iso2: 'PR', dialCode: '+1787', name: 'Puerto Rico' }, + { iso2: 'DO', dialCode: '+1809', name: 'Dominican Republic' }, + { iso2: 'TT', dialCode: '+1868', name: 'Trinidad and Tobago' }, + { iso2: 'KN', dialCode: '+1869', name: 'Saint Kitts and Nevis' }, + { iso2: 'JM', dialCode: '+1876', name: 'Jamaica' }, + { iso2: 'SJ', dialCode: '+4779', name: 'Svalbard and Jan Mayen' }, + { iso2: 'AX', dialCode: '+35818', name: 'Aland Islands' }, + { iso2: 'EH', dialCode: '+2125288', name: 'Western Sahara' }, + { iso2: 'VA', dialCode: '+3906698', name: 'Vatican City' }, +] + +const NON_USER_FACING_ISO2 = new Set([ + 'AQ', 'AX', 'BL', 'BM', 'BQ', 'BV', 'CC', 'CK', 'CP', 'CW', 'CX', 'EH', 'FK', 'FO', + 'GF', 'GG', 'GI', 'GL', 'GP', 'GS', 'GU', 'HK', 'HM', 'IM', 'IO', 'JE', 'KY', 'MF', + 'MO', 'MP', 'MQ', 'MS', 'NC', 'NF', 'NU', 'PF', 'PM', 'PN', 'PR', 'RE', 'SH', 'SJ', + 'TC', 'TF', 'TK', 'UM', 'VG', 'VI', 'WF', 'YT' +]) + +function normalizeDialCode (rawDialCode: string): string { + if (/^\+1\d{3,}$/.test(rawDialCode)) return '+1' + if (/^\+7\d+$/.test(rawDialCode)) return '+7' + if (/^\+39\d{3,}$/.test(rawDialCode)) return '+39' + if (/^\+358\d+$/.test(rawDialCode)) return '+358' + if (/^\+47\d+$/.test(rawDialCode)) return '+47' + if (/^\+212\d+$/.test(rawDialCode)) return '+212' + return rawDialCode +} + +const COUNTRY_DIAL_OPTIONS_USER = COUNTRY_DIAL_OPTIONS + .filter((option) => !NON_USER_FACING_ISO2.has(option.iso2)) + .map((option) => ({ ...option, dialCode: normalizeDialCode(option.dialCode) })) + .filter((option, index, source) => { + const key = `${option.iso2}|${option.dialCode}` + return source.findIndex((candidate) => `${candidate.iso2}|${candidate.dialCode}` === key) === index + }) + +export const COUNTRY_DIAL_OPTIONS_ASC = [...COUNTRY_DIAL_OPTIONS_USER].sort((a, b) => { + const codeA = Number.parseInt(a.dialCode.replace('+', ''), 10) + const codeB = Number.parseInt(b.dialCode.replace('+', ''), 10) + if (codeA !== codeB) return codeA - codeB + return a.name.localeCompare(b.name) +}) + +const PREFERRED_ISO_BY_DIAL_CODE: Record = { + '+1': 'US', + '+7': 'RU' +} + +export const COUNTRY_PREFIX_OPTIONS = Array.from( + COUNTRY_DIAL_OPTIONS_ASC.reduce((map, option) => { + const current = map.get(option.dialCode) + if (!current) { + map.set(option.dialCode, option) + return map + } + + const preferredIso = PREFERRED_ISO_BY_DIAL_CODE[option.dialCode] + if (preferredIso && option.iso2 === preferredIso) { + map.set(option.dialCode, option) + } + + return map + }, new Map()).values() +) + +const DIAL_CODES_DESC = Array.from(new Set(COUNTRY_PREFIX_OPTIONS.map((option) => option.dialCode))).sort( + (a, b) => b.length - a.length +) + +export function countryCodeToFlag (iso2: string): string { + const normalized = iso2.toUpperCase() + if (!/^[A-Z]{2}$/.test(normalized)) return '' + const codePoints = [...normalized].map((char) => 127397 + char.charCodeAt(0)) + return String.fromCodePoint(...codePoints) +} + +export function splitPhoneValue (raw: string) { + const value = raw.trim() + if (!value) return { dialCode: DEFAULT_DIAL_CODE, localNumber: '' } + if (!value.startsWith('+')) return { dialCode: DEFAULT_DIAL_CODE, localNumber: value } + + const matchedDialCode = DIAL_CODES_DESC.find((dialCode) => value.startsWith(dialCode)) + if (!matchedDialCode) { + return { dialCode: DEFAULT_DIAL_CODE, localNumber: value } + } + + const normalizedDialCode = normalizeDialCode(matchedDialCode) + const localNumber = value.slice(matchedDialCode.length) + const fallbackLocalNumber = value.slice(normalizedDialCode.length) + + return { + dialCode: COUNTRY_PREFIX_OPTIONS.some((option) => option.dialCode === normalizedDialCode) + ? normalizedDialCode + : DEFAULT_DIAL_CODE, + localNumber: matchedDialCode.length > normalizedDialCode.length ? fallbackLocalNumber : localNumber + } +} + +export function combinePhoneValue (dialCode: string, localNumber: string): string { + const trimmed = localNumber.trim() + if (!trimmed) return '' + if (trimmed.startsWith('+')) return trimmed + return dialCode ? `${dialCode}${trimmed}` : trimmed +} diff --git a/src/components/profile-heading/editor/sections/shared/rdfMutationHelpers.ts b/src/components/profile-heading/editor/sections/shared/rdfMutationHelpers.ts new file mode 100644 index 00000000..ffdf5ca9 --- /dev/null +++ b/src/components/profile-heading/editor/sections/shared/rdfMutationHelpers.ts @@ -0,0 +1,503 @@ +import { graph, LiveStore, NamedNode, Node, st } from 'rdflib' +import { fallbackSaveUpdatesErrorMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts' +import { PrefixCapable, RdfFetcher, RdfStatement, RdfUpdater } from './types' +/* Some of the code below was generated by AI Model: GPT-5.4. + Prompt: can you add types to rdfMutationHelpers.ts and consolidate mutation logic + where appropriate. */ + +const STANDARD_MUTATION_PREFIXES: Record = { + rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + vcard: 'http://www.w3.org/2006/vcard/ns#', + foaf: 'http://xmlns.com/foaf/0.1/', + solid: 'http://www.w3.org/ns/solid/terms#', + schema: 'http://schema.org/', + org: 'http://www.w3.org/ns/org#', + owl: 'http://www.w3.org/2002/07/owl#', + dc: 'http://purl.org/dc/elements/1.1/' +} + +const STANDARD_MUTATION_NAMESPACE_URIS = Object.values(STANDARD_MUTATION_PREFIXES) + +export type MutationDocumentTextCache = Map + +type ForceDocumentPutCheckOptions = { + documentText?: string + documentTextCache?: MutationDocumentTextCache +} + +function normalizeNodeId (value: string): string { + return value.startsWith('_:') ? value.slice(2) : value +} + +function isCompleteStatement (statement: RdfStatement | null | undefined): statement is RdfStatement { + return Boolean(statement?.subject && statement?.predicate && statement?.object) +} + +function statementTermKey (term: { toNT?: () => string; value?: string } | null | undefined): string { + if (!term) return '' + if (typeof term.toNT === 'function') return term.toNT() + return term.value || '' +} + +export function registerStorePrefix (target: PrefixCapable | undefined, prefix: string, uri: string): void { + if (!target) return + if (typeof target.setPrefixForURI === 'function') { + target.setPrefixForURI(prefix, uri) + return + } + if (!target.namespaces) { + target.namespaces = {} + } + target.namespaces[prefix] = uri +} + +export function ensureStandardMutationPrefixes (store: LiveStore | undefined): void { + if (!store) return + + const updater = getStoreUpdater(store) + const targets: Array = [ + store as PrefixCapable, + updater, + updater?.store as PrefixCapable | undefined + ] + + Object.entries(STANDARD_MUTATION_PREFIXES).forEach(([prefix, uri]) => { + targets.forEach((target) => { + registerStorePrefix(target, prefix, uri) + }) + }) +} + +export function statementKey (statement: RdfStatement): string { + return `${statementTermKey(statement.subject)} ${statementTermKey(statement.predicate)} ${statementTermKey(statement.object)} ${statementTermKey(statement.why)}` +} + +export function uniqueStatements (statements: RdfStatement[]): RdfStatement[] { + return Array.from(new Map((statements || []).map((statement) => [statementKey(statement), statement])).values()) +} + +export function applyStatementsToStore (store: LiveStore, deletions: RdfStatement[], insertions: RdfStatement[]): void { + store.remove(deletions) + insertions.forEach((statement) => { + store.add(statement.subject, statement.predicate, statement.object, statement.why) + }) +} + +export function sanitizePatchStatements ( + store: LiveStore, + deletions: RdfStatement[], + insertions: RdfStatement[], + requireStoredDelete = false +): { + safeDeletions: RdfStatement[] + safeInsertions: RdfStatement[] + } { + const safeDeletions = uniqueStatements( + (deletions || []).filter((statement) => { + if (!isCompleteStatement(statement)) return false + if (store.holds(statement.subject, statement.predicate, statement.object, statement.why)) return true + return requireStoredDelete + ? false + : store.statementsMatching(statement.subject, statement.predicate, statement.object, statement.why).length > 0 + }) + ) + + const safeInsertions = uniqueStatements((insertions || []).filter(isCompleteStatement)) + + return { safeDeletions, safeInsertions } +} + +function getStoreUpdater (store: LiveStore): RdfUpdater | undefined { + return store.updater as RdfUpdater | undefined +} + +function getStoreFetcher (store: LiveStore): RdfFetcher | undefined { + return store.fetcher as RdfFetcher | undefined +} + +async function bestEffortLoadDocIfStoreEmpty (store: LiveStore, doc: NamedNode | null): Promise { + if (!doc) return + + const fetcher = getStoreFetcher(store) + if (typeof fetcher?.load !== 'function') return + if (store.statementsMatching(undefined, undefined, undefined, doc).length > 0) return + + try { + await fetcher.load(doc) + } catch { + // Continue with current in-memory statements if the pre-load fails. + } +} + +function escapeRegex (text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function statementNamespaceUris (statement: RdfStatement, candidateNamespaceUris: string[]): string[] { + const uris = new Set() + const terms = [statement.subject, statement.predicate, statement.object] + + terms.forEach((term: any) => { + if (!term) return + + if (typeof term.value === 'string') { + candidateNamespaceUris.forEach((namespaceUri) => { + if (term.value.startsWith(namespaceUri)) { + uris.add(namespaceUri) + } + }) + } + + if (term.termType === 'Literal' && typeof term.datatype?.value === 'string') { + candidateNamespaceUris.forEach((namespaceUri) => { + if (term.datatype.value.startsWith(namespaceUri)) { + uris.add(namespaceUri) + } + }) + } + }) + + return [...uris] +} + +function documentDeclaresNamespacePrefix (documentText: string, namespaceUri: string): boolean { + const escapedNamespaceUri = escapeRegex(namespaceUri) + const prefixPattern = new RegExp(`(^|\\n)\\s*(?:@prefix|PREFIX)\\s+[A-Za-z][\\w-]*:\\s*<${escapedNamespaceUri}>`, 'i') + return prefixPattern.test(documentText) +} + +export async function shouldForceDocumentPutForStatements ( + store: LiveStore, + doc: NamedNode, + statements: RdfStatement[], + candidateNamespaceUris = STANDARD_MUTATION_NAMESPACE_URIS, + options: ForceDocumentPutCheckOptions = {} +): Promise { + const updater = getStoreUpdater(store) + const fetcher = getStoreFetcher(store) + + if (typeof updater?.serialize !== 'function' || typeof fetcher?.webOperation !== 'function') { + return false + } + + const neededNamespaceUris = Array.from(new Set( + (statements || []).flatMap((statement) => statementNamespaceUris(statement, candidateNamespaceUris)) + )) + + if (neededNamespaceUris.length === 0) { + return false + } + + const cachedDocumentText = options.documentTextCache?.get(doc.value) + const existingDocumentText = typeof options.documentText === 'string' + ? options.documentText + : cachedDocumentText + + if (typeof existingDocumentText === 'string') { + return neededNamespaceUris.some((namespaceUri) => { + return !documentDeclaresNamespacePrefix(existingDocumentText, namespaceUri) + }) + } + + try { + const response = await fetcher.webOperation('GET', doc.value, { + noMeta: true, + headers: { accept: 'text/turtle' } + }) + + if (!response?.ok || typeof response.responseText !== 'string') { + return false + } + + options.documentTextCache?.set(doc.value, response.responseText) + + return neededNamespaceUris.some((namespaceUri) => { + return !documentDeclaresNamespacePrefix(response.responseText || '', namespaceUri) + }) + } catch { + return false + } +} + +export function isPatchFailureMessage (message: string): boolean { + const text = (message || '').toLowerCase() + return ( + text.includes('fetch error for patch') || + text.includes('failed to fetch') || + text.includes(' on patch ') || + text.includes('web error: 400') || + text.includes('web error: 405') || + text.includes('web error: 500') || + text.includes('web error: 501') + ) +} + +export function isMissingGetRecordErrorMessage (message: string): boolean { + return (message || '').toLowerCase().includes('no record of our http get request for document') +} + +function runUpdaterUpdate ( + updater: RdfUpdater, + deletions: RdfStatement[], + insertions: RdfStatement[], + failureMessage: string +) { + if (typeof updater.update !== 'function') { + throw new Error(updaterUnsupportedStoreErrorMessageText) + } + const update = updater.update.bind(updater) + + return new Promise((resolve, reject) => { + try { + update(deletions, insertions, (_uri: string, ok: boolean, message?: string) => { + if (ok === true) { + resolve() + return + } + reject(new Error(message || failureMessage)) + }) + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))) + } + }) +} + +export async function runDirectUpdaterUpdate ( + store: LiveStore, + deletions: RdfStatement[], + insertions: RdfStatement[], + failureMessage = fallbackSaveUpdatesErrorMessageText +) { + ensureStandardMutationPrefixes(store) + + const updater = getStoreUpdater(store) + if (!updater) { + throw new Error(updaterUnsupportedStoreErrorMessageText) + } + + await runUpdaterUpdate(updater, deletions, insertions, failureMessage) +} + +export async function putResourceWithStatements ( + store: LiveStore, + doc: NamedNode, + deletions: RdfStatement[], + insertions: RdfStatement[], + unsupportedMessage: string, + documentTextCache?: MutationDocumentTextCache +) { + ensureStandardMutationPrefixes(store) + + const updater = getStoreUpdater(store) + const fetcher = getStoreFetcher(store) + + if (!updater || typeof updater.serialize !== 'function' || !fetcher || typeof fetcher.webOperation !== 'function') { + throw new Error(unsupportedMessage) + } + + await bestEffortLoadDocIfStoreEmpty(store, doc) + + const currentStatements = store.statementsMatching(undefined, undefined, undefined, doc).slice() + + const deletionKeys = new Set((deletions || []).map((statement) => statementKey(statement))) + const nextStatements = currentStatements.filter((statement) => !deletionKeys.has(statementKey(statement))).concat(insertions || []) + const serializationStore = graph() as LiveStore + nextStatements.forEach((statement) => { + serializationStore.add(statement.subject, statement.predicate, statement.object, statement.why) + }) + + const contentType = 'text/turtle' + const body = updater.serialize(doc.value, serializationStore.statementsMatching(undefined, undefined, undefined, doc), contentType) + const response = await fetcher.webOperation('PUT', doc.value, { + noMeta: true, + contentType, + body + }) + + if (!response || response.ok !== true) { + const status = response?.status || 'unknown' + throw new Error(`Web error: ${status} on PUT of <${doc.value}>`) + } + + documentTextCache?.set(doc.value, body) + + applyStatementsToStore(store, deletions, insertions) +} + +type UpdateTransportOptions = { + unsupportedMessage: string + failureMessage?: string + requireStoredDelete?: boolean + forcePut?: boolean + documentTextCache?: MutationDocumentTextCache + useDavFallback?: boolean + usePutFallback?: boolean + patchFailureMatcher?: (message: string) => boolean + missingGetRecordMatcher?: (message: string) => boolean +} + +export async function runUpdateTransport ( + store: LiveStore, + doc: NamedNode | null, + deletions: RdfStatement[], + insertions: RdfStatement[], + options: UpdateTransportOptions +) { + ensureStandardMutationPrefixes(store) + + const updater = getStoreUpdater(store) + if (!updater) { + throw new Error(options.unsupportedMessage) + } + + if (options.forcePut) { + await bestEffortLoadDocIfStoreEmpty(store, doc) + } + + const { + safeDeletions, + safeInsertions + } = sanitizePatchStatements(store, deletions, insertions, options.requireStoredDelete === true) + + if (safeDeletions.length === 0 && safeInsertions.length === 0) { + return + } + + if (options.forcePut) { + if (!doc) { + throw new Error(options.unsupportedMessage) + } + await putResourceWithStatements(store, doc, safeDeletions, safeInsertions, options.unsupportedMessage, options.documentTextCache) + return + } + + const failureMessage = options.failureMessage || fallbackSaveUpdatesErrorMessageText + + const isPatchFailure = options.patchFailureMatcher || isPatchFailureMessage + const isMissingGetRecord = options.missingGetRecordMatcher || isMissingGetRecordErrorMessage + + try { + await runUpdaterUpdate(updater, safeDeletions, safeInsertions, failureMessage) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!isPatchFailure(message)) { + throw error + } + + const davDoc = doc + if (options.useDavFallback && typeof updater.updateDav === 'function' && davDoc) { + if (getStoreFetcher(store)?.load) { + try { + await getStoreFetcher(store)?.load?.(davDoc) + } catch { + // Continue to DAV fallback and then PUT fallback if needed. + } + } + + try { + await new Promise((resolve, reject) => { + updater.updateDav?.(davDoc, safeDeletions, safeInsertions, (_uri: string, ok: boolean, body?: string) => { + if (ok === true) { + resolve() + return + } + reject(new Error(body || message || failureMessage)) + }) + }) + return + } catch (davError) { + const davMessage = davError instanceof Error ? davError.message : String(davError) + if (!options.usePutFallback || !isMissingGetRecord(davMessage)) { + throw davError + } + } + } + + if (options.usePutFallback) { + if (!doc) { + throw new Error(options.unsupportedMessage) + } + await putResourceWithStatements(store, doc, safeDeletions, safeInsertions, options.unsupportedMessage, options.documentTextCache) + return + } + + throw error + } +} + +export function applyUpdaterPatch (store: LiveStore, deletions: RdfStatement[], insertions: RdfStatement[]) { + if (!store.updater) { + throw new Error(updaterUnsupportedStoreErrorMessageText) + } + + return runUpdateTransport(store, null, deletions, insertions, { + unsupportedMessage: updaterUnsupportedStoreErrorMessageText, + failureMessage: fallbackSaveUpdatesErrorMessageText, + requireStoredDelete: true, + useDavFallback: false, + usePutFallback: false + }) +} + +export function collectNodeStatements (store: LiveStore, node: Node, doc: NamedNode): RdfStatement[] { + return store.statementsMatching(node as any, null, null, doc) +} + +export function collectLinkStatements ( + store: LiveStore, + subject: NamedNode, + predicate: NamedNode, + node: Node, + doc: NamedNode +): RdfStatement[] { + return store.statementsMatching(subject, predicate, node, doc) +} + +/* The following helper was generated by AI Model: GPT-5.3-Codex. + Prompt: 'do you see anything I can do differently in the mutation code so that it's not so verbose and possible reusable accross sections' */ +export function replacePredicateStatements ( + store: LiveStore, + subject: NamedNode, + predicate: NamedNode, + doc: NamedNode, + deletions: RdfStatement[], + insertions: RdfStatement[], + nextObject?: Node | null +) { + deletions.push(...store.statementsMatching(subject, predicate, null, doc)) + if (nextObject) { + insertions.push(st(subject, predicate, nextObject, doc)) + } +} + +/* The following helper was generated by AI Model: GPT-5.3-Codex. + Prompt: 'do you see anything I can do differently in the mutation code so that it's not so verbose and possible reusable accross sections' */ +export function collectLinkedNodeStatements ( + store: LiveStore, + sourceNode: Node, + predicate: NamedNode, + doc: NamedNode +): { + linkedNodes: Node[] + linkedStatements: RdfStatement[] + linkStatements: RdfStatement[] + } { + const linkedNodes = store.each(sourceNode as any, predicate, null, doc) as Node[] + const linkedStatements = linkedNodes.flatMap((linkedNode) => collectNodeStatements(store, linkedNode, doc)) + const linkStatements = store.statementsMatching(sourceNode as any, predicate, null, doc) + + return { + linkedNodes, + linkedStatements, + linkStatements + } +} + +export function findExistingNode (nodes: Node[], entryNode: string) { + const normalizedEntryNode = normalizeNodeId(entryNode) + return nodes.find((node) => { + const nodeValue = normalizeNodeId(node.value) + if (nodeValue === normalizedEntryNode) return true + return node.value === entryNode + }) +} diff --git a/src/components/profile-heading/editor/sections/shared/rowState.ts b/src/components/profile-heading/editor/sections/shared/rowState.ts new file mode 100644 index 00000000..003c7b38 --- /dev/null +++ b/src/components/profile-heading/editor/sections/shared/rowState.ts @@ -0,0 +1,72 @@ +import { RowStatus } from './types' + +export type EditableRow = { + entryNode: string + status: RowStatus +} + +export type TypedEditableRow = EditableRow & { + type: string +} + +type StringKeyOf = { + [K in keyof T]-?: T[K] extends string ? K : never +}[keyof T] + +export function deleteRow (rows: T[], index: number) { + const row = rows[index] + if (!row) return + + if (row.entryNode) { + row.status = 'deleted' + return + } + + rows.splice(index, 1) +} + +export function applyRowSelectChange> ( + row: T, + field: K, + nextValue: Extract +) { + row[field] = nextValue.trim() as T[K] + + if (row.entryNode) { + row.status = row.status === 'existing' || row.status === 'deleted' ? 'modified' : row.status + return + } + + row.status = 'new' +} + +export function summarizeRowOps (rows: T[], rowHasContent: (row: T) => boolean) { + const create = rows.filter((row) => row.status === 'new' && rowHasContent(row)) + const update = rows.filter((row) => row.status === 'modified' && rowHasContent(row)) + const remove = rows.filter((row) => row.status === 'deleted') + return { create, update, remove } +} + +/* The following function was generated by AI Model: GPT-5.3-Codex */ +/* it was originally only for addresses but I made it more generic so it can be used for any type of rows */ +/* Prompt: can you have applyAddressFieldChange update the row status based on whether any fields have content */ +export function applyRowFieldChange ( + row: T, + field: K, + nextValue: T[K], + hasContent: (row: T) => boolean +) { + row[field] = nextValue + + if (!hasContent(row)) { + row.status = row.entryNode ? 'deleted' : 'new' + return + } + + if (row.entryNode) { + row.status = row.status === 'existing' || row.status === 'deleted' ? 'modified' : row.status + return + } + + row.status = 'new' +} diff --git a/src/components/profile-heading/editor/sections/shared/sanitizeUtils.ts b/src/components/profile-heading/editor/sections/shared/sanitizeUtils.ts new file mode 100644 index 00000000..d7b2930d --- /dev/null +++ b/src/components/profile-heading/editor/sections/shared/sanitizeUtils.ts @@ -0,0 +1,17 @@ +import { sanitizeTextValue } from '../../textUtils' + +export function sanitizeEmailValue (value: string): string { + return sanitizeTextValue(value).replace(/\s+/g, '') +} + +export function sanitizePhoneLocalValue (value: string): string { + return sanitizeTextValue(value).replace(/[^0-9()\-\s.]/g, '') +} + +export function sanitizeAddressFieldValue (value: string): string { + return sanitizeTextValue(value) +} + +export function sanitizeBasicInputFieldValue (value: string): string { + return sanitizeTextValue(value) +} diff --git a/src/components/profile-heading/editor/sections/shared/types.ts b/src/components/profile-heading/editor/sections/shared/types.ts new file mode 100644 index 00000000..b7310e4b --- /dev/null +++ b/src/components/profile-heading/editor/sections/shared/types.ts @@ -0,0 +1,40 @@ +import { LiveStore, NamedNode, st } from 'rdflib' + +export type RowStatus = 'existing' | 'new' | 'modified' | 'deleted' + +export type MutationOps = { + create: T[] + update: T[] + remove: T[] +} + +export type RdfStatement = ReturnType + +export type PrefixCapable = { + namespaces?: Record + setPrefixForURI?: (prefix: string, uri: string) => void +} + +export type UpdateCallback = (_uri: string, ok: boolean, message?: string) => void + +export type DavUpdateCallback = (_uri: string, ok: boolean, body?: string) => void + +export type RdfUpdater = PrefixCapable & { + update?: (deletions: RdfStatement[], insertions: RdfStatement[], callback: UpdateCallback) => void + updateDav?: (doc: NamedNode, deletions: RdfStatement[], insertions: RdfStatement[], callback: DavUpdateCallback) => void + serialize?: (docUri: string, statements: RdfStatement[], contentType: string) => string + store?: PrefixCapable +} + +export type WebOperationResponse = { + ok?: boolean + status?: number + responseText?: string +} + +export type RdfFetcher = { + load?: (doc: NamedNode) => Promise + webOperation?: (...args: unknown[]) => Promise +} + +export type MutationCapableStore = LiveStore diff --git a/src/components/profile-heading/editor/styles/EditDialogs.css b/src/components/profile-heading/editor/styles/EditDialogs.css new file mode 100644 index 00000000..a1ea0ad2 --- /dev/null +++ b/src/components/profile-heading/editor/styles/EditDialogs.css @@ -0,0 +1,743 @@ +@import './EditDialogs.responsive.css'; + +.profile__edit-form { + --profile-edit-dialog-row-max-width: 544px; + --profile-edit-dialog-muted-text: var(--solid-ui-color-gray-600, #4A5565); + --profile-edit-dialog-quiet-text: var(--solid-ui-color-gray-700, #364153); + --profile-edit-dialog-mobile-image-button-font-size: 0.75rem; + --profile-edit-dialog-mobile-image-label-font-size: var(--solid-ui-font-size-sm, 0.875rem); + --profile-edit-dialog-mobile-image-description-font-size: 0.625rem; + --profile-edit-dialog-mobile-image-button-padding: 6.6px 12px 7px 14px; + width: 100%; +} + +.profile-edit-dialog__section, +.profile-edit-dialog__section--education { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile-edit { + display: flex; + flex-direction: column; + gap: 25px; +} + +.profile-edit-dialog__section h4 { + margin: 0; +} + +.profile-edit-dialog__section fieldset { + width: 100%; + margin: 0; + display: grid; + gap: 15px; + border: 0; + padding: 0; + min-inline-size: 0; +} + +.profile-edit-dialog__field-description { + color: var(--profile-edit-dialog-muted-text); + font-size: var(--solid-ui-font-size-sm, 0.875rem); + font-weight: 400; + line-height: 1.5; +} + +.profile-edit-dialog__row { + display: flex; + align-items: flex-start; + gap: 15px; + max-width: var(--profile-edit-dialog-row-max-width); +} + +.profile-edit-dialog__row--heading-photo { + gap: 10px; +} + +.profile-edit-dialog__row--full { + max-width: 100%; + width: 100%; +} + +.profile-edit-dialog__field, +.profile-edit-dialog__field-type { + display: block; + margin: 0; +} + +.profile-edit-dialog__field-type { + width: 100%; +} + +.profile-edit-dialog__field { + min-width: 0; +} + +.profile-edit-dialog__field--stack { + display: grid; +} + +.profile-edit-dialog__field--stack > small { + display: block; + width: 100%; + text-align: right; + justify-self: end; +} + +.profile-edit-dialog__field--full { + max-width: 100% !important; + width: 100%; +} + +.profile-edit-dialog__field--row-width { + max-width: 544px; + width: 100%; +} + +.profile-edit-dialog__login-message { + margin: 0; + font-size: var(--solid-ui-font-size-xs, 0.75rem); + color: var(--solid-ui-color-error, #b00020); +} + +.profile-edit-dialog__section + .profile-edit-dialog__login-message { + margin-top: 15px; +} + +.profile__edit-form--projects .profile-edit-dialog__login-message { + margin-top: 15px; +} + +.profile-edit-dialog__actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 0 0 auto; +} + +.profile-edit-dialog__actions--edge { + margin-left: auto; +} + +.profile-edit-dialog__input-wrap { + position: relative; + width: 100%; +} + +.profile-edit-dialog__input--with-action { + width: 100%; + box-sizing: border-box; + padding-right: 120px; +} + +.profile-edit-dialog__image-preview { + display: grid; +} + +.profile-edit-dialog__image-preview-header { + margin-bottom: 0; +} + +.profile-edit-dialog__image-preview-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.profile-edit-dialog__image-preview-label { + color: var(--gray-900, #101828); + font-size: var(--solid-ui-font-size-sm, .875rem); + font-weight: 600; +} + +.profile-edit-dialog__image-preview-description { + color: var(--solid-ui-color-gray-600, #4a5565); + font-size: var(--solid-ui-font-size-sm, .875rem); + font-weight: 400; +} + +.profile-edit-dialog__image-preview-label, +.profile-edit-dialog__image-preview-description { + margin: 0; +} + +.profile-edit-dialog--heading { + --profile-edit-dialog-avatar-size: 5.625rem; +} + +.profile-edit-dialog__image-frame { + position: relative; + display: inline-block; + width: var(--profile-edit-dialog-avatar-size, 5.625rem); + height: var(--profile-edit-dialog-avatar-size, 5.625rem); + line-height: 0; + font-size: 0; + vertical-align: top; + overflow: visible; +} + +.profile-edit-dialog__image-frame > solid-ui-photo-capture { + position: absolute; + inset: 0; + display: block; + pointer-events: none; +} + +.profile-edit-dialog__image-frame > solid-ui-photo-capture > * { + pointer-events: auto; +} + +.profile__image-frame { + position: relative; + display: block; + width: var(--profile-edit-dialog-avatar-size, 5.625rem); + height: var(--profile-edit-dialog-avatar-size, 5.625rem); + overflow: hidden; + border-radius: 10px; + border: 1px solid var(--solid-ui-color-primary, #7c4dff); + background: var(--solid-ui-color-slate-50, #F8FAFC); + box-sizing: border-box; + padding: 0; + margin: 0; +} + +.profile__image-frame--fallback { + border: 1px dashed var(--solid-ui-color-slate-200, #E2E8F0); + background: var(--solid-ui-color-slate-50, #F8FAFC); +} + +.profile__hero, +.profile__hero-alt { + display: block; + width: 100%; + height: 100%; + box-sizing: border-box; + border-radius: 10px; +} + +.profile__hero { + object-fit: cover; + object-position: center; + background: var(--color-surface-placeholder, var(--gray-300, #d1d5dc)) 50% / cover no-repeat; +} + +.profile__hero-alt { + display: none; + align-items: center; + justify-content: center; + background: var(--solid-ui-color-slate-50, #F8FAFC); +} + +.profile__image-frame--fallback .profile__hero-alt { + display: flex; +} + +.profile__hero-icon { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 1.125rem; + box-sizing: border-box; +} + +.profile__hero-icon icon-lucide-circle-user-round, +.profile__hero-alt icon-lucide-circle-user-round { + display: block; + width: 1.75rem; + height: 1.75rem; + color: #D9E1F2; +} + +.profile-edit-dialog__image-camera-button { + position: absolute; + right: 5px; + bottom: 5px; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + min-width: 1.75rem; + min-height: 1.75rem; + padding: 0; + border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); + border-radius: 7px; + background: var(--solid-ui-color-white, #FFFFFF); + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12); + color: var(--solid-ui-color-gray-700, #364153); + cursor: pointer; + box-sizing: border-box; + transition: none; + line-height: 0; +} + +.profile-edit-dialog__image-camera-button:hover, +.profile-edit-dialog__image-camera-button:focus-visible, +.profile-edit-dialog__image-camera-button:active { + width: 1.75rem; + height: 1.75rem; + min-width: 1.75rem; + min-height: 1.75rem; + padding: 0; + border-color: var(--solid-ui-color-slate-200, #E2E8F0); + background: color-mix(in srgb, var(--solid-ui-color-primary, #7c4dff) 12%, #FFFFFF); + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12); + transform: none; + color: var(--solid-ui-color-primary, #7c4dff); +} + +.profile-edit-dialog__image-camera-button icon-lucide-camera { + display: block; + width: 0.875rem; + height: 0.875rem; + flex-shrink: 0; + vertical-align: middle; + margin: 0.05rem; +} + +.profile-edit-dialog__image-camera-capture-row { + display: flex; + justify-content: center; + width: 100%; +} + +.profile-edit-dialog__image-camera-capture-frame { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: fit-content; + max-width: 100%; + margin-left: auto; + margin-right: auto; + margin-top: calc(12px / 2); + margin-bottom: 12px; + padding: 10px; + box-sizing: border-box; + border: 1px solid var(--solid-color-gray-200, #E5E7EB); + border-radius: 8px; +} + +.profile-edit-dialog__image-camera-capture-frame[hidden] { + display: none; +} + +.profile-edit-dialog__paste-button { + position: absolute; + white-space: nowrap; + right: 10px; + top: 50%; + transform: translateY(-50%); + z-index: 1; +} + +.profile-edit-dialog__paste-icon { + width: 0.875rem; + height: 0.875rem; + stroke: var(--solid-ui-color-primary, #7c4dff); + fill: var(--solid-ui-color-primary, #7c4dff); +} + +.profile-edit-dialog__paste-icon svg { + display: block; + transform: translate(1px, 1px); +} + +solid-ui-button.profile-edit-dialog__drag-handle { + cursor: grab; + touch-action: none; + -webkit-user-select: none; + user-select: none; +} + +solid-ui-button.profile-edit-dialog__drag-handle:active { + cursor: grabbing; +} + +.profile-edit-dialog__row--drop-target { + outline: 1px dashed var(--solid-ui-color-gray-400, #99A1AF); + outline-offset: 2px; +} + +.profile-edit-dialog__field input, +.profile-edit-dialog__field select, +.profile-edit-dialog__field-type input, +.profile-edit-dialog__field-type select { + margin-block-start: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-height: var(--solid-ui-clickable-area, 42px); + height: var(--solid-ui-clickable-area, 42px); + border-radius: 5px; + border: 1px solid var(--solid-ui-color-gray-400, #99A1AF); + background: var(--solid-ui-color-white, #FFF); + color: var(--color-text-subheading, var(--gray-900, #101828)); + font-size: var(--solid-ui-font-size-sm, 0.875rem); + font-weight: 500; + padding: 10px; +} + +.profile-edit-dialog__field input[type='date'], +.profile-edit-dialog__field-type input[type='date'] { + min-width: 0; +} + +.profile-edit-dialog__field textarea, +.profile-edit-dialog__field-type textarea { + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-height: var(--solid-ui-clickable-area, 42px); +} + +.profile-edit-dialog__textarea { + color: var(--color-text-subheading, var(--gray-900, #101828)); + font-size: var(--solid-ui-font-size-sm, 0.875rem); + font-weight: 500; + line-height: 1.6; + border-radius: 5px; + border: 1px solid var(--solid-ui-color-gray-400, #99A1AF); + background: var(--solid-ui-color-white, #FFFFFF); + min-height: 160px !important; + height: auto; + resize: vertical; +} + +.profile__edit-form--heading { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Section-specific edit dialog overrides */ + +.profile-edit-dialog__bio-field-container { + display: flex; + width: 100%; + max-width: var(--profile-edit-dialog-row-max-width); + padding: 0; + flex-direction: column; + align-items: flex-start; + gap: 12px; + box-sizing: border-box; + margin: 0; +} + +.profile-edit-dialog__row--language { + margin-left: 0; +} + +.profile-edit-dialog__row--language > .profile-edit-dialog__field--language-name { + flex: 1 1 0; + min-width: 0; +} + +.profile-edit-dialog__field-type--language-proficiency, +.profile-edit-dialog__field-type--language-proficiency[hidden] { + display: none !important; +} + +.profile-edit-dialog__actions--language { + margin-left: 0; +} + +.profile-edit-dialog__row--social { + margin-left: calc(-1 * 10px); + max-width: 100%; + width: 100%; +} + +.profile-edit-dialog__field--social-type { + flex: 0 0 96px; + width: 96px; + max-width: 96px; +} + +.profile-edit-dialog__field--social-url { + flex: 1 1 auto; + min-width: 0; +} + +.profile-edit-dialog__row--project { + max-width: 100%; + width: 100%; +} + +.profile-edit-dialog__row--resume-entry-header { + align-items: center; + border-bottom: 1px solid var(--solid-ui-color-gray-400, #99A1AF); +} + +.profile-edit-dialog__row--resume-entry-header > .profile-edit-dialog__entry-heading { + margin: 0; +} + +.profile-edit-dialog__entry-heading { + margin: 0; + color: var(--solid-ui-color-gray-700, #364153); + font-size: var(--solid-ui-font-size-sm, 0.875rem); + font-weight: 500; +} + +.profile-edit-dialog__section--education .profile-edit-dialog__section-heading { + margin: 0; + color: var(--solid-ui-color-gray-700, #364153); + font-size: var(--solid-ui-font-size-md, 1rem); + font-weight: 500; +} + +/* Force two direct children to split the row into equal columns. */ +.profile-edit-dialog__row--equal > .profile-edit-dialog__field, +.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type { + flex: 1 1 0; + min-width: 0; +} + +.profile-edit-dialog--heading .profile-edit-dialog__image-camera-capture-row { + margin-bottom: calc(25px / -2); +} + +/* An empty capture row would still add a flex gap below the divider. */ +.profile-edit-dialog--heading .profile-edit-dialog__image-camera-capture-row:has(> [hidden]) { + display: none; +} + +.profile-edit-dialog--heading .profile-edit-dialog__image-camera-capture-frame { + margin-top: calc(12px / 4); + margin-bottom: calc(12px / 2); +} + +.profile__edit-form--resume .profile-edit-dialog__field, +.profile__edit-form--resume .profile-edit-dialog__field-type:not(.hidden), +.profile__edit-form--contact-info .profile-edit-dialog__field, +.profile__edit-form--contact-info .profile-edit-dialog__field-type:not(.hidden) { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; + flex: 1 0 0; +} + +.profile__edit-form--resume .profile-edit-dialog__row { + max-width: 100%; + width: 100%; +} + +.profile__edit-form--resume .profile-edit-dialog__section fieldset > .profile-edit-dialog__field { + max-width: 100%; +} + +.profile__edit-form--resume .profile-edit-dialog__field > input, +.profile__edit-form--resume .profile-edit-dialog__field > solid-ui-select, +.profile__edit-form--resume .profile-edit-dialog__field > solid-ui-combobox, +.profile__edit-form--resume .profile-edit-dialog__field > textarea, +.profile__edit-form--resume .profile-edit-dialog__field-type > input, +.profile__edit-form--resume .profile-edit-dialog__field-type > solid-ui-select, +.profile__edit-form--resume .profile-edit-dialog__field-type > textarea, +.profile__edit-form--contact-info .profile-edit-dialog__field > input, +.profile__edit-form--contact-info .profile-edit-dialog__field > solid-ui-select, +.profile__edit-form--contact-info .profile-edit-dialog__field > textarea, +.profile__edit-form--contact-info .profile-edit-dialog__field-type > input, +.profile__edit-form--contact-info .profile-edit-dialog__field-type > solid-ui-select, +.profile__edit-form--contact-info .profile-edit-dialog__field-type > textarea { + align-self: stretch; +} + +.profile-edit-dialog__field > solid-ui-combobox, +.profile-edit-dialog__field-type > solid-ui-combobox { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + align-self: stretch; +} + +/* The combobox input is transparent and inherits its font from the host, so the host provides the field background and matches the 42px plain input height. */ +.profile__edit-form solid-ui-combobox { + background: var(--solid-ui-color-white, #FFFFFF); + border-radius: 5px; + color: var(--color-text-subheading, var(--gray-900, #101828)); + font-size: var(--solid-ui-font-size-sm, 0.875rem); + font-weight: 500; + line-height: calc(var(--solid-ui-clickable-area, 42px) - 22px); +} + +.profile-edit-dialog__field--resume-location { + flex: 1 0 0; + width: auto; + max-width: none; +} + +.profile-edit-dialog__row--resume-dates { + max-width: 100%; + width: 100%; +} + +.profile-edit-dialog__field--date-group { + flex: 1 1 0; + min-width: 0; +} + +.profile-edit-dialog__date-pair { + display: flex; + align-items: center; + gap: 15px; + width: 100%; +} + +.profile-edit-dialog__date-pair > solid-ui-select { + flex: 1 1 0; + min-width: 0; +} + +.profile-edit-dialog__resume-date-select { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.profile-edit-dialog__resume-date-select--disabled { + pointer-events: none; + opacity: var(--opacity-input-disabled, 0.75); +} + +.profile-edit-dialog__bio-field-container > small { + align-self: flex-end; + text-align: right; +} + +.profile-edit-dialog--heading .profile-edit-dialog__row, +.profile-edit-dialog--heading .profile-edit-dialog__field--row-width { + max-width: 100%; + width: 100%; +} + +.profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo { + padding-bottom: 20px; + border-bottom: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); +} + +.profile-edit-dialog--heading .profile-edit { + gap: calc(25px / 2); +} + +.profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__hero-alt { + padding: 0; +} + +.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point { + max-width: 100%; + width: 100%; + gap: 10px; +} + +.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point > .profile-edit-dialog__field { + flex: 1 1 0; + min-width: 0; +} + +.profile-edit-dialog--heading .profile-edit-dialog__field-type--contact-point { + padding-top: calc(var(--solid-ui-font-size-sm, 0.875rem) * 1.5); +} + +.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point > .profile-edit-dialog__field > .label { + display: block; + width: 100%; +} + +.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type.profile-edit-dialog__field-type--contact-point { + flex: 0 0 103px; + width: 103px; + min-width: 103px; + max-width: 103px; +} + +.profile-edit-dialog__row--address-header { + max-width: var(--profile-edit-dialog-row-max-width); + align-items: flex-end; +} + +.profile-edit-dialog__row--inline-end { + justify-content: flex-end; + max-width: 100%; +} + +.profile-edit-dialog__checkbox-label { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 12px; + cursor: var(--profile-interactive-cursor, pointer); +} + +.profile-edit-dialog__checkbox-input { + width: var(--icon-xxs, 1rem); + height: var(--icon-xxs, 1rem); + margin: 0; + flex: 0 0 auto; + accent-color: var(--solid-ui-color-primary, #7C4DFF); + cursor: var(--profile-interactive-cursor, pointer); +} + +.profile-edit-dialog__checkbox-input:focus-visible { + outline: var(--focus-indicator-width, 2px) solid var(--solid-ui-color-primary, #7C4DFF); + outline-offset: 2px; +} + +.profile-edit-dialog__checkbox-input:disabled { + opacity: var(--opacity-disabled, 0.5); +} + +/* The street-address row is a standalone label, so cap its width like the flex rows. */ +.profile-edit-dialog__section fieldset > .profile-edit-dialog__field { + max-width: var(--profile-edit-dialog-row-max-width); +} + +.profile-edit-dialog__field-type--wide { + flex: 1 1 auto; + min-width: 0; + width: 100%; + max-width: 100%; +} + +.profile-edit-dialog__field-type--contact-point { + flex: 0 0 auto; + width: auto; + max-width: none; + align-self: flex-start; +} + +/* Dedicated hook for the social account type select in SocialEditDialog. */ +.profile-edit-dialog__social-account-select { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + contain: inline-size; +} + +.profile-edit-dialog__resume-organization-type-select { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.profile-edit-dialog__bio-field-container textarea { + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-height: var(--solid-ui-clickable-area, 42px); +} diff --git a/src/components/profile-heading/editor/styles/EditDialogs.responsive.css b/src/components/profile-heading/editor/styles/EditDialogs.responsive.css new file mode 100644 index 00000000..c0ed6dae --- /dev/null +++ b/src/components/profile-heading/editor/styles/EditDialogs.responsive.css @@ -0,0 +1,277 @@ +html[data-layout='mobile'] .profile-edit-dialog__row { + max-width: 100%; + flex-direction: column; + align-items: stretch; +} + +html[data-layout='mobile'] .profile-edit-dialog__row--equal > .profile-edit-dialog__field, +html[data-layout='mobile'] .profile-edit-dialog__row--equal > .profile-edit-dialog__field-type, +html[data-layout='mobile'] .profile-edit-dialog__field-type { + flex: 1 1 auto; +} + +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__field--dob { + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + align-self: stretch; +} + +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__row--heading-dob { + flex-direction: column; + align-items: stretch; +} + +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__row--heading-dob > .profile-edit-dialog__field, +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__row--heading-dob > .profile-edit-dialog__field-type { + width: 100%; + max-width: 100%; + min-width: 0; + flex: 1 1 auto; +} + +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob { + display: block; + height: var(--solid-ui-clickable-area, 42px); + min-height: var(--solid-ui-clickable-area, 42px); + max-height: var(--solid-ui-clickable-area, 42px); + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + line-height: var(--line-height-base, 1.5); +} + +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-date-and-time-value, +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit, +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit-fields-wrapper { + width: 100%; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-align: center; +} + +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-date-and-time-value { + text-align: center; + white-space: nowrap; +} + +@media (hover: none) and (pointer: coarse) { + /* Native mobile date pickers can overflow the shared field sizing. */ + html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob { + height: var(--solid-ui-clickable-area, 42px); + min-height: var(--solid-ui-clickable-area, 42px); + max-height: var(--solid-ui-clickable-area, 42px); + line-height: calc(var(--solid-ui-clickable-area, 42px) - 2px); + padding-block: 0; + width: calc(100% - 24px); + max-width: calc(100% - 24px); + } + + html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-date-and-time-value, + html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit, + html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit-fields-wrapper { + line-height: calc(var(--solid-ui-clickable-area, 42px) - 2px); + } + + html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-calendar-picker-indicator { + margin: 0; + padding: 0; + } +} + +html[data-layout='mobile'] .profile__edit-form { + --profile-edit-dialog-mobile-image-button-font-size: var(--solid-ui-font-size-md, 1rem); + --profile-edit-dialog-mobile-image-label-font-size: var(--solid-ui-font-size-sm, 0.875rem); + --profile-edit-dialog-mobile-image-description-font-size: 0.625rem; +} + +html[data-layout='mobile'] #profile-modal .modal label.profile-edit-dialog__field, +html[data-layout='mobile'] #profile-modal .modal label.profile-edit-dialog__field-type, +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog__field > .label, +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog__field-type > .label, +html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog__field-description { + font-size: var(--solid-ui-font-size-sm, 0.875rem); + line-height: 1.5; +} + +html[data-layout='mobile'] .profile-edit-dialog__field input, +html[data-layout='mobile'] .profile-edit-dialog__field-type input { + font-size: var(--solid-ui-font-size-md, 1rem); +} + +html[data-layout='mobile'] .profile-edit-dialog__field-type--contact-point { + flex: 1 1 auto; + width: 100%; + max-width: 100%; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point { + flex-direction: row; + align-items: stretch; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type.profile-edit-dialog__field-type--contact-point { + flex: 0 0 103px; + width: 103px; + min-width: 103px; + max-width: 103px; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo { + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + gap: 12px; + width: 100%; + max-width: 100%; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo > header { + margin-bottom: 0; + flex: 0 0 auto; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading { + --profile-edit-dialog-avatar-size: 5rem; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__hero-alt { + padding: 0; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__image-frame--fallback .profile__hero-alt { + display: flex; + align-items: center; + justify-content: center; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview { + flex: 1 1 auto; + min-width: 0; + gap: 5px; + width: 100%; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-label { + font-size: var(--profile-edit-dialog-mobile-image-label-font-size); + line-height: 1.25; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-description { + font-size: var(--profile-edit-dialog-mobile-image-description-font-size); + line-height: 1.4; +} + +html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-actions { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 8px; + width: 100%; + align-items: stretch; + justify-content: start; +} + +@media (max-width: 576px) { + .profile-edit-dialog__row { + max-width: 100%; + flex-direction: column; + align-items: stretch; + } + + .profile__edit-form { + --profile-edit-dialog-mobile-image-button-font-size: var(--solid-ui-font-size-md, 1rem); + --profile-edit-dialog-mobile-image-label-font-size: var(--solid-ui-font-size-sm, 0.875rem); + --profile-edit-dialog-mobile-image-description-font-size: 0.625rem; + } + + .profile-edit-dialog__row--equal > .profile-edit-dialog__field, + .profile-edit-dialog__row--equal > .profile-edit-dialog__field-type, + .profile-edit-dialog__field-type { + flex: 1 1 auto; + } + + .profile-edit-dialog__field input, + .profile-edit-dialog__field-type input { + font-size: var(--solid-ui-font-size-sm, 0.875rem); + } + + .profile-edit-dialog__field-type--contact-point { + flex: 1 1 auto; + width: 100%; + max-width: 100%; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point { + flex-direction: row; + align-items: stretch; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type.profile-edit-dialog__field-type--contact-point { + flex: 0 0 103px; + width: 103px; + min-width: 103px; + max-width: 103px; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo { + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + gap: 12px; + width: 100%; + max-width: 100%; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo > header { + margin-bottom: 0; + flex: 0 0 auto; + } + + .profile__edit-form.profile-edit-dialog--heading { + --profile-edit-dialog-avatar-size: 5rem; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__hero-alt { + padding: 0; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__image-frame--fallback .profile__hero-alt { + display: flex; + align-items: center; + justify-content: center; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview { + flex: 1 1 auto; + min-width: 0; + gap: 5px; + width: 100%; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-label { + font-size: var(--profile-edit-dialog-mobile-image-label-font-size); + line-height: var(--line-height-tight, 1.25); + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-description { + font-size: var(--profile-edit-dialog-mobile-image-description-font-size); + line-height: var(--line-height-base, 1.4); + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-actions { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 8px; + width: 100%; + align-items: stretch; + justify-content: start; + } + + .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-button { + width: auto; + min-width: 0; + } +} diff --git a/src/components/profile-heading/editor/textUtils.ts b/src/components/profile-heading/editor/textUtils.ts new file mode 100644 index 00000000..7c60d455 --- /dev/null +++ b/src/components/profile-heading/editor/textUtils.ts @@ -0,0 +1,44 @@ +export function strToUpperCase (str: string) { + if (str.trim().length > 0) { + const strCase = str.split(' ') + for (let i = 0; i < strCase.length; i++) { + strCase[i] = strCase[i].charAt(0).toUpperCase() + + strCase[i].substring(1) + } + return strCase.join(' ') + } + return '' +} + +export function toText (value: unknown): string { + if (!value) return '' + if (typeof value === 'string') return value + if (typeof value === 'object' && 'value' in (value as Record)) { + const nested = (value as { value?: unknown }).value + return typeof nested === 'string' ? nested : '' + } + return '' +} + +export function toTypeLabel (value: unknown): string { + const raw = toText(value).trim() + if (!raw) return '' + const withoutAngles = raw.replace(/^<|>$/g, '') + const hashParts = withoutAngles.split('#') + const lastByHash = hashParts[hashParts.length - 1] + const slashParts = lastByHash.split('/') + return slashParts[slashParts.length - 1] +} + +export function sanitizeTextValue (value: string): string { + return value + // Control characters must be removed from user-entered profile values. + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001F\u007F]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +export function hasNonEmptyText (value: string | undefined): boolean { + return Boolean(value && value.trim() !== '') +} diff --git a/src/components/profile-heading/editor/texts.ts b/src/components/profile-heading/editor/texts.ts new file mode 100644 index 00000000..bf5ffd5f --- /dev/null +++ b/src/components/profile-heading/editor/texts.ts @@ -0,0 +1,6 @@ +/* The text refactor was generated by AI Model: GPT-5.4-Copilot. */ +export * from './texts/buttonTexts' +export * from './texts/dialogTexts' +export * from './texts/messageTexts' +export * from './texts/profileTexts' +export * from './texts/qrCodeTexts' diff --git a/src/components/profile-heading/editor/texts/buttonTexts.ts b/src/components/profile-heading/editor/texts/buttonTexts.ts new file mode 100644 index 00000000..40b09675 --- /dev/null +++ b/src/components/profile-heading/editor/texts/buttonTexts.ts @@ -0,0 +1,8 @@ +export const addMeToYourFriendsButtonText = 'Add as friend' +export const logInAddMeToYourFriendsButtonText = 'Log in to add as friend' +export const addMeToYourContactsButtonText = 'Add as Contact' +export const logInAddMeToYourContactsButtonText = 'Login to add me to your contacts' +export const logInToChatWithMeButtonText = 'Login to chat with me' +export const friendExistsAlreadyButtonText = 'Already a friend' +export const contactExistsAlreadyButtonText = 'Already part of contacts' +export const contactExistsAlreadyByNameButtonText = 'Add webID to contact' diff --git a/src/components/profile-heading/editor/texts/dialogTexts.ts b/src/components/profile-heading/editor/texts/dialogTexts.ts new file mode 100644 index 00000000..d09dd97c --- /dev/null +++ b/src/components/profile-heading/editor/texts/dialogTexts.ts @@ -0,0 +1,13 @@ +export const editSkillsDialogTitleText = 'Edit Skills' +export const editLanguagesDialogTitleText = 'Edit Languages' +export const editContactInfoDialogTitleText = 'Edit More Contacts' +export const editResumeDialogTitleText = 'Edit Resume' +export const editEducationDialogTitleText = 'Edit Education' +export const editProjectsDialogTitleText = 'Add Projects & Communities' +export const editHeadingDialogTitleText = 'Edit Heading' +export const editSocialDialogTitleText = 'Edit Social Links' +export const editBioDialogTitleText = 'Edit Bio' +export const dialogSubmitLabelText = 'Save Changes' +export const dialogCancelLabelText = 'Cancel' +export const pasteEntryButtonTitleText = 'Paste' +export const deleteEntryButtonTitleText = 'Delete entry' diff --git a/src/components/profile-heading/editor/texts/messageTexts.ts b/src/components/profile-heading/editor/texts/messageTexts.ts new file mode 100644 index 00000000..f1a171c4 --- /dev/null +++ b/src/components/profile-heading/editor/texts/messageTexts.ts @@ -0,0 +1,43 @@ +export const userNotLoggedInErrorMessage = 'Current user not found! Not logged in?' +export const friendWasAddedSuccesMessage = 'Friend was added!' +export const contactWasAddedSuccesMessage = 'Contact was added!' +export const friendExistsMessage = 'Friend already exists' +export const contactExistsMessage = 'Contact already exists' +export const loadingMessage = 'Loading...' +export const groupIsRequired = 'You need to either pick a group or enter one.' +export const errorGroupCreation = 'Unable to create the group.' +export const errorContactCreation = 'Unable to add the contact.' +export const errorAddressBookCreation = 'Unable to create your address book.' +export const errorReadingAddressBook = 'There was an error reading your address book.' +export const errorLoadingContact = 'There was an error loading the contact: ' +export const errorGettingAddressBooks = 'There was an error getting your address books.' +export const errorNotExistsAddressBookUri = 'Please enter an address book URI.' +export const errorProcessingUriAddressBook = 'Unable to add your address book.' +export const errorUnableToDetermineUserWorkspace = 'Unable to determine user workspace for new address book.' +export const errorAddingContactWebIDToAddressBook = 'Contact webID was not saved.' +export const ownerLoginRequiredDialogMessageText = 'You need to log in as the profile owner to save contact updates.' + +export const saveContactUpdatesFailedMessageText = 'Unable to save your contact information.' +export const saveHeadingUpdatesFailedMessageText = 'Unable to save your heading information.' +export const saveLanguageUpdatesFailedMessageText = 'Unable to save your languages.' +export const saveSkillsUpdatesFailedMessageText = 'Unable to save your skills.' +export const saveResumeUpdatesFailedMessageText = 'Unable to save your resume.' +export const saveEducationUpdatesFailedMessageText = 'Unable to save your education history.' +export const saveProjectsUpdatesFailedMessageText = 'Unable to save your project data.' +export const unfollowProjectFailedMessageText = 'Unable to unfollow the project.' +export const saveSocialUpdatesFailedMessageText = 'Unable to save your social accounts.' +export const saveBioUpdatesFailedMessageText = 'Unable to save your bio.' + +export const bioMutationSaveFailedDebugText = 'Bio mutation save failed' +export const addContactWebIdSaveFailedDebugText = 'Add contact WebID save failed' +export const contactInfoMutationSaveFailedDebugText = 'Contact info mutation save failed' +export const educationMutationSaveFailedDebugText = 'Education mutation save failed' +export const headingMutationSaveFailedDebugText = 'Heading mutation save failed' +export const languageMutationSaveFailedDebugText = 'Language mutation save failed' +export const projectsMutationSaveFailedDebugText = 'Projects mutation save failed' +export const resumeMutationSaveFailedDebugText = 'Resume mutation save failed' +export const skillsMutationSaveFailedDebugText = 'Skills mutation save failed' +export const socialMutationSaveFailedDebugText = 'Social mutation save failed' +export const resumeUpdateEntryNotFoundErrorMessageText = 'Could not find existing resume entry for update. Please reload profile data and try again.' +export const updaterUnsupportedStoreErrorMessageText = 'Store does not support updates' +export const fallbackSaveUpdatesErrorMessageText = 'Failed to save updates' diff --git a/src/components/profile-heading/editor/texts/profileTexts.ts b/src/components/profile-heading/editor/texts/profileTexts.ts new file mode 100644 index 00000000..b79a42d1 --- /dev/null +++ b/src/components/profile-heading/editor/texts/profileTexts.ts @@ -0,0 +1,13 @@ +export const yourContactInformationHeading = 'Your profile' +export const otherPreferencesHeadingText = 'Other preferences' +export const communitiesHeadingText = 'Your communities' +export const resumeHeadingText = 'Resume' +export const bioHeadingText = 'Bio' +export const educationHeadingText = 'Education' +export const skillsHeadingText = 'Skills' +export const languagesHeadingText = 'Languages' +export const projectsHeadingText = 'Projects & Communities' +export const contactInfoHeadingText = 'More Contacts' +export const contactInfoEmptyHeadingText = 'Contact Info' +export const socialAccountsHeadingText = 'Follow me on' +export const friendsHeadingText = 'Friends' diff --git a/src/components/profile-heading/editor/texts/qrCodeTexts.ts b/src/components/profile-heading/editor/texts/qrCodeTexts.ts new file mode 100644 index 00000000..bebf3b58 --- /dev/null +++ b/src/components/profile-heading/editor/texts/qrCodeTexts.ts @@ -0,0 +1 @@ +export const scanQrToConnectText = 'Add to Contacts' diff --git a/src/components/profile-heading/editor/ui/dialog.css b/src/components/profile-heading/editor/ui/dialog.css new file mode 100644 index 00000000..d4f48b63 --- /dev/null +++ b/src/components/profile-heading/editor/ui/dialog.css @@ -0,0 +1,163 @@ +@import './dialog.responsive.css'; + +/* CSS for the accessible modal dialogs created by dialog.ts */ +/* dialog host */ +#profile-modal { + --profile-modal-backdrop: var(--overlay-bg-muted, rgba(0, 0, 0, 0.5)); + --profile-modal-panel-inline-padding: 25px; + --profile-modal-panel-block-padding: 20px; + --profile-modal-compact-padding: 15px; + --profile-modal-button-gap: 15px; + --profile-modal-error-padding: 12px; + --profile-modal-panel-width: min(600px, calc(100vw - 32px)); + --profile-modal-panel-max-width: 600px; + --profile-modal-panel-max-height: calc(100dvh - 32px); + margin: 0; + padding: 0; + border: none; + background: var(--profile-modal-backdrop); + max-width: none; + max-height: none; + width: 100%; + height: 100%; + overflow: hidden; + position: fixed; + inset: 0; + z-index: 9999; + display: grid; + place-items: center; + overscroll-behavior: contain; + padding-top: max(25px, env(safe-area-inset-top)); + padding-right: 0; + padding-bottom: max(25px, env(safe-area-inset-bottom)); + padding-left: 0; +} + +#profile-modal:not([open]) { + display: none; +} + +#profile-modal::backdrop { + background: var(--profile-modal-backdrop); +} + +#profile-modal .modal { + box-sizing: border-box; + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; + margin: 0; + background: var(--color-background); + border-radius: 5px; + max-width: var(--profile-modal-panel-max-width); + width: var(--profile-modal-panel-width); + max-height: var(--profile-modal-panel-max-height); + overflow: hidden; + box-shadow: var(--box-shadow-modal, 0 2px 10px rgba(0, 0, 0, 0.3)); + align-self: center; + justify-self: center; +} + +#profile-modal .modal .modal-header { + gap: var(--spacing-lg); + width: 100%; + min-width: 0; + padding: var(--profile-modal-panel-block-padding) var(--profile-modal-panel-inline-padding); + box-sizing: border-box; + border-radius: 5px 5px 0 0; + border-bottom: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); + background: var(--solid-ui-color-slate-50, #F8FAFC); +} + +#profile-modal .modal #modal-desc { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + width: 100%; + min-width: 0; + padding: var(--profile-modal-panel-inline-padding); + box-sizing: border-box; +} + +#profile-modal .modal #modal-desc:has(> .profile__edit-form--projects) { + padding: 10px var(--profile-modal-panel-inline-padding); +} + +#profile-modal .modal #modal-desc:has(> #contacts-addressbook-picker-dialog) { + padding-top: 0; +} + +#profile-modal #modal-desc > .profile__edit-form--projects { + display: flex; + flex-direction: column; + gap: 10px; +} + +#profile-modal .modal #modal-title { + margin: 0; + color: var(--color-text-subheading, var(--gray-900, #101828)); +} + +#profile-modal .modal:has(#modal-desc > .profile__edit-form--projects) #modal-header-action, +#profile-modal .modal:has(#modal-desc > .profile__edit-form--contact-info) #modal-header-action { + display: none; +} + +/* button container: center buttons horizontally (uses id in markup) */ +#profile-modal #modal-buttons { + width: 100%; + min-width: 0; + padding: var(--profile-modal-panel-block-padding) calc(var(--profile-modal-panel-inline-padding) + var(--spacing-xxs, 0.3125rem)) var(--profile-modal-compact-padding); + gap: var(--profile-modal-button-gap); + align-self: stretch; + box-sizing: border-box; + border-top: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); + background: var(--color-surface-tertiary, var(--gray-50, #F9FAFB)); +} + +#profile-modal #modal-buttons[hidden] { + display: none !important; +} + +#profile-modal #modal-buttons > solid-ui-button { + width: 10.8rem; +} + +#profile-modal .modal__error-section { + margin-top: var(--spacing-sm); + margin-bottom: 0; + padding: var(--profile-modal-error-padding); + border: 1px solid var(--dialog-error-border, var(--pink-700, #ff98b3)); + border-radius: 5px; + background: var(--dialog-error-background, var(--pink-500, #ffdde6)); + color: var(--solid-ui-color-error, #B00020); + text-align: center; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +#profile-modal .modal__error-section[hidden] { + display: none !important; +} + +#profile-modal #modal-saving-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.78); + z-index: 10; +} + +#profile-modal #modal-saving-overlay[hidden] { + display: none; +} + +#profile-modal.modal--saving #modal-desc { + opacity: 0.45; + pointer-events: none; +} + +#profile-modal .modal__saving-indicator { + gap: 0; +} diff --git a/src/components/profile-heading/editor/ui/dialog.responsive.css b/src/components/profile-heading/editor/ui/dialog.responsive.css new file mode 100644 index 00000000..caa4c0b9 --- /dev/null +++ b/src/components/profile-heading/editor/ui/dialog.responsive.css @@ -0,0 +1,107 @@ +#profile-modal .modal:has(#modal-desc > .profile__edit-form solid-ui-combobox:focus-within) #modal-desc { + padding-bottom: calc(var(--profile-modal-panel-inline-padding) + 30px); +} + +@media (max-width: 576px) { + #profile-modal { + --profile-modal-panel-max-height: calc(100dvh - 4 * 15px); + padding-top: max(15px, env(safe-area-inset-top)); + padding-right: 0; + padding-bottom: max(15px, env(safe-area-inset-bottom)); + padding-left: 0; + place-items: center; + } + + #profile-modal .modal { + width: var(--profile-modal-panel-width); + max-width: var(--profile-modal-panel-max-width); + min-width: 0; + padding: 0; + border-radius: 5px; + overscroll-behavior: contain; + justify-self: center; + align-self: center; + margin: 0; + } + + #profile-modal .modal .modal-header { + gap: 25px; + padding: 15px; + } + + #profile-modal .modal #modal-title { + font-size: var(--solid-ui-font-size-lg, 1.125rem); + line-height: 1.4; + } + + #profile-modal .modal #modal-desc { + padding: 15px 15px 25px; + } + + #profile-modal #modal-buttons { + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: var(--profile-modal-button-gap); + padding: var(--profile-modal-compact-padding); + width: 100%; + } + + #profile-modal #modal-buttons > solid-ui-button { + flex: 1 0 0; + width: auto; + min-width: 0; + } +} + +html[data-layout='mobile'] #profile-modal { + --profile-modal-panel-max-height: calc(100dvh - 4 * 15px); + padding-top: max(15px, env(safe-area-inset-top)); + padding-right: 0; + padding-bottom: max(15px, env(safe-area-inset-bottom)); + padding-left: 0; + place-items: center; +} + +html[data-layout='mobile'] #profile-modal .modal { + width: var(--profile-modal-panel-width); + height: auto; + max-height: var(--profile-modal-panel-max-height); + max-width: var(--profile-modal-panel-max-width); + min-width: 0; + padding: 0; + border-radius: 5px; + overscroll-behavior: contain; + justify-self: center; + align-self: center; + margin: 0; +} + +html[data-layout='mobile'] #profile-modal .modal .modal-header { + gap: 25px; + padding: 15px; +} + +html[data-layout='mobile'] #profile-modal .modal #modal-title { + font-size: var(--solid-ui-font-size-lg, 1.125rem); + line-height: 1.4; +} + +html[data-layout='mobile'] #profile-modal .modal #modal-desc { + padding: 15px 15px 25px; +} + +html[data-layout='mobile'] #profile-modal #modal-buttons { + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: var(--profile-modal-button-gap); + padding: var(--profile-modal-compact-padding); + width: 100%; +} + +html[data-layout='mobile'] #profile-modal #modal-buttons > solid-ui-button { + flex: 1 0 0; + width: auto; + min-width: 0; +} diff --git a/src/components/profile-heading/editor/ui/dialog.ts b/src/components/profile-heading/editor/ui/dialog.ts new file mode 100644 index 00000000..5d68a83d --- /dev/null +++ b/src/components/profile-heading/editor/ui/dialog.ts @@ -0,0 +1,656 @@ +import './dialog.css' +import 'solid-ui/components/button' +import '~icons/lucide/x' +import { html, render } from 'lit-html' +import { createSpinner } from './spinner' +import { formatDisplayError } from '../utils/errorDisplay' + +const closeIcon = html`` + +/* Copied from issue-pane, minor typescript adjustments */ +/* Changed modal from div to dialog element */ +let modalDialog: HTMLDialogElement | null = null + +const scrollLockState = new WeakMap void) | null +}>() + +function getEventTargetElement (target: EventTarget | null): HTMLElement | null { + if (target instanceof HTMLElement) return target + if (target instanceof Node) return target.parentElement + return null +} + +function getEventPath (event: Event): EventTarget[] { + if (typeof event.composedPath === 'function') { + return event.composedPath() + } + + return event.target ? [event.target] : [] +} + +function isDialogScrollRegionTarget (event: Event): boolean { + const path = getEventPath(event) + const description = modalDialog?.querySelector('#modal-desc') + + if (description && path.includes(description)) { + return true + } + + if (modalDialog && path.includes(modalDialog)) { + return true + } + + const targetElement = getEventTargetElement(event.target) + if (!targetElement || !modalDialog || !modalDialog.contains(targetElement)) { + return false + } + + return Boolean(targetElement.closest('#modal-desc')) +} + +function getDocumentScrollLockState (dom: Document) { + let state = scrollLockState.get(dom) + if (!state) { + state = { + count: 0, + previousHtmlOverflow: dom.documentElement.style.overflow, + previousBodyOverflow: dom.body.style.overflow, + removeScrollGuard: null + } + scrollLockState.set(dom, state) + } + return state +} + +function installDocumentScrollGuard (dom: Document): void { + const state = getDocumentScrollLockState(dom) + if (state.removeScrollGuard) return + + const preventBackgroundScroll = (event: Event) => { + if (!isDialogScrollRegionTarget(event)) { + event.preventDefault() + } + } + + dom.addEventListener('wheel', preventBackgroundScroll, { capture: true, passive: false }) + dom.addEventListener('touchmove', preventBackgroundScroll, { capture: true, passive: false }) + state.removeScrollGuard = () => { + dom.removeEventListener('wheel', preventBackgroundScroll, true) + dom.removeEventListener('touchmove', preventBackgroundScroll, true) + state.removeScrollGuard = null + } +} + +function lockDocumentScroll (dom: Document): void { + const state = getDocumentScrollLockState(dom) + + if (state.count === 0) { + state.previousHtmlOverflow = dom.documentElement.style.overflow + state.previousBodyOverflow = dom.body.style.overflow + + dom.documentElement.style.overflow = 'hidden' + dom.body.style.overflow = 'hidden' + installDocumentScrollGuard(dom) + } + + state.count += 1 +} + +function unlockDocumentScroll (dom: Document): void { + const state = getDocumentScrollLockState(dom) + if (state.count === 0) return + + state.count -= 1 + if (state.count > 0) return + + dom.documentElement.style.overflow = state.previousHtmlOverflow + dom.body.style.overflow = state.previousBodyOverflow + state.removeScrollGuard?.() + state.removeScrollGuard = null +} + +function getDialogMountTarget (dom: Document): HTMLElement { + return dom.body +} + +type DialogButtonValue = boolean | 'save' | null +type DialogButton = { + label: string, + value: DialogButtonValue, + primary?: boolean, + cancel?: boolean, + beforeClose?: () => Promise | boolean +} + +type DialogHeaderAction = + | { type: 'close' } + | { type: 'none' } + | { + type: 'button', + label: string, + ariaLabel?: string, + className?: string, + onClick?: () => Promise | void + } + +type DialogElements = { + dialog: HTMLDialogElement, + title: HTMLHeadingElement, + headerAction: HTMLDivElement, + description: HTMLDivElement, + error: HTMLElement, + buttons: HTMLDivElement, + savingOverlay: HTMLDivElement +} + +type DialogActionControl = HTMLElement & { + disabled?: boolean, + label?: string, + click: () => void, + shadowRoot?: ShadowRoot | null +} + +function isSolidUiButton (control: Element | null): control is DialogActionControl { + return control?.tagName === 'SOLID-UI-BUTTON' +} + +function setDialogActionDisabled (control: DialogActionControl | null, disabled: boolean): void { + if (!control) return + control.disabled = disabled + control.toggleAttribute('disabled', disabled) +} + +function setDialogActionLabel (control: DialogActionControl | null, label: string): void { + if (!control) return + + if (isSolidUiButton(control)) { + control.label = label + control.setAttribute('label', label) + control.textContent = label + return + } + + ;(control as HTMLElement).textContent = label +} + +export function getSharedDialogSaveButton (root: ParentNode): DialogActionControl | null { + return root.querySelector('#modal-buttons [data-dialog-primary="true"]') as DialogActionControl | null +} + +export function getSharedDialogCancelButton (root: ParentNode): DialogActionControl | null { + return root.querySelector('#modal-buttons [data-cancel="true"]') as DialogActionControl | null +} + +function ensureModalDialog (dom: Document): HTMLDialogElement { + const mountTarget = getDialogMountTarget(dom) + + // if we previously created a dialog but it was removed from the document + // (tests clear body), rebuild it. Checking presence ensures our reference + // doesn't point at a detached element. + if (modalDialog && dom.body.contains(modalDialog)) { + if (modalDialog.parentElement !== mountTarget) { + mountTarget.appendChild(modalDialog) + } + return modalDialog + } + + modalDialog = null + modalDialog = dom.createElement('dialog') + modalDialog.id = 'profile-modal' + modalDialog.setAttribute('aria-modal', 'true') + modalDialog.setAttribute('aria-labelledby', 'modal-title') + modalDialog.setAttribute('aria-describedby', 'modal-desc') + + modalDialog.innerHTML = ` + + ` + + mountTarget.appendChild(modalDialog) + return modalDialog +} + +function getDialogElements (dialog: HTMLDialogElement): DialogElements { + return { + dialog, + title: dialog.querySelector('#modal-title') as HTMLHeadingElement, + headerAction: dialog.querySelector('#modal-header-action') as HTMLDivElement, + description: dialog.querySelector('#modal-desc') as HTMLDivElement, + error: dialog.querySelector('#modal-error') as HTMLElement, + buttons: dialog.querySelector('#modal-buttons') as HTMLDivElement, + savingOverlay: dialog.querySelector('#modal-saving-overlay') as HTMLDivElement + } +} + +function syncDialogErrorDescription (elements: DialogElements): void { + const defaultDescription = 'modal-desc' + const describedBy = elements.error.hidden || !elements.error.textContent?.trim() + ? defaultDescription + : `${defaultDescription} modal-error` + + elements.dialog.setAttribute('aria-describedby', describedBy) +} + +function clearModalError (elements: DialogElements): void { + elements.error.textContent = '' + elements.error.setAttribute('aria-hidden', 'true') + elements.error.hidden = true + syncDialogErrorDescription(elements) +} + +function setModalError (elements: DialogElements, error: unknown, fallbackMessage?: string): void { + elements.error.textContent = formatDisplayError(error, fallbackMessage) + elements.error.setAttribute('aria-hidden', 'false') + elements.error.hidden = false + syncDialogErrorDescription(elements) +} + +function openDialogElement (dialog: HTMLDialogElement): void { + lockDocumentScroll(dialog.ownerDocument) + + if (typeof dialog.showModal === 'function') { + try { + if (!dialog.open) dialog.showModal() + return + } catch { + // Fall back for jsdom or partial dialog implementations. + } + } + + dialog.setAttribute('open', '') +} + +function closeDialogElement (dialog: HTMLDialogElement): void { + try { + if (typeof dialog.close === 'function' && dialog.open) { + dialog.close() + return + } + + dialog.removeAttribute('open') + } finally { + unlockDocumentScroll(dialog.ownerDocument) + } +} + +function isMobileDialogLayout (dom: Document): boolean { + return Boolean(dom.querySelector('[data-layout=\'mobile\']')) +} + +function isFocusableElement (element: HTMLElement): boolean { + if (element.hasAttribute('disabled')) return false + if (element.getAttribute('aria-hidden') === 'true') return false + if (element.getAttribute('hidden') !== null) return false + if (element.tabIndex < 0) return false + return true +} + +function findPreferredInitialFocusTarget (container: ParentNode): HTMLElement | null { + const focusableControls = container.querySelectorAll([ + 'input:not([type="hidden"]):not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + 'solid-ui-select:not([disabled])', + 'solid-ui-combobox:not([disabled])', + '[contenteditable="true"]', + 'button:not([disabled])', + 'solid-ui-button:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(',')) + + for (const match of focusableControls) { + if (match instanceof HTMLElement && isFocusableElement(match)) return match + } + + return null +} + +function focusInitialDialogTarget (dialog: HTMLDialogElement, description: HTMLDivElement, buttons: HTMLDivElement): void { + if (isMobileDialogLayout(dialog.ownerDocument)) { + dialog.tabIndex = -1 + dialog.focus() + return + } + + const initialTarget = findPreferredInitialFocusTarget(description) + if (initialTarget) { + initialTarget.focus() + if (initialTarget instanceof HTMLInputElement || initialTarget instanceof HTMLTextAreaElement) { + initialTarget.select() + } + return + } + + const firstButton = findPreferredInitialFocusTarget(buttons) + if (firstButton) { + firstButton.focus() + } +} + +function collectFormValues (form: HTMLFormElement): InputDialogValues { + const data = new FormData(form) + const values: InputDialogValues = {} + data.forEach((value, key) => { + values[key] = String(value) + }) + return values +} + +function openModal ({ + title, + message, + buttons, + headerAction, + dom +}: { + title?: string, + message?: string | Node, + buttons: DialogButton[], + headerAction?: DialogHeaderAction, + dom: Document +}) { + const dialog = ensureModalDialog(dom) + const elements = getDialogElements(dialog) + + openDialogElement(dialog) + + elements.title.textContent = title || '' + elements.description.innerHTML = '' + if (typeof message === 'string') { + elements.description.textContent = message + } else if (message) { + elements.description.appendChild(message) + } + + elements.buttons.innerHTML = '' + clearModalError(elements) + + return new Promise((resolve) => { + const finish = (value: DialogButtonValue) => { + closeModal(value) + resolve(value) + } + + const requestCancel = () => { + const cancelBtn = getSharedDialogCancelButton(dialog) + if (cancelBtn) { + cancelBtn.click() + return + } + + finish(false) + } + + dialog.oncancel = (event: Event) => { + event.preventDefault() + requestCancel() + } + + elements.headerAction.innerHTML = '' + const resolvedHeaderAction = headerAction || { type: 'close' as const } + if (resolvedHeaderAction.type === 'close') { + const closeButton = dom.createElement('solid-ui-button') as DialogActionControl + closeButton.setAttribute('variant', 'ghost') + closeButton.setAttribute('aria-label', 'Close dialog') + const iconSlot = dom.createElement('span') + iconSlot.setAttribute('slot', 'icon') + render(closeIcon, iconSlot) + closeButton.appendChild(iconSlot) + closeButton.onclick = () => requestCancel() + elements.headerAction.appendChild(closeButton) + } else if (resolvedHeaderAction.type === 'button') { + const actionButton = dom.createElement('solid-ui-button') as DialogActionControl + actionButton.setAttribute('variant', 'tertiary') + actionButton.className = resolvedHeaderAction.className || '' + actionButton.textContent = resolvedHeaderAction.label + actionButton.setAttribute('label', resolvedHeaderAction.label) + actionButton.setAttribute('aria-label', resolvedHeaderAction.ariaLabel || resolvedHeaderAction.label) + actionButton.onclick = async () => { + if (resolvedHeaderAction.onClick) { + await resolvedHeaderAction.onClick() + } + } + elements.headerAction.appendChild(actionButton) + } + + buttons.forEach((btn) => { + const b = dom.createElement('solid-ui-button') as DialogActionControl + b.setAttribute('label', btn.label) + b.setAttribute('variant', btn.primary ? 'primary' : 'secondary') + b.textContent = btn.label + if (btn.primary) { + b.setAttribute('data-dialog-primary', 'true') + } + if (btn.cancel) { + b.setAttribute('data-cancel', 'true') + } + b.addEventListener('click', async () => { + if (btn.beforeClose) { + const shouldClose = await btn.beforeClose() + if (!shouldClose) return + } + finish(btn.value) + }) + elements.buttons.appendChild(b) + }) + + const scheduleInitialFocus = dom.defaultView?.requestAnimationFrame + ? (callback: () => void) => dom.defaultView?.requestAnimationFrame(() => callback()) + : (callback: () => void) => setTimeout(callback, 0) + + scheduleInitialFocus(() => { + focusInitialDialogTarget(dialog, elements.description, elements.buttons) + }) + }) +} + +function closeModal (_result: DialogButtonValue): void { + if (modalDialog) { + const elements = getDialogElements(modalDialog) + clearModalError(elements) + closeDialogElement(modalDialog) + modalDialog.oncancel = null + const headerActionButton = modalDialog.querySelector('#modal-header-action button') as HTMLButtonElement | null + if (headerActionButton) headerActionButton.onclick = null + } +} + +export function closeSharedDialog (): void { + closeModal(null) +} + +export function alertDialog (message: string, title = 'Information', dom: Document) { + return openModal({ + title, + message, + buttons: [{ label: 'OK', value: true, primary: true }], + dom + }) +} + +function updateSavingUI (dialog: HTMLDialogElement, submitLabel: string, isSaving: boolean) { + const elements = getDialogElements(dialog) + const saveButton = getSharedDialogSaveButton(dialog) + const cancelButton = getSharedDialogCancelButton(dialog) + + dialog.classList.toggle('modal--saving', isSaving) + dialog.setAttribute('aria-busy', String(isSaving)) + elements.description.toggleAttribute('inert', isSaving) + + if (isSaving) { + const activeElement = dialog.ownerDocument.activeElement as HTMLElement | null + if (activeElement && dialog.contains(activeElement)) { + activeElement.blur() + } + } + + if (saveButton) { + setDialogActionDisabled(saveButton, isSaving) + saveButton.setAttribute('aria-busy', String(isSaving)) + setDialogActionLabel(saveButton, submitLabel) + } + + if (cancelButton) { + setDialogActionDisabled(cancelButton, isSaving) + } + + elements.savingOverlay.hidden = !isSaving + if (isSaving) { + render(html` + + `, elements.savingOverlay) + } else { + render(html``, elements.savingOverlay) + } +} + +export function setSharedDialogSavingState (dom: Document, isSaving: boolean, submitLabel = 'Saving Changes'): void { + const dialog = ensureModalDialog(dom) + updateSavingUI(dialog, submitLabel, isSaving) +} + +type OpenInputDialogCustom = { + title: string, + dom: Document, + form: HTMLFormElement, + submitLabel?: string, + cancelLabel?: string, + headerAction?: DialogHeaderAction, + hideFooterButtons?: boolean, + onOpen?: () => void, + shouldCloseWithoutSave?: () => Promise | boolean, + validate?: () => Promise | string | null, + onSave?: () => Promise | void, + formatSaveError?: (error: unknown) => string +} + +type InputDialogValues = Record +/* Function below generated by AI Model: GPT-5.3-Codex */ +/* Prompt: write me a reusable function to open a dialog with a form and return the form values on submit. +The function should also support validation and display error messages in the dialog. +It should reuse the OpenModal function for consistency. Done in 2 passes */ +export function openInputDialog (options: OpenInputDialogCustom): Promise { + const submitLabel = options.submitLabel || 'Save Changes' + const cancelLabel = options.cancelLabel || 'Cancel' + const dialog = ensureModalDialog(options.dom) + const elements = getDialogElements(dialog) + + const deriveDefaultFieldLabel = (value: string): string => value + .replace(/^\s*edit\s+/i, '') + .replace(/^\s*add\s+/i, '') + .replace(/^\s*create\s+/i, '') + .trim() + + const ensureFormControlsHaveAccessibleNames = () => { + const titleLabel = deriveDefaultFieldLabel(options.title) + if (!titleLabel) return + + const controls = Array.from(options.form.querySelectorAll([ + 'input:not([type="hidden"])', + 'textarea', + 'select' + ].join(','))) + + if (controls.length !== 1) return + + const [control] = controls + const hasExplicitLabel = control.hasAttribute('aria-label') || control.hasAttribute('aria-labelledby') + const hasNativeLabel = Boolean(control.closest('label')) + if (hasExplicitLabel || hasNativeLabel || control.getAttribute('type') === 'hidden') { + return + } + + if (!control.getAttribute('aria-label')) { + control.setAttribute('aria-label', titleLabel) + } + } + + ensureFormControlsHaveAccessibleNames() + + const handleSubmit = (event: SubmitEvent) => { + event.preventDefault() + const saveButton = getSharedDialogSaveButton(dialog) + saveButton?.click() + } + + options.form.addEventListener('submit', handleSubmit) + + const dialogPromise = openModal({ + title: options.title, + message: options.form, + headerAction: options.headerAction, + buttons: [ + { label: cancelLabel, value: null, cancel: true }, + { + label: submitLabel, + value: 'save', + primary: true, + beforeClose: async () => { + clearModalError(elements) + + if (options.shouldCloseWithoutSave && await options.shouldCloseWithoutSave()) { + return true + } + + if (options.validate) { + const validationMessage = await options.validate() + if (validationMessage) { + setModalError(elements, validationMessage) + return false + } + } + + if (!options.onSave) return true + + try { + updateSavingUI(dialog, submitLabel, true) + + await options.onSave() + return true + } catch (error) { + updateSavingUI(dialog, submitLabel, false) + const fallback = options.formatSaveError ? options.formatSaveError(error) : undefined + setModalError(elements, error, fallback) + return false + } + } + } + ], + dom: options.dom + }) + + const scheduleOpenCallback = options.dom.defaultView?.requestAnimationFrame + ? (callback: () => void) => options.dom.defaultView?.requestAnimationFrame(() => callback()) + : (callback: () => void) => setTimeout(callback, 0) + + if (options.onOpen) { + scheduleOpenCallback(options.onOpen) + } + + elements.buttons.hidden = Boolean(options.hideFooterButtons) + + return dialogPromise + .then((result) => { + if (result !== 'save') return null + return collectFormValues(options.form) + }) + .finally(() => { + elements.buttons.hidden = false + updateSavingUI(dialog, submitLabel, false) + options.form.removeEventListener('submit', handleSubmit) + }) +} diff --git a/src/components/profile-heading/editor/ui/spinner.ts b/src/components/profile-heading/editor/ui/spinner.ts new file mode 100644 index 00000000..78dddede --- /dev/null +++ b/src/components/profile-heading/editor/ui/spinner.ts @@ -0,0 +1,8 @@ +import { html, TemplateResult } from 'lit-html' + +export function createSpinner (): TemplateResult { + return html` + + Saving... + ` +} diff --git a/src/components/profile-heading/editor/utils/errorDisplay.ts b/src/components/profile-heading/editor/utils/errorDisplay.ts new file mode 100644 index 00000000..eaaccc8a --- /dev/null +++ b/src/components/profile-heading/editor/utils/errorDisplay.ts @@ -0,0 +1,15 @@ +function stringifyUnknown (value: unknown): string { + if (typeof value === 'string') return value + if (value instanceof Error) return value.message || value.name + if (value == null) return '' + + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +export function formatDisplayError (error: unknown, fallbackMessage?: string): string { + return (fallbackMessage || stringifyUnknown(error) || 'Something went wrong.').trim() +} diff --git a/src/components/profile-heading/friendActions.ts b/src/components/profile-heading/friendActions.ts new file mode 100644 index 00000000..bf4f5984 --- /dev/null +++ b/src/components/profile-heading/friendActions.ts @@ -0,0 +1,39 @@ +import { authn, store } from 'solid-logic' +import { st, type NamedNode } from 'rdflib' +import { ns } from 'solid-ui' +import { ensureStandardMutationPrefixes } from './editor/sections/shared/rdfMutationHelpers' +import { formatDisplayError } from './editor/utils/errorDisplay' + +export const friendTexts = { + add: 'Add as friend', + exists: 'Already a friend', + success: 'Friend was added!', + duplicate: 'Friend already exists', + loginRequired: 'Current user not found! Not logged in?' +} + +function requireViewer (viewer: NamedNode): void { + if (!authn.currentUser()?.sameTerm(viewer)) { + throw new Error(friendTexts.loginRequired) + } +} + +export async function checkFriend (viewer: NamedNode, subject: NamedNode): Promise { + await store.fetcher.load(viewer) + requireViewer(viewer) + return store.whether(viewer, ns.foaf('knows'), subject, viewer.doc()) > 0 +} + +export async function addFriend (viewer: NamedNode, subject: NamedNode): Promise { + requireViewer(viewer) + if (await checkFriend(viewer, subject)) throw new Error(friendTexts.duplicate) + ensureStandardMutationPrefixes(store) + try { + await store.updater.update([], [st(viewer, ns.foaf('knows'), subject, viewer.doc())]) + } catch (error: unknown) { + if (formatDisplayError(error).includes('Unauthenticated')) { + throw new Error(friendTexts.loginRequired) + } + throw error + } +} diff --git a/src/components/profile-heading/index.ts b/src/components/profile-heading/index.ts new file mode 100644 index 00000000..810ec439 --- /dev/null +++ b/src/components/profile-heading/index.ts @@ -0,0 +1,5 @@ +import ProfileHeading from './ProfileHeading' + +export { ProfileHeading } +export type { ProfileHeadingData, ProfileHeadingEditContext, ProfileHeadingPoint } from './ProfileHeading' +export default ProfileHeading diff --git a/src/components/profile-heading/profileHeadingData.ts b/src/components/profile-heading/profileHeadingData.ts new file mode 100644 index 00000000..1bf592a5 --- /dev/null +++ b/src/components/profile-heading/profileHeadingData.ts @@ -0,0 +1,163 @@ +import { store } from 'solid-logic' +import { getViewerRelation } from '../../utils/viewerUtils' +import { NamedNode, type LiveStore, type Node } from 'rdflib' +import { ns, utils, widgets } from 'solid-ui' +import type { AddressDetails, PointDetails } from './editor/sections/contactInfo/types' +import type { ProfileDetails } from './editor/sections/heading/types' +import type { ViewerMode } from '../../types' + +export async function getViewerMode (subject: NamedNode): Promise { + const relation = getViewerRelation(subject) + if (relation !== 'owner') return relation + + try { + return store.updater?.editable?.(subject.doc()) ? 'owner' : 'anonymous' + } catch (error) { + console.warn('Failed to check profile edit permissions.', error) + return 'anonymous' + } +} + +export function presentProfile (subject: NamedNode, profileStore: LiveStore = store): ProfileDetails { + const doc = subject.doc() + const nickname = profileStore.anyValue(subject, ns.vcard('nickname')) || + profileStore.anyValue(subject, ns.foaf('nick')) || undefined + const dateOfBirth = profileStore.anyValue(subject, ns.vcard('bday')) || undefined + const imageSrc = widgets.findImage(subject) || undefined + const primaryPhone = selectPrimaryPoint(profileStore, subject, ns.vcard('hasTelephone'), doc, 'phone') + const primaryEmail = selectPrimaryPoint(profileStore, subject, ns.vcard('hasEmail'), doc, 'email') + const jobTitle = selectHeadingJobTitle(profileStore, subject, doc) + + const addressNode = profileStore.any(subject, ns.vcard('hasAddress'), null, doc) || undefined + const addressSubject = addressNode as NamedNode | undefined + const primaryAddress: AddressDetails | undefined = addressNode + ? { + entryNode: addressNode, + type: profileStore.any(addressSubject, ns.rdf('type'), null, doc) || undefined, + streetAddress: profileStore.anyValue(addressSubject, ns.vcard('street-address')) || undefined, + locality: profileStore.anyValue(addressSubject, ns.vcard('locality')) || undefined, + region: profileStore.anyValue(addressSubject, ns.vcard('region')) || undefined, + postalCode: profileStore.anyValue(addressSubject, ns.vcard('postal-code')) || undefined, + countryName: profileStore.anyValue(addressSubject, ns.vcard('country-name')) || undefined + } + : undefined + const country = primaryAddress?.countryName + const locality = primaryAddress?.locality + const subjectPronoun = profileStore.anyValue(subject, ns.solid('preferredSubjectPronoun')) || '' + const objectPronoun = profileStore.anyValue(subject, ns.solid('preferredObjectPronoun')) || '' + const pronouns = subjectPronoun ? `${subjectPronoun}${objectPronoun ? `/${objectPronoun}` : ''}` : undefined + + return { + name: utils.label(subject), + nickname, + imageSrc, + dateOfBirth, + jobTitle, + primaryPhone, + primaryEmail, + primaryAddress, + location: country && locality ? `${locality}, ${country}` : country || locality || undefined, + pronouns, + entryNode: subject + } +} + +function selectHeadingJobTitle (profileStore: LiveStore, subject: NamedNode, doc: NamedNode): string | undefined { + const roles = profileStore.each(null, ns.org('member'), subject, doc) + .filter(membership => + !profileStore.holds( + membership as NamedNode, + ns.rdf('type'), + ns.schema('EducationalOccupationalCredential'), + doc + ) + ) + .map(membership => { + const membershipNode = membership as NamedNode + const types = profileStore.each(membershipNode, ns.rdf('type'), null, doc) + const roleType = types.find(type => type.value.startsWith(ns.solid('').value))?.value.split(/[#/]/).pop()?.toLowerCase() + const roleText = profileStore.anyValue(membershipNode, ns.vcard('role'), null, doc) || undefined + const escoRole = profileStore.any(membershipNode, ns.org('role'), null, doc) + const escoRoleName = escoRole + ? profileStore.anyValue(escoRole as NamedNode, ns.schema('name'), null, doc) || undefined + : undefined + const endDate = profileStore.any(membershipNode, ns.schema('endDate'), null, doc) + const title = escoRoleName && roleText + ? `${escoRoleName} - ${roleText}` + : roleText || escoRoleName + + return { + title, + roleType, + isCurrent: !endDate || roleType === 'currentrole' + } + }) + const selected = roles.find(role => role.isCurrent) || + roles.find(role => role.roleType !== 'currentrole' && role.roleType !== 'futurerole') + return selected?.title +} + +function selectPrimaryPoint ( + profileStore: LiveStore, + subject: NamedNode, + predicate: NamedNode, + doc: NamedNode, + kind: 'email' | 'phone' +): PointDetails | undefined { + const candidates: PointDetails[] = [] + const nodes = [ + ...profileStore.statementsMatching(subject, predicate, null, doc), + ...profileStore.statementsMatching(subject, predicate) + ].map(statement => statement.object as Node) + const seen = new Set() + + for (const entryNode of nodes) { + if (seen.has(entryNode.value)) continue + seen.add(entryNode.value) + + const valueNode = resolvePointValue(profileStore, entryNode, doc, kind) + if (!valueNode) continue + + const types = entryNode.termType === 'NamedNode' + ? [ + ...profileStore.each(entryNode as NamedNode, ns.rdf('type'), null, doc), + ...profileStore.each(entryNode as NamedNode, ns.rdf('type')) + ] + : [] + const type = types[0] || (kind === 'email' ? ns.vcard('Internet') : ns.vcard('Voice')) + const candidate = { entryNode, type: type as Node, valueNode } + if (types.some(typeNode => isWorkType(typeNode.value))) return candidate + candidates.push(candidate) + } + + return candidates[0] +} + +function resolvePointValue ( + profileStore: LiveStore, + entryNode: Node, + doc: NamedNode, + kind: 'email' | 'phone' +): Node | undefined { + if (isContactValue(entryNode.value, kind)) return entryNode + if (entryNode.termType !== 'NamedNode') return undefined + + const values = [ + ...profileStore.each(entryNode as NamedNode, ns.vcard('value'), null, doc), + ...profileStore.each(entryNode as NamedNode, ns.vcard('value')) + ] + return values.find(value => isContactValue(value.value, kind)) +} + +function isContactValue (value: string, kind: 'email' | 'phone'): boolean { + const normalized = value.trim() + if (kind === 'email') { + return /^mailto:/i.test(normalized) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) + } + return /^tel:/i.test(normalized) || /^[+()\-\s\d]{5,}$/.test(normalized) +} + +function isWorkType (value: string): boolean { + const normalized = value.trim().toLowerCase() + return normalized === 'work' || normalized.endsWith('#work') || normalized.endsWith('/work') +} diff --git a/src/icons/clock.svg b/src/icons/clock.svg deleted file mode 100644 index ba92e067..00000000 --- a/src/icons/clock.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/icons/comment.svg b/src/icons/comment.svg deleted file mode 100644 index fd7b0d62..00000000 --- a/src/icons/comment.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - \ No newline at end of file diff --git a/src/icons/downArrow.svg b/src/icons/downArrow.svg deleted file mode 100644 index a765b021..00000000 --- a/src/icons/downArrow.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - \ No newline at end of file diff --git a/src/icons/signOut.svg b/src/icons/signOut.svg deleted file mode 100644 index c7c9b7c6..00000000 --- a/src/icons/signOut.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/icons/signUp.svg b/src/icons/signUp.svg deleted file mode 100644 index 82a9fab5..00000000 --- a/src/icons/signUp.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file 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 4b45bb8e..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,36 +8,25 @@ 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') - } - } - - const storagePanes = me ? await getFolderPanesFromURI(me) : [] const storageMenuItems: AccountMenuItem[] = storagePanes.map(pane => ({ label: html` ${pane.label()}`, async onSelected () { - hideNavbar() - if (me) { - outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false) + if (await getCurrentViewer()) { + await navigation.show({ kind: 'storage', pane, source: 'accountMenu' }) } } })) @@ -46,45 +34,35 @@ export async function createHeader (outliner: OutlineManager) { { label: html` Profile`, async onSelected () { - hideNavbar() - if (me) { - const profilePane = await getProfilePaneFromURI(me) - outliner.GotoSubject(sym(window.location.href), true, profilePane, true, undefined, outlineView, false) + const currentViewer = await getCurrentViewer() + if (currentViewer) { + await navigation.show({ kind: 'profile', subject: currentViewer.value, source: 'accountMenu' }) } } }, { label: html` Friends`, async onSelected () { - hideNavbar() - if (me) { - const socialPane = await getSocialPaneFromURI(me) - outliner.GotoSubject(sym(window.location.href), true, socialPane, true, undefined, outlineView, false) + const currentViewer = await getCurrentViewer() + if (currentViewer) { + await navigation.show({ kind: 'friends', subject: currentViewer.value, source: 'accountMenu' }) } } }, ...storageMenuItems, { label: html` Dashboard`, - onSelected () { - hideNavbar() - if (me) { - const pane = outliner.context.session.paneRegistry.byName('home') - if (pane) { - outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false) - } + async onSelected () { + if (await getCurrentViewer()) { + await navigation.show({ kind: 'app', pane: 'home', source: 'accountMenu' }) } } }, { label: html` Preferences`, - onSelected () { - hideNavbar() - if (me) { - const pane = outliner.context.session.paneRegistry.byName('basicPreferences') - if (pane) { - outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false) - } + async onSelected () { + if (await getCurrentViewer()) { + await navigation.show({ kind: 'app', pane: 'basicPreferences', source: 'accountMenu' }) } } }, diff --git a/src/mainPage/index.ts b/src/mainPage/index.ts index 17b22876..a7d2b08b 100644 --- a/src/mainPage/index.ts +++ b/src/mainPage/index.ts @@ -5,12 +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 } from './navbar' -import { getProfilePaneFromURI } from '../utils/paneUtils' -import { isWebIdUri } from '../utils/webIdUtils' +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. @@ -40,38 +41,29 @@ export async function initMainPage ( environment?: RenderEnvironment ) { ensureMainContent() - const outliner = getOutliner(document, environment) - ;(outliner as any)[LAST_RENDER_ENV_KEY] = renderEnvSignature(environment) uri = uri || window.location.href const subject: NamedNode = typeof uri === 'string' ? store.sym(uri) : uri - const historyPaneName = window.history.state?.paneName - const historyPane = historyPaneName - ? paneRegistry.byName(historyPaneName) - : undefined - const initialPane = historyPane ?? - (!historyPaneName && isWebIdUri(subject) - ? await getProfilePaneFromURI(subject) - : undefined) - - outliner.GotoSubject(subject, true, initialPane, true, undefined, undefined, true, false) + // 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) + const navigation = navigationFor(outliner, store) + registerBrowserNavigation(navigation) + await navigation.show({ kind: 'restore', subject: subject.uri }) - const header = await createHeader(outliner) - const navbar = await createNavbar(outliner) + 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]) } export async function refreshUI (outliner: OutlineManager) { const store = outliner?.context?.session?.store - const paneRegistry = outliner?.context?.session?.paneRegistry - const paneName = window.history.state?.paneName - const paneUri = window.history.state?.paneUri - // Panes rendered for a subject other than the page URL (storage/profile/social) - // must be restored from paneUri, since byName() returns the pane without that subject. - const usesOwnSubject = paneName === 'profile' || paneName === 'social' || paneName === 'folder' - const subjectUri = paneUri && usesOwnSubject - ? paneUri - : window.document.location.href - const pane = paneName ? paneRegistry?.byName?.(paneName) : undefined // Only re-run GotoSubject (full pane re-render) when render-relevant // environment fields actually changed since the last render. @@ -80,7 +72,9 @@ export async function refreshUI (outliner: OutlineManager) { const envChanged = currentSignature !== previousSignature if (envChanged && store && typeof outliner?.GotoSubject === 'function') { - outliner.GotoSubject(store.sym(subjectUri), true, pane, true, undefined, undefined, true, false) + // Re-render what the current history entry displays without changing the + // URL or losing that it came from the account menu. + 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 da1f571a..f7607f38 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 { isWebIdUri, loadProfileFromURI } from '../utils/webIdUtils' +import type { NavigationController } from '../navigation/controller' +import { isOwnWebIdProfile, isWebIdUri } from '../utils/webIdUtils' +import { deriveNavbarSelection, isNavbarHidden, 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 { setNavbarHidden } from '../navigation/navbarVisibility' import '~icons/lucide/user' import '~icons/lucide/users' @@ -13,19 +16,29 @@ import '../components/navbar' import type { NavbarMenuItem } from '../components/navbar/Navbar' function showNavbar () { - const navbar = document.querySelector('solid-panes-navbar') - if (navbar) { - navbar.classList.remove('navbar--hidden') - } + setNavbarHidden(isNavbarHidden({ + viaAccountMenu: false, + ownProfile: isOwnWebIdProfile(store.sym(window.location.href)) + })) +} + +let selectPaneInNavbar: ((paneName: string, paneUri?: string) => void) | undefined + +export function selectNavbarPane (paneName: string, paneUri?: string): void { + selectPaneInNavbar?.(paneName, paneUri) } function createNavItem ( label: string, + paneName: string, + paneUri: string, onSelected: () => void, selected = false ): NavbarMenuItem { return { label, + paneName, + paneUri, onSelected () { showNavbar() return onSelected() @@ -34,41 +47,39 @@ function createNavItem ( } } -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', async () => { - const profilePane = await getProfilePaneFromURI(webId) - outliner.GotoSubject(subject, true, profilePane, true, undefined, outlineView) - }, selectedPane === 'profile'), - createNavItem('Friends', 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(), - async () => { - outliner.GotoSubject(subject, true, pane, true, undefined, outlineView) - }, - selectedPane === pane.paneName && - (selectedPaneUri ? selectedPaneUri === pane.subject.value : storagePanes.length === 1) + pane.paneName, + pane.subject.value, + () => navigation.show({ kind: 'storage', pane }), + isNavbarItemSelected( + { paneName: pane.paneName, paneUri: pane.subject.value }, + selection, + context.storagePanes.length + ) ) ) }) @@ -76,25 +87,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) { - existingNavbar.classList.remove('navbar--hidden') + 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) => ({ @@ -123,6 +127,13 @@ export async function createNavbar (outliner: OutlineManager) { renderNavbar() + selectPaneInNavbar = (paneName, paneUri) => { + const item = pickNavbarItem(menuItems, paneName, paneUri) + if (!item) return + setSelectedItem(item) + renderNavbar() + } + const navbar = tmpContainer.firstElementChild as HTMLElement | null if (!navbar) { @@ -135,5 +146,22 @@ export async function createNavbar (outliner: OutlineManager) { document.body.prepend(navbar) } + 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() + + const request = ++rebuildRequest + getPodContext(url).then(next => { + if (request !== rebuildRequest || isSamePod(podContext, next)) 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.css b/src/mainPage/profileHeading.css new file mode 100644 index 00000000..f0aea43e --- /dev/null +++ b/src/mainPage/profileHeading.css @@ -0,0 +1,4 @@ +#profile-heading-mount { + margin-top: 18px; + margin-bottom: 20px; +} diff --git a/src/mainPage/profileHeading.ts b/src/mainPage/profileHeading.ts new file mode 100644 index 00000000..084f8fff --- /dev/null +++ b/src/mainPage/profileHeading.ts @@ -0,0 +1,299 @@ +import { authn, authSession, store } from 'solid-logic' +import { NamedNode } from 'rdflib' +import type { ProfileDetails } from '../components/profile-heading/editor/sections/heading/types' +import type { ProfileHeadingEditContext } from '../components/profile-heading/ProfileHeading' +import { getViewerMode, presentProfile } from '../components/profile-heading/profileHeadingData' +import type { ViewerMode } from '../types' +import { loadProfileFromURI } from '../utils/webIdUtils' +import '../components/profile-heading' +import './profileHeading.css' +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' + +let refreshRequest = 0 +let navigatedListenerRegistered = false +let authListenersRegistered = false +let profileSavedListenerRegistered = false +let authReady: Promise | undefined +let displayedPaneKey = '' +let displayedSubjectUri = '' +let loadingPaneKey = '' +const hideAnimations = new WeakMap() +const showAnimations = new WeakMap() + +function showHeading (mount: HTMLElement): void { + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || + typeof mount.animate !== 'function') return + + const style = getComputedStyle(mount) + const marginTop = style.marginTop || '18px' + const marginBottom = style.marginBottom || '20px' + const animation = mount.animate([ + { height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px', overflow: 'hidden' }, + { + height: `${mount.getBoundingClientRect().height}px`, + opacity: 1, + marginTop, + marginBottom, + overflow: 'hidden' + } + ], { duration: 300, easing: 'ease', fill: 'both' }) + showAnimations.set(mount, animation) + const finish = () => { + if (showAnimations.get(mount) !== animation) return + showAnimations.delete(mount) + animation.cancel() + } + animation.finished.then(finish, (error: unknown) => { + if (showAnimations.get(mount) !== animation) return + console.error('Failed to animate profile heading appearance.', error) + finish() + }) +} + +function cancelHeadingHide (mount: HTMLElement): void { + const animation = hideAnimations.get(mount) + hideAnimations.delete(mount) + animation?.cancel() + mount.inert = false + mount.removeAttribute('aria-hidden') +} + +function hideHeading (mount: HTMLElement): void { + if (hideAnimations.has(mount)) return + + const style = getComputedStyle(mount) + const height = mount.getBoundingClientRect().height + const opacity = style.opacity + const marginTop = style.marginTop + const marginBottom = style.marginBottom + const showing = showAnimations.get(mount) + showAnimations.delete(mount) + showing?.cancel() + + const finish = () => { + mount.hidden = true + mount.replaceChildren() + } + mount.inert = true + mount.setAttribute('aria-hidden', 'true') + + if (mount.hidden || !mount.firstElementChild || + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || + typeof mount.animate !== 'function') { + finish() + return + } + + const animation = mount.animate([ + { + height: `${height}px`, + opacity, + marginTop, + marginBottom, + overflow: 'hidden' + }, + { height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px', overflow: 'hidden' } + ], { duration: 300, easing: 'ease', fill: 'forwards' }) + hideAnimations.set(mount, animation) + animation.finished.then(() => { + if (hideAnimations.get(mount) !== animation) return + finish() + hideAnimations.delete(mount) + animation.cancel() + }, (error: unknown) => { + if (hideAnimations.get(mount) !== animation) return + console.error('Failed to animate profile heading disappearance.', error) + finish() + hideAnimations.delete(mount) + animation.cancel() + }) +} + +function refreshAfterAuthChange (): void { + refreshProfileHeading(true).catch((error: unknown) => { + console.error('Failed to refresh profile heading after an authentication change.', error) + }) +} + +function ensureMountPoint (): HTMLElement | null { + const mainContent = document.getElementById('MainContent') + if (!mainContent) return null + + let mount = document.getElementById(PROFILE_HEADING_MOUNT_ID) + if (!mount) { + mount = document.createElement('div') + mount.id = PROFILE_HEADING_MOUNT_ID + mount.setAttribute('aria-label', 'Profile heading') + } + + const navbar = document.querySelector('solid-panes-navbar') + const outlineView = document.getElementById('OutlineView') + const insertionPoint = navbar?.parentNode === mainContent + ? navbar + : outlineView?.parentNode === mainContent + ? outlineView + : null + if (insertionPoint && mount.nextSibling !== insertionPoint) { + mainContent.insertBefore(mount, insertionPoint) + } else if (!insertionPoint && mount.parentNode !== mainContent) { + mainContent.appendChild(mount) + } + return mount +} + +export async function refreshProfileHeading (force = false): Promise { + await authReady + + const pod = await getPodContext(window.location.href) + const mount = ensureMountPoint() + if (!mount) return + + if (force) { + refreshRequest++ + displayedPaneKey = '' + loadingPaneKey = '' + } + + 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 (decision.mode === 'none') { + refreshRequest++ + loadingPaneKey = '' + displayedPaneKey = '' + displayedSubjectUri = '' + hideHeading(mount) + return + } + + const { subjectUri } = decision + const isProfilePane = decision.mode === 'full' + cancelHeadingHide(mount) + const paneKey = `${decision.paneName}:${subjectUri}` + if (paneKey === displayedPaneKey || paneKey === loadingPaneKey) return + + const request = ++refreshRequest + loadingPaneKey = paneKey + let subject: NamedNode + let profileData: ProfileDetails + let canEdit: boolean + let viewerMode: ViewerMode + try { + subject = await loadProfileFromURI(store.sym(subjectUri)) + profileData = presentProfile(subject, store) + viewerMode = isProfilePane ? await getViewerMode(subject) : 'anonymous' + canEdit = viewerMode === 'owner' + } catch (error) { + if (request === refreshRequest) loadingPaneKey = '' + throw error + } + if (request !== refreshRequest) return + + const heading = (mount.querySelector(PROFILE_HEADING_TAG) || + document.createElement(PROFILE_HEADING_TAG)) as HTMLElement & { + profileData?: ProfileDetails + editContext?: ProfileHeadingEditContext + friendSubject?: NamedNode + compact?: boolean + updateComplete?: Promise + } + heading.profileData = profileData + const viewer = authn.currentUser() + heading.friendSubject = isProfilePane && viewer && !viewer.sameTerm(subject) + ? subject + : undefined + heading.editContext = canEdit + ? { + subject, + profileData, + viewerMode, + onSaved: () => refreshProfileHeading(true) + } + : undefined + heading.compact = !isProfilePane + const appearing = mount.hidden || !mount.firstElementChild + mount.hidden = false + if (heading.parentNode !== mount) mount.replaceChildren(heading) + displayedPaneKey = paneKey + displayedSubjectUri = subject.value + loadingPaneKey = '' + if (appearing) { + await heading.updateComplete + if (request === refreshRequest && mount.isConnected && !mount.hidden && !hideAnimations.has(mount)) { + showHeading(mount) + } + } +} + +/** 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() + + if (!profileSavedListenerRegistered) { + document.addEventListener('profile-pane-saved', (event) => { + if (!(event instanceof CustomEvent) || event.detail?.subjectUri !== displayedSubjectUri) return + + const heading = document.querySelector(`#${PROFILE_HEADING_MOUNT_ID} ${PROFILE_HEADING_TAG}`) + if (heading && event.detail.profileData) { + refreshRequest++ + loadingPaneKey = '' + heading.profileData = event.detail.profileData + if (heading.editContext) { + heading.editContext = { ...heading.editContext, profileData: event.detail.profileData } + } + return + } + + refreshProfileHeading(true).catch((error: unknown) => { + console.error('Failed to refresh profile heading after saving profile information.', error) + }) + }) + profileSavedListenerRegistered = true + } + + const readiness = authReady ?? authn.checkUser().then(() => undefined) + authReady = readiness + + if (!authListenersRegistered) { + authSession.events.on('login', refreshAfterAuthChange) + authSession.events.on('sessionRestore', refreshAfterAuthChange) + authSession.events.on('logout', refreshAfterAuthChange) + authSession.events.on('sessionChange', refreshAfterAuthChange) + authSession.events.on('identityReplaced', refreshAfterAuthChange) + authListenersRegistered = true + } + + // 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) + }) + }) + navigatedListenerRegistered = true + } + + readiness.then(() => refreshProfileHeading(true)).catch((error: unknown) => { + console.error('Failed to refresh profile heading.', error) + }) +} 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..e122c4bc --- /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 on the viewer's own profile, nor over account-menu content. */ +export function isNavbarHidden (input: { viaAccountMenu: boolean, ownProfile: boolean }): boolean { + return input.viaAccountMenu || input.ownProfile +} + +// 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..30c3da16 --- /dev/null +++ b/src/navigation/navbarVisibility.ts @@ -0,0 +1,6 @@ +const NAVBAR_HIDDEN_CLASS = 'navbar--hidden' + +/** Show or hide the navbar, if the page has one. */ +export function setNavbarHidden (hidden: boolean): void { + document.querySelector('solid-panes-navbar')?.classList.toggle(NAVBAR_HIDDEN_CLASS, hidden) +} 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 fe5029bd..19ecea07 100644 --- a/src/outline/manager.js +++ b/src/outline/manager.js @@ -12,7 +12,13 @@ import { outlineIcons } from './outlineIcons.js' // @@ chec import { UserInput } from './userInput.js' import * as queryByExample from './queryByExample.js' import { loadContainerRepresentation } from '../utils/podUtils' -import { isWebIdUri } from '../utils/webIdUtils' +import { isOwnWebIdProfile, isWebIdUri } from '../utils/webIdUtils' +import { deriveExplorerHeader, isNavbarHidden } from '../navigation/chrome' +import { isPageInViewerPod } from '../navigation/podContext' +import { nextHistoryState } from '../navigation/state' +import { announceNavigation } from '../navigation/events' +import { resolveSource } from '../navigation/options' +import { setNavbarHidden } from '../navigation/navbarVisibility' import '../components/file-explorer-header' export default function (context) { @@ -300,15 +306,8 @@ export default function (context) { return getOrCreateContainer('OutlineView', 'Resource browser') } - function getNavbarElement () { - return document.querySelector('solid-panes-navbar') - } - - function showSolidPanesNavbar () { - const navbar = getNavbarElement() - if (navbar) { - navbar.classList.remove('navbar--hidden') - } + function showSolidPanesNavbar (subject) { + setNavbarHidden(isNavbarHidden({ viaAccountMenu: false, ownProfile: isOwnWebIdProfile(subject) })) } /** @@ -470,7 +469,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) - provider.showHeader = !isRootResource && !isWebIdUri(subject) + 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)) @@ -1832,6 +1838,58 @@ export default function (context) { const subject = kb.sym(uri) this.GotoSubject(subject, true) } + let openPathRequest = 0 + + // Opens, one level at a time, the containers from `root` down to `target` (the last + // one being the container or resource itself) inside the rows of a rendered container. + function openPathWithin (table, root, target) { + const request = ++openPathRequest + const rootUri = root.uri + const targetUri = target.uri.split('#')[0] + if (!rootUri.endsWith('/') || !targetUri.startsWith(rootUri) || targetUri === rootUri) return + + const steps = [] + const parts = targetUri.slice(rootUri.length).split('/') + let uri = rootUri + parts.forEach((part, index) => { + if (!part) return + uri += index < parts.length - 1 ? part + '/' : part + steps.push(uri) + }) + + const findRow = stepUri => Array.from(table.querySelectorAll('td.obj[about]')) + .find(td => td.getAttribute('about') === kb.sym(stepUri).toNT()) + + // The rows only exist once their container has been fetched and rendered + const whenRow = stepUri => new Promise(resolve => { + const existing = findRow(stepUri) + if (existing) return resolve(existing) + + const timer = setTimeout(() => { observer.disconnect(); resolve(null) }, 10000) + const observer = new MutationObserver(() => { + const row = findRow(stepUri) + if (!row && request === openPathRequest) return + clearTimeout(timer) + observer.disconnect() + resolve(row) + }) + observer.observe(table, { childList: true, subtree: true }) + }) + + ;(async () => { + for (const stepUri of steps) { + const row = await whenRow(stepUri) + if (!row || request !== openPathRequest) return + if (!row.firstChild || row.firstChild.nodeName !== 'TABLE') { + outlineExpand(row, kb.sym(stepUri)) + } + if (stepUri === steps[steps.length - 1] && typeof row.scrollIntoView === 'function') { + row.scrollIntoView({ block: 'nearest' }) + } + } + })().catch(err => UI.log.warn('outline: could not open ' + target.uri + ' inside ' + rootUri + ': ' + err)) + } + this.GotoURIinit = function (uri) { const subject = kb.sym(uri) this.GotoSubject(subject) @@ -1841,16 +1899,24 @@ export default function (context) { @param subject -- RDF term for the thing to be presented @param expand -- flag -- open the subject rather than keep folded closed - @param pane -- optional -- pane to be used for expanded display + @param pane -- optional -- pane to be used for expanded display. When the pane has its own + subject (the profile, friends or storage of a person), that subject is displayed + instead of `subject`, which only stays the page URL; for a storage the container + or resource `subject` points at is opened inside it @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 + @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() + showSolidPanesNavbar(subject) } table = table || outlineContainer // if does not exist create a compatible host in the current shell @@ -1859,25 +1925,34 @@ export default function (context) { table.style.width = '100%' } + // The URL and history always follow `subject`; what is displayed may differ: + // a pane with its own subject (profile, friends or storage of a person) is displayed + // for that subject, not for the resource the page URL points at. + const displayedSubject = pane && pane.subject && pane.subject.uri + ? pane.subject + : subject + function GotoSubjectDefault () { const tr = dom.createElement('tr') tr.classList.add('outlineRow1') tr.style.verticalAlign = 'top' table.appendChild(tr) - const td = thisOutline.outlineObjectTD(subject, undefined, tr) + const td = thisOutline.outlineObjectTD(displayedSubject, undefined, tr) tr.appendChild(td) return td } const td = GotoSubjectDefault() - if (solo) setUrlBarAndTitle(subject) // dom.title = UI.utils.label(subject) // 'Tabulator: '+ No need to advertize + if (solo) setUrlBarAndTitle(displayedSubject) // dom.title = UI.utils.label(subject) // 'Tabulator: '+ No need to advertize if (expand) { - outlineExpand(td, subject, { + outlineExpand(td, displayedSubject, { pane, - solo + solo, + headerOnlyInViewerPod: !showNavbar || !!(pane && pane.subject && pane.subject.uri) }) + if (solo && displayedSubject !== subject) openPathWithin(table, displayedSubject, subject) } if ( @@ -1887,16 +1962,19 @@ 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 } + const leavingPage = document.location.href !== subject.uri + 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) if (currentUrl.origin === targetUrl.origin) { - if (document.location.href !== subject.uri) { + if (leavingPage) { dom.defaultView.history.pushState(stateObj, subject.uri, subject.uri) } else if (JSON.stringify(currentState) !== JSON.stringify(stateObj)) { dom.defaultView.history.replaceState(stateObj, subject.uri, subject.uri) @@ -1906,6 +1984,8 @@ export default function (context) { } } + if (solo) announceNavigation(dom) + return subject } diff --git a/src/social/socialPane.ts b/src/social/socialPane.ts index f04c5db7..954508d9 100644 --- a/src/social/socialPane.ts +++ b/src/social/socialPane.ts @@ -16,14 +16,12 @@ import { DataBrowserContext } from 'pane-registry' import { locationIcon } from './icons' import { createAllFriendsSection, - createHeaderSection, FriendRowRenderers, createMutualSection, - createRequestsSection, - HeaderControls, - SocialHeaderElement + createRequestsSection } from './socialSections' -import type { ViewerMode } from './socialSections' +import type { ViewerMode } from '../types' +import { getViewerRelation } from '../utils/viewerUtils' import { FriendshipTriage, loadFriendshipTriage, triageFriends } from './triage' function applyEnvironmentAttributes ( @@ -225,8 +223,7 @@ export const socialPane = { const mutualConnections = me && !thisIsYou ? uniqueNodes(common(uniqueFriends, myFriends)).filter(friend => !kb.sameThings(friend, me)) : [] - const mutualFriendCount = me && !thisIsYou ? mutualConnections.length : null - const viewerMode = getViewerMode(s, me) + const viewerMode = getViewerRelation(s, me) // Do I have a public profile document? let profile: NamedNode | null = null // This could be SPARQL { ?me foaf:primaryTopic [ a foaf:PersonalProfileDocument ] } @@ -320,37 +317,10 @@ export const socialPane = { } // me is defined // End of you and s - const shouldShowHeaderAddFriendAction = (mode: ViewerMode) => { - return mode === 'authenticated' && !thisIsYou && !outgoing - } - const canEditOwnedProfile = viewerMode === 'owner' && editable const canManageRelationship = viewerMode === 'authenticated' && !thisIsYou && editable && Boolean(profile) const canModifyFriendsTab = canEditOwnedProfile - let headerControls: HeaderControls = { - canEdit: canEditOwnedProfile, - viewerMode, - showAddFriendAction: shouldShowHeaderAddFriendAction(viewerMode) - } - - const header = createHeaderSection(context, s, headerControls, { - friendCount: uniqueFriends.length, - mutualFriendCount, - onSelectFriends: function () { - setActivePanel('all-friends') - }, - onSelectMutual: typeof mutualFriendCount === 'number' - ? function () { - setActivePanel('mutual') - } - : undefined - }, function () { - return selectProfileData(context, s) - }) - header.classList.add('social-pane__header-section', 'flex-column') - socialPane.prepend(header) - // div.appendChild(dom.createTextNode(plural(friends.length, 'acquaintance') +'. ')) // ///////////////////////////////////////////// Main block @@ -547,11 +517,6 @@ export const socialPane = { refreshRelationshipUi = function () { rebuildMutualSection() - headerControls = { - ...headerControls, - showAddFriendAction: shouldShowHeaderAddFriendAction(headerControls.viewerMode) - } - ;(header as SocialHeaderElement).refreshSocialHeader?.(headerControls) requestsTriage = triageFriends(kb, s) requestsSection.refreshRequests(requestsTriage) setActivePanel(activePanel) @@ -626,26 +591,11 @@ export const socialPane = { authn.checkUser() .then(webId => { - const confirmedViewerMode = getViewerMode(s, webId) + const confirmedViewerMode = getViewerRelation(s, webId) applyViewerMode(confirmedViewerMode) - const confirmedCanEditOwnedProfile = confirmedViewerMode === 'owner' && editable - headerControls = { - ...headerControls, - canEdit: confirmedCanEditOwnedProfile, - viewerMode: confirmedViewerMode, - showAddFriendAction: shouldShowHeaderAddFriendAction(confirmedViewerMode) - } - ;(header as SocialHeaderElement).refreshSocialHeader?.(headerControls) }) .catch(() => { applyViewerMode('anonymous') - headerControls = { - ...headerControls, - canEdit: false, - viewerMode: 'anonymous', - showAddFriendAction: false - } - ;(header as SocialHeaderElement).refreshSocialHeader?.(headerControls) }) return socialPane @@ -833,20 +783,3 @@ export function selectProfileData (context: DataBrowserContext, subject: NamedNo pronouns } } - -export type { ViewerMode } from './socialSections' - -function getViewerMode (subject: NamedNode, currentUser: unknown = authn.currentUser()): ViewerMode { - const currentUserUri = - typeof currentUser === 'string' - ? currentUser - : typeof currentUser === 'object' && currentUser !== null - ? ((currentUser as { value?: string, uri?: string }).value || - (currentUser as { value?: string, uri?: string }).uri || - null) - : null - let mode: ViewerMode = 'anonymous' - if (currentUserUri === subject.value) mode = 'owner' - if (currentUserUri && currentUserUri !== subject.value) mode = 'authenticated' - return mode -} diff --git a/src/social/socialSections.ts b/src/social/socialSections.ts index d6bf3aaa..17cc8b88 100644 --- a/src/social/socialSections.ts +++ b/src/social/socialSections.ts @@ -5,8 +5,7 @@ import { ns, utils, widgets } from 'solid-ui' import { appendProfileLinks } from './editProfileDetails' import { locationIcon, personInCircleIcon as personInCircleIconSvg } from './icons' import { FriendshipTriage } from './triage' - -export type ViewerMode = 'owner' | 'authenticated' | 'anonymous' +import type { ViewerMode } from '../types' export type HeaderControls = { canEdit: boolean, diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..0f263262 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,3 @@ +export type ViewerMode = 'owner' | 'authenticated' | 'anonymous' + +export type Layout = 'mobile' | 'desktop' diff --git a/src/utils/viewerUtils.ts b/src/utils/viewerUtils.ts new file mode 100644 index 00000000..50cc7484 --- /dev/null +++ b/src/utils/viewerUtils.ts @@ -0,0 +1,16 @@ +import { authn } from 'solid-logic' +import type { NamedNode } from 'rdflib' +import type { ViewerMode } from '../types' + +// Long-lived UI (e.g. the header) outlives logins, so resolve the viewer on demand. +export async function getCurrentViewer (): Promise { + return authn.currentUser() ?? await authn.checkUser() +} + +// Relation of a viewer to a subject, without checking write access. +export function getViewerRelation (subject: NamedNode, viewer: unknown = authn.currentUser()): ViewerMode { + const { value, uri } = (typeof viewer === 'object' && viewer !== null ? viewer : {}) as { value?: string, uri?: string } + const viewerUri = typeof viewer === 'string' ? viewer : value || uri || null + if (!viewerUri) return 'anonymous' + return viewerUri === subject.value ? 'owner' : 'authenticated' +} diff --git a/src/utils/webIdUtils.ts b/src/utils/webIdUtils.ts index a961dcb5..1f7af259 100644 --- a/src/utils/webIdUtils.ts +++ b/src/utils/webIdUtils.ts @@ -5,10 +5,15 @@ import { IndexedFormula, NamedNode, sym } from 'rdflib' import { ns } from 'solid-ui' -import { store } from 'solid-logic' +import { authn, store } from 'solid-logic' const DEFAULT_PROFILE_PATH = 'profile/card#me' +export function isOwnWebIdProfile (subject: NamedNode): boolean { + const viewer = authn.currentUser() + return Boolean(viewer && (viewer.sameTerm(subject) || viewer.doc().sameTerm(subject))) +} + export async function loadProfileFromURI ( uri: NamedNode ): Promise { diff --git a/test/unit/components/fileExplorerProvider.test.ts b/test/unit/components/fileExplorerProvider.test.ts new file mode 100644 index 00000000..b8141103 --- /dev/null +++ b/test/unit/components/fileExplorerProvider.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { store } from 'solid-logic' +import type { PaneDefinition } from 'pane-registry' + +vi.mock('../../../src/utils/podUtils', async (importOriginal) => ({ + ...await importOriginal(), + fetchResourceMetadata: async () => ({ modified: undefined, isPublic: false, canEdit: false, aclUri: undefined }) +})) + +import '../../../src/components/file-explorer-header' + +afterEach(() => { + document.body.replaceChildren() +}) + +type Provider = HTMLElement & { + context: unknown + subjectUri: string + pane: Pick + showHeader: boolean + updateComplete: Promise +} + +async function renderProvider (paneName: string, showHeader = true) { + const provider = document.createElement('file-explorer-provider') as Provider + provider.context = { dom: document, session: { store } } + provider.subjectUri = `${window.location.origin}/container/` + provider.pane = { name: paneName } + provider.showHeader = showHeader + document.body.appendChild(provider) + await provider.updateComplete + return provider +} + +describe('file explorer provider', () => { + it.each(['profile', 'social'])('does not render the file explorer header for the %s pane', async (paneName) => { + const provider = await renderProvider(paneName) + + expect(provider.shadowRoot!.querySelector('file-explorer-header')).toBeNull() + expect(provider.shadowRoot!.querySelector('slot.pane')).not.toBeNull() + }) + + it.each(['folder', 'source'])('renders the file explorer header for the %s pane', async (paneName) => { + const provider = await renderProvider(paneName) + + expect(provider.shadowRoot!.querySelector('file-explorer-header')).not.toBeNull() + }) + + it('still honours showHeader being off', async () => { + const provider = await renderProvider('folder', false) + + expect(provider.shadowRoot!.querySelector('file-explorer-header')).toBeNull() + }) + + it('removes the header when the displayed pane changes to the profile pane', async () => { + const provider = await renderProvider('folder') + expect(provider.shadowRoot!.querySelector('file-explorer-header')).not.toBeNull() + + provider.pane = { name: 'profile' } + await provider.updateComplete + + expect(provider.shadowRoot!.querySelector('file-explorer-header')).toBeNull() + }) +}) diff --git a/test/unit/components/navbar.test.ts b/test/unit/components/navbar.test.ts index ae5a8ba8..ac71794d 100644 --- a/test/unit/components/navbar.test.ts +++ b/test/unit/components/navbar.test.ts @@ -1,9 +1,29 @@ import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' import '../../../src/components/navbar/Navbar' import type { NavbarMenuItem } from '../../../src/components/navbar/Navbar' describe('Navbar', () => { + it('owns its hover shadow and stacking styles independently of pane styles', () => { + const sheet = new CSSStyleSheet() + sheet.replaceSync(readFileSync('src/components/navbar/Navbar.styles.css', 'utf8')) + const rules = Array.from(sheet.cssRules).filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) + const host = rules.find(rule => rule.selectorText === ':host') + const hiddenHost = rules.find(rule => rule.selectorText === ':host(.navbar--hidden)') + const navbar = rules.find(rule => rule.selectorText === '.navbar') + const hover = rules.find(rule => rule.selectorText === '.navbar:hover') + + expect(host?.style.getPropertyValue('position')).toBe('relative') + expect(host?.style.getPropertyValue('z-index')).toBe('1') + expect(host?.style.getPropertyValue('flex-shrink')).toBe('0') + expect(host?.style.getPropertyValue('margin-bottom')).toBe('20px') + expect(hiddenHost?.style.getPropertyValue('display')).toBe('none') + expect(hiddenHost?.style.getPropertyPriority('display')).toBe('important') + expect(navbar?.style.getPropertyValue('transition')).toBe('box-shadow 0.2s ease') + expect(hover?.style.getPropertyValue('box-shadow')).toBe('0 8px 10px rgba(124, 77, 255, 0.1)') + }) + it('emits solid-ui-select and lets the parent handle async onSelected failures', async () => { const error = new Error('pane load failed') const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/test/unit/mainPage/header.test.ts b/test/unit/mainPage/header.test.ts new file mode 100644 index 00000000..bbdecbdb --- /dev/null +++ b/test/unit/mainPage/header.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { authn } from 'solid-logic' +import { sym } from 'rdflib' +import type { NavigationController } from '../../../src/navigation/controller' + +const { getFolderPanes } = vi.hoisted(() => ({ getFolderPanes: vi.fn() })) + +vi.mock('../../../src/utils/paneUtils', () => ({ getFolderPanesFromURI: getFolderPanes })) + +import { createHeader } from '../../../src/mainPage/header' + +afterEach(() => { + vi.restoreAllMocks() + vi.resetAllMocks() + document.body.replaceChildren() +}) + +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, storages: object[] = []) { + const show = vi.fn().mockResolvedValue(undefined) + vi.spyOn(authn, 'currentUser').mockReturnValue(currentUser) + vi.spyOn(authn, 'checkUser').mockResolvedValue(checkUser) + getFolderPanes.mockResolvedValue(storages) + const header = await createHeader({ show } as NavigationController) as HTMLElement & { menuItems: MenuItem[] } + return { show, header, items: header.menuItems } + } + + 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(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] + ])('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 items[0].onSelected?.() + await items[1].onSelected?.() + + expect(show.mock.calls.map(call => call[0].subject)).toEqual([viewer.value, viewer.value]) + }) + + it('does nothing when nobody is logged in', async () => { + const { show, items } = await setup(null, null) + + for (const item of items) await item.onSelected?.() + + expect(show).not.toHaveBeenCalled() + }) + + it('is created once', async () => { + const { header, show } = await setup(viewer) + + expect(await createHeader({ show } as NavigationController)).toBe(header) + }) +}) diff --git a/test/unit/mainPage/index.test.ts b/test/unit/mainPage/index.test.ts new file mode 100644 index 00000000..de044b47 --- /dev/null +++ b/test/unit/mainPage/index.test.ts @@ -0,0 +1,329 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { store } from 'solid-logic' + +const { + getOutliner, byName, getProfilePane, getSocialPane, getFolderPane, refreshHeading, selectNavbarPane, isWebId +} = vi.hoisted(() => ({ + getOutliner: vi.fn(), + byName: vi.fn(), + getProfilePane: vi.fn(), + getSocialPane: vi.fn(), + getFolderPane: vi.fn(), + refreshHeading: vi.fn(), + selectNavbarPane: vi.fn(), + isWebId: vi.fn() +})) + +vi.mock('../../../src/index', () => ({ getOutliner })) +vi.mock('pane-registry', () => ({ byName })) +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', () => ({ + getProfilePaneFromURI: getProfilePane, + getSocialPaneFromURI: getSocialPane, + getFolderPaneForStorage: getFolderPane +})) +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', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + navigationListeners[type] = listener + } + } +}) + +afterEach(() => { + vi.resetAllMocks() + document.body.replaceChildren() + window.history.replaceState({}, '', '/') +}) + +const origin = window.location.origin +const environment = { + layout: 'desktop', + layoutPreference: 'auto', + theme: 'light', + inputMode: 'pointer', + viewport: { width: 1280, height: 800 } +} +const urlProfilePane = { + name: 'profile', + paneName: 'profile', + subject: store.sym('https://other.example/profile/card#me') +} + +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, options) => { + const next = { ...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) + }) + getOutliner.mockReturnValue({ GotoSubject }) + getProfilePane.mockResolvedValue(urlProfilePane) + byName.mockReturnValue({ name: 'folder' }) + isWebId.mockReturnValue(webId) + window.history.replaceState(state, '', webId ? '/other/profile/card#me' : '/other/storage/file.ttl') + return GotoSubject +} + +describe('main page load', () => { + it('loads the URL\'s profile instead of the pane the account menu showed', async () => { + const GotoSubject = setup({ + paneName: 'folder', + paneUri: `${origin}/storage/`, + viaAccountMenu: true, + other: 'kept' + }) + + await initMainPage(store) + + expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href)) + expect(byName).not.toHaveBeenCalled() + expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane) + expect(window.location.pathname).toBe('/other/profile/card') + }) + + it.each([ + ['social', 'https://other.example/profile/card#me'], + ['folder', 'https://other.example/storage/'] + ])('always shows the profile when a WebID is reloaded after the %s pane was selected', async (paneName, paneUri) => { + const GotoSubject = setup({ paneName, paneUri, other: 'kept' }) + + await initMainPage(store) + + expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href)) + expect(byName).not.toHaveBeenCalled() + expect(getSocialPane).not.toHaveBeenCalled() + expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane) + expect(window.history.state).toEqual({ + paneName: 'profile', + paneUri: urlProfilePane.subject.value, + other: 'kept' + }) + }) + + 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).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 () => { + const GotoSubject = setup({ + paneName: 'folder', + paneUri: `${origin}/storage/`, + viaAccountMenu: true + }, false) + + await initMainPage(store) + + expect(byName).not.toHaveBeenCalled() + expect(GotoSubject.mock.calls[0][2]).toBeUndefined() + expect(window.history.state).toEqual({}) + }) +}) + +describe('browser navigation without a reload', () => { + async function setupLoaded (state: object = {}, webId = true) { + const GotoSubject = setup(state, webId) + await initMainPage(store) + GotoSubject.mockClear() + refreshHeading.mockClear() + getProfilePane.mockClear() + selectNavbarPane.mockClear() + return GotoSubject + } + + function enter (overrides: object = {}) { + const intercept = vi.fn() + navigationListeners.navigate({ + canIntercept: true, + userInitiated: true, + navigationType: 'replace', + destination: { url: window.location.href }, + intercept, + ...overrides + }) + return intercept + } + + function traverse (navigationType = 'traverse') { + navigationListeners.currententrychange({ navigationType }) + } + + it('shows the URL\'s profile, navbar selection and heading when Enter is pressed on the current URL', async () => { + const GotoSubject = await setupLoaded() + window.history.replaceState({ + paneName: 'folder', + paneUri: `${origin}/storage/`, + viaAccountMenu: true + }, '', window.location.href) + + for (const overrides of [ + { userInitiated: false }, + { navigationType: 'push' }, + { canIntercept: false }, + { destination: { url: `${origin}/elsewhere` } } + ]) { + expect(enter(overrides)).not.toHaveBeenCalled() + } + + const intercept = enter() + expect(intercept).toHaveBeenCalledOnce() + await intercept.mock.calls[0][0].handler() + + 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 }) + expect(refreshHeading).toHaveBeenCalledWith(true) + }) + + 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) + + const intercept = enter() + expect(intercept).toHaveBeenCalledOnce() + await intercept.mock.calls[0][0].handler() + + expect(getFolderPane).not.toHaveBeenCalled() + expect(GotoSubject.mock.calls[0][2]).toBeUndefined() + expect(window.history.state).toEqual({}) + expect(selectNavbarPane).not.toHaveBeenCalled() + expect(refreshHeading).toHaveBeenCalledWith(true) + }) + + 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() + window.history.replaceState({ ...saved, other: 'kept' }, '', window.location.href) + + traverse() + await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(true)) + + 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 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(true)) + + 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 () => { + const GotoSubject = await setupLoaded() + + traverse('replace') + traverse('push') + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(GotoSubject).not.toHaveBeenCalled() + }) + + it('only renders the latest when navigating quickly', async () => { + const GotoSubject = await setupLoaded() + let resolveFirst!: (pane: unknown) => void + getProfilePane.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve })) + traverse() + await vi.waitFor(() => expect(getProfilePane).toHaveBeenCalledOnce()) + + traverse() + await vi.waitFor(() => expect(GotoSubject).toHaveBeenCalledOnce()) + 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('keeps the URL and account-menu view when the render environment changes', 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) + const pageUrl = window.location.href + const outliner = getOutliner() + outliner.context = { session: { store }, environment: { ...environment, layout: 'mobile' } } + + await refreshUI(outliner) + + const [subject, , pane, , , , options] = GotoSubject.mock.calls[0] + expect(subject.value).toBe(pageUrl) + expect(pane).toBe(storagePane) + 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') + + await refreshUI(outliner) + expect(GotoSubject).toHaveBeenCalledOnce() + }) + + it('falls back to popstate when the Navigation API is unavailable', async () => { + Reflect.deleteProperty(window, 'navigation') + vi.resetModules() + const { initMainPage: initWithoutNavigationApi } = await import('../../../src/mainPage') + const GotoSubject = setup({}) + await initWithoutNavigationApi(store) + GotoSubject.mockClear() + window.history.replaceState({}, '', window.location.href) + + window.dispatchEvent(new PopStateEvent('popstate')) + await vi.waitFor(() => expect(GotoSubject).toHaveBeenCalledOnce()) + expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane) + }) +}) diff --git a/test/unit/mainPage/navbar.test.ts b/test/unit/mainPage/navbar.test.ts new file mode 100644 index 00000000..19502786 --- /dev/null +++ b/test/unit/mainPage/navbar.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { authn } from 'solid-logic' +import { sym } from 'rdflib' +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' + +const { loadProfile } = vi.hoisted(() => ({ loadProfile: vi.fn() })) + +vi.mock('../../../src/utils/paneUtils', () => ({ + getFolderPanesFromURI: vi.fn(async () => []) +})) + +vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({ + ...await importOriginal(), + 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([]) + document.body.replaceChildren() + window.history.replaceState({}, '', '/') +}) + +describe('page navbar visibility', () => { + 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(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] } + const selected = () => navbar.navbarItems.filter(item => item.selected).map(item => item.paneName) + expect(selected()).toEqual(['social']) + + selectNavbarPane('profile') + expect(selected()).toEqual(['profile']) + + selectNavbarPane('unknown') + expect(selected()).toEqual(['profile']) + }) + + 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(folderPane)) + window.history.replaceState({}, '', '/other/profile/card#me') + + 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) + expect(selected()).toEqual([storages[1].value]) + + selectNavbarPane('folder', `${window.location.origin}/unknown/`) + expect(selected()).toEqual([storages[0].value]) + }) + + 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(folderPane)) + window.history.replaceState({ + paneName: 'folder', + paneUri: storages[1].value + }, '', subject.value) + + 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.selected)).toEqual([false, true]) + }) + + it.each(['/profile/card#me', '/profile/card'])('hides a new navbar on the own profile URL %s', async (url) => { + vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`)) + window.history.replaceState({}, '', url) + + const navbar = await createNavbar(navigation) + + expect(navbar).toHaveClass('navbar--hidden') + }) + + it.each([ + ['/profile/card#me', false], + ['/other/profile/card#me', true], + ['/storage/file.ttl', true], + ['/profile/card#someone-else', true] + ])('keeps the navbar visible for %s when logged in: %s', async (url, loggedIn) => { + vi.spyOn(authn, 'currentUser').mockReturnValue( + loggedIn ? sym(`${window.location.origin}/profile/card#me`) : null + ) + window.history.replaceState({}, '', url) + + const navbar = await createNavbar(navigation) + + expect(navbar).not.toHaveClass('navbar--hidden') + }) + + it('updates an existing navbar when moving to and from the own profile', async () => { + vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`)) + const navbar = document.createElement('solid-panes-navbar') + document.body.appendChild(navbar) + window.history.replaceState({}, '', '/profile/card#me') + + expect(await createNavbar(navigation)).toBe(navbar) + expect(navbar).toHaveClass('navbar--hidden') + + window.history.replaceState({}, '', '/other/profile/card#me') + expect(await createNavbar(navigation)).toBe(navbar) + expect(navbar).not.toHaveClass('navbar--hidden') + }) + + it('matches the actual WebID rather than assuming a #me fragment', async () => { + vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/identity#person`)) + window.history.replaceState({}, '', '/identity#person') + + 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 new Promise(resolve => setTimeout(resolve, 20)) + + expect(navbar.navbarItems).toBe(items) + }) + + 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 new file mode 100644 index 00000000..48f96837 --- /dev/null +++ b/test/unit/mainPage/profileHeading.test.ts @@ -0,0 +1,918 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { authn, authSession, store } from 'solid-logic' +import { lit, sym } from 'rdflib' +import { ns } from 'solid-ui' +import type { ProfileHeadingEditContext } from '../../../src/components/profile-heading/ProfileHeading' + +const { getViewerModeMock, createHeadingEditDialogMock, pod } = vi.hoisted(() => ({ + getViewerModeMock: 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', () => ({ + createHeadingEditDialog: createHeadingEditDialogMock +})) + +vi.mock('../../../src/components/profile-heading/profileHeadingData', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + getViewerMode: getViewerModeMock + } +}) + +vi.mock('../../../src/utils/webIdUtils', async () => { + const actual = await vi.importActual('../../../src/utils/webIdUtils') + return { + ...actual, + loadProfileFromURI: async (subject: ReturnType) => subject + } +}) + +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', () => { + it('renders for the active profile pane after concurrent refresh requests', async () => { + const subject = sym('https://profile-heading.example/profile/card#me') + store.add(subject, ns.vcard('fn'), lit('Profile Heading Test'), subject.doc()) + + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + const navbar = document.createElement('solid-panes-navbar') + const outline = document.createElement('table') + outline.id = 'OutlineView' + mainContent.append(navbar, outline) + document.body.appendChild(mainContent) + window.history.replaceState({ + paneName: 'profile', + paneUri: subject.value + }, '', '/') + + await Promise.all([refreshProfileHeading(), refreshProfileHeading()]) + + const mount = document.getElementById('profile-heading-mount') + const heading = mount?.querySelector('solid-panes-profile-heading') as (HTMLElement & { + profileData?: { name: string } + updateComplete?: Promise + }) | null + await heading?.updateComplete + + expect(mount?.hidden).toBe(false) + expect(mainContent.firstElementChild).toBe(mount) + expect(heading?.profileData?.name).toBe('Profile Heading Test') + expect(heading?.shadowRoot?.querySelector('h1')?.textContent).toBe('Profile Heading Test') + expect(heading?.shadowRoot?.querySelector('.image-frame')).toHaveClass('image-frame--fallback') + expect(heading?.shadowRoot?.querySelector('.hero-fallback icon-lucide-circle-user-round')).not.toBeNull() + expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull() + }) + + it('opens the heading editor from Edit and announces the save to other views', async () => { + const subject = sym('https://edit-flow.example/profile/card#me') + const profileData = { entryNode: subject, name: 'Profile Heading Test' } + const onSaved = vi.fn() + createHeadingEditDialogMock.mockReset().mockImplementation( + async (_event, _store, _subject, _profile, _viewerMode, saved) => saved() + ) + const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & { + profileData?: typeof profileData + editContext?: unknown + updateComplete?: Promise + } + heading.profileData = profileData + heading.editContext = { subject, profileData, viewerMode: 'owner', onSaved } + document.body.appendChild(heading) + await heading.updateComplete + + const headingSaved = vi.fn() + document.addEventListener('profile-heading-saved', headingSaved) + try { + const desktopEditButton = heading.shadowRoot?.querySelector('.desktop-edit-button') + expect(desktopEditButton).toHaveAttribute('variant', 'tertiary') + const mobileEditButton = heading.shadowRoot?.querySelector('.mobile-edit-button') + expect(mobileEditButton).toHaveAttribute('variant', 'ghost') + expect(mobileEditButton?.querySelector('icon-lucide-pencil')).not.toBeNull() + desktopEditButton?.click() + + await vi.waitFor(() => expect(onSaved).toHaveBeenCalledOnce()) + expect(createHeadingEditDialogMock).toHaveBeenCalledWith( + expect.any(Event), store, subject, profileData, 'owner', expect.any(Function) + ) + expect(headingSaved).toHaveBeenCalledOnce() + expect((headingSaved.mock.calls[0][0] as CustomEvent).detail).toEqual({ subjectUri: subject.value }) + } finally { + document.removeEventListener('profile-heading-saved', headingSaved) + } + }) + + it('does not announce a save when the editor is closed without saving', async () => { + const subject = sym('https://edit-flow-cancel.example/profile/card#me') + const profileData = { entryNode: subject, name: 'Profile Heading Test' } + const onSaved = vi.fn() + createHeadingEditDialogMock.mockReset().mockResolvedValue(undefined) + const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & { + profileData?: typeof profileData + editContext?: unknown + updateComplete?: Promise + } + heading.profileData = profileData + heading.editContext = { subject, profileData, viewerMode: 'owner', onSaved } + document.body.appendChild(heading) + await heading.updateComplete + + const headingSaved = vi.fn() + document.addEventListener('profile-heading-saved', headingSaved) + try { + heading.shadowRoot?.querySelector('.desktop-edit-button')?.click() + await vi.waitFor(() => expect(createHeadingEditDialogMock).toHaveBeenCalledOnce()) + expect(headingSaved).not.toHaveBeenCalled() + expect(onSaved).not.toHaveBeenCalled() + } finally { + document.removeEventListener('profile-heading-saved', headingSaved) + } + }) + + it('renders Lucide icons for profile detail rows', async () => { + const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & { + profileData?: { + name: string + dateOfBirth: string + location: string + primaryPhone: { valueNode: ReturnType } + primaryEmail: { valueNode: ReturnType } + } + updateComplete?: Promise + } + heading.profileData = { + name: 'Profile Heading Test', + dateOfBirth: '2001-02-03', + location: 'Example City', + primaryPhone: { valueNode: lit('tel:+123456789') }, + primaryEmail: { valueNode: lit('mailto:person@example.com') } + } + document.body.appendChild(heading) + await heading.updateComplete + + expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-cake')).not.toBeNull() + expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-map-pin')).not.toBeNull() + expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-phone')).not.toBeNull() + expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-mail')).not.toBeNull() + }) + + it('rechecks owner edit access after a restored session', async () => { + getViewerModeMock.mockReset() + const subject = sym('https://profile-heading.example/profile/card#me') + let sessionReady = false + const checkUser = vi.spyOn(authn, 'checkUser').mockImplementation(async () => { + sessionReady = true + return subject + }) + getViewerModeMock.mockImplementation(async () => sessionReady ? 'owner' : 'anonymous') + + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ + paneName: 'profile', + paneUri: subject.value + }, '', '/') + + initializeProfileHeading() + await refreshProfileHeading(true) + expect(checkUser).toHaveBeenCalledOnce() + + const getHeading = () => document.querySelector('solid-panes-profile-heading') as (HTMLElement & { + editContext?: unknown + updateComplete?: Promise + }) | null + + await vi.waitFor(async () => { + await getHeading()?.updateComplete + expect(getHeading()?.editContext).toBeTruthy() + }) + + sessionReady = false + authSession.events.emit('logout') + + await vi.waitFor(async () => { + await getHeading()?.updateComplete + expect(getHeading()?.editContext).toBeUndefined() + }) + + checkUser.mockRestore() + }) + + it('hides extra information and editing in compact mode, then restores the full heading', async () => { + const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & { + profileData: { + name: string + jobTitle: string + pronouns: string + dateOfBirth: string + location: string + primaryEmail: { valueNode: ReturnType } + } + compact: boolean + editContext: { + subject: ReturnType + profileData: { entryNode: ReturnType; name: string } + viewerMode: 'owner' + onSaved: () => void + } + updateComplete: Promise + } + heading.profileData = { + name: 'Compact Heading Test', + jobTitle: 'Engineer', + pronouns: 'she/her', + dateOfBirth: '2000-01-02', + location: 'Paris, France', + primaryEmail: { valueNode: sym('mailto:compact@example.com') } + } + heading.editContext = { + subject: sym('https://compact-heading.example/profile/card#me'), + profileData: { + entryNode: sym('https://compact-heading.example/profile/card#me'), + name: 'Compact Heading Test' + }, + viewerMode: 'owner', + onSaved: vi.fn() + } + document.body.appendChild(heading) + await heading.updateComplete + const avatar = heading.shadowRoot?.querySelector('.image-frame') + const details = heading.shadowRoot?.querySelector('.details-collapse') + expect(details).toHaveAttribute('aria-hidden', 'false') + expect(heading.shadowRoot?.querySelector('.pronouns')).not.toHaveAttribute('hidden') + + heading.compact = true + await heading.updateComplete + expect(heading).toHaveAttribute('compact') + expect(details).toHaveAttribute('aria-hidden', 'true') + expect(details).toHaveAttribute('inert') + expect(heading.shadowRoot?.querySelector('.pronouns')).toHaveAttribute('hidden') + expect(heading.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull() + expect(heading.shadowRoot?.querySelector('.name')?.textContent).toBe('Compact Heading Test') + expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Engineer') + expect(heading.shadowRoot?.querySelector('.image-frame')).toBe(avatar) + + heading.compact = false + await heading.updateComplete + expect(heading).not.toHaveAttribute('compact') + expect(details).not.toHaveAttribute('inert') + expect(details).toHaveAttribute('aria-hidden', 'false') + expect(heading.shadowRoot?.querySelector('.pronouns')).not.toHaveAttribute('hidden') + expect(heading.shadowRoot?.querySelector('.profile__heading-edit-action')).not.toBeNull() + expect(heading.shadowRoot?.querySelector('.image-frame')).toBe(avatar) + }) + + it('only enables heading editing on the profile pane', async () => { + getViewerModeMock.mockReset().mockResolvedValue('owner') + const subject = sym('https://profile-heading.example/profile/card#me') + store.add(subject, ns.vcard('fn'), lit('Profile Heading Test'), subject.doc()) + + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + + window.history.replaceState({ + paneName: 'social', + paneUri: subject.value + }, '', '/') + await refreshProfileHeading(true) + + const heading = document.querySelector('solid-panes-profile-heading') as (HTMLElement & { + editContext?: unknown + compact?: boolean + updateComplete?: Promise + }) | null + await heading?.updateComplete + expect(heading?.editContext).toBeUndefined() + expect(heading?.compact).toBe(true) + expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull() + + window.history.replaceState({ + paneName: 'profile', + paneUri: subject.value + }, '', '/') + await refreshProfileHeading(true) + await heading?.updateComplete + + expect(heading?.editContext).toBeTruthy() + expect(heading?.compact).toBe(false) + expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).not.toBeNull() + + window.history.replaceState({ + paneName: 'social', + paneUri: subject.value + }, '', '/') + await refreshProfileHeading() + await heading?.updateComplete + + expect(document.querySelector('solid-panes-profile-heading')).toBe(heading) + expect(heading?.compact).toBe(true) + expect(heading?.editContext).toBeUndefined() + }) + + it('refreshes the heading job title when the displayed profile saves resume changes', async () => { + const subject = sym('https://resume-heading.example/profile/card#me') + const membership = sym('https://resume-heading.example/profile/card#role') + const doc = subject.doc() + store.add(subject, ns.vcard('fn'), lit('Resume Heading Test'), doc) + store.add(membership, ns.org('member'), subject, doc) + store.add(membership, ns.rdf('type'), ns.solid('CurrentRole'), doc) + store.add(membership, ns.vcard('role'), lit('Engineer'), doc) + getViewerModeMock.mockResolvedValue('owner') + + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + const profilePane = document.createElement('div') + mainContent.appendChild(profilePane) + document.body.appendChild(mainContent) + window.history.replaceState({ + paneName: 'profile', + paneUri: subject.value + }, '', '/') + initializeProfileHeading() + await refreshProfileHeading(true) + + const heading = document.querySelector('solid-panes-profile-heading') as HTMLElement & { + updateComplete: Promise + } + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Engineer') + + store.removeMany(membership, ns.vcard('role'), null, doc) + store.add(membership, ns.vcard('role'), lit('Staff Engineer'), doc) + + profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', { + bubbles: true, + composed: true, + detail: { subjectUri: 'https://unrelated.example/profile/card#me' } + })) + await Promise.resolve() + expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Engineer') + + profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', { + bubbles: true, + composed: true, + detail: { subjectUri: subject.value } + })) + await vi.waitFor(async () => { + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Staff Engineer') + }) + + store.removeMany(membership, ns.vcard('role'), null, doc) + profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', { + bubbles: true, + composed: true, + detail: { subjectUri: subject.value } + })) + await vi.waitFor(async () => { + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('.role')).toBeNull() + }) + }) + + it('refreshes heading contact details after More contacts saves additions, edits, and removals', async () => { + const subject = sym('https://contacts-heading.example/profile/card#me') + const doc = subject.doc() + const phone = sym(`${doc.value}#phone`) + const email = sym(`${doc.value}#email`) + const address = sym(`${doc.value}#address`) + store.add(subject, ns.vcard('fn'), lit('Contacts Heading Test'), doc) + getViewerModeMock.mockResolvedValue('owner') + + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + const profilePane = document.createElement('div') + mainContent.appendChild(profilePane) + document.body.appendChild(mainContent) + window.history.replaceState({ + paneName: 'profile', + paneUri: subject.value + }, '', '/') + initializeProfileHeading() + await refreshProfileHeading(true) + + const heading = document.querySelector('solid-panes-profile-heading') as HTMLElement & { + updateComplete: Promise + } + const values = () => Array.from( + heading.shadowRoot?.querySelectorAll('.detail-value') ?? [], + element => element.textContent + ) + const notifySaved = () => profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', { + bubbles: true, + composed: true, + detail: { subjectUri: subject.value } + })) + await heading.updateComplete + expect(values()).toEqual([]) + + store.add(subject, ns.vcard('hasTelephone'), phone, doc) + store.add(phone, ns.vcard('value'), sym('tel:+123456789'), doc) + store.add(phone, ns.rdf('type'), ns.vcard('Work'), doc) + store.add(subject, ns.vcard('hasEmail'), email, doc) + store.add(email, ns.vcard('value'), sym('mailto:work@example.com'), doc) + store.add(email, ns.rdf('type'), ns.vcard('Work'), doc) + store.add(subject, ns.vcard('hasAddress'), address, doc) + store.add(address, ns.vcard('locality'), lit('Paris'), doc) + store.add(address, ns.vcard('country-name'), lit('France'), doc) + notifySaved() + await vi.waitFor(async () => { + await heading.updateComplete + expect(values()).toEqual(['Paris, France', '+123456789', 'work@example.com']) + }) + + store.removeMany(phone, ns.vcard('value'), null, doc) + store.add(phone, ns.vcard('value'), sym('tel:+987654321'), doc) + store.removeMany(email, ns.vcard('value'), null, doc) + store.add(email, ns.vcard('value'), sym('mailto:updated@example.com'), doc) + store.removeMany(address, ns.vcard('locality'), null, doc) + store.add(address, ns.vcard('locality'), lit('Lyon'), doc) + notifySaved() + await vi.waitFor(async () => { + await heading.updateComplete + expect(values()).toEqual(['Lyon, France', '+987654321', 'updated@example.com']) + }) + + store.removeMany(subject, ns.vcard('hasTelephone'), null, doc) + store.removeMany(subject, ns.vcard('hasEmail'), null, doc) + store.removeMany(subject, ns.vcard('hasAddress'), null, doc) + notifySaved() + await vi.waitFor(async () => { + await heading.updateComplete + expect(values()).toEqual([]) + }) + }) + + it.each(['owner', 'anonymous'] as const)('uses fresh saved data for the heading and editor in %s mode', async (viewerMode) => { + const subject = sym('https://saved-contacts.example/profile/card#me') + store.add(subject, ns.vcard('fn'), lit('Saved Contacts Test'), subject.doc()) + store.add(subject, ns.vcard('hasEmail'), sym('mailto:old@example.com'), subject.doc()) + getViewerModeMock.mockResolvedValue(viewerMode) + createHeadingEditDialogMock.mockReset().mockResolvedValue(undefined) + + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ + paneName: 'profile', + paneUri: subject.value + }, '', '/') + initializeProfileHeading() + await refreshProfileHeading(true) + const heading = document.querySelector('solid-panes-profile-heading') as HTMLElement & { + profileData?: unknown + editContext?: ProfileHeadingEditContext + updateComplete: Promise + } + await heading.updateComplete + expect(heading.shadowRoot?.textContent).toContain('old@example.com') + const previousEditContext = heading.editContext + const profileData = { + entryNode: subject, + name: 'Saved Contacts Test', + primaryPhone: { valueNode: sym('tel:+123456789') }, + primaryEmail: { valueNode: sym('mailto:new@example.com') }, + location: 'Lyon, France', + jobTitle: 'Staff Engineer' + } + + mainContent.dispatchEvent(new CustomEvent('profile-pane-saved', { + bubbles: true, + composed: true, + detail: { + subjectUri: subject.value, + profileData + } + })) + await heading.updateComplete + expect(heading.shadowRoot?.textContent).toContain('new@example.com') + expect(heading.shadowRoot?.textContent).toContain('+123456789') + expect(heading.shadowRoot?.textContent).toContain('Lyon, France') + expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Staff Engineer') + expect(heading.shadowRoot?.textContent).not.toContain('old@example.com') + expect(heading.profileData).toBe(profileData) + if (viewerMode === 'owner') { + expect(previousEditContext).toBeDefined() + expect(heading.editContext).not.toBe(previousEditContext) + expect(heading.editContext).toEqual({ ...previousEditContext, profileData }) + expect(heading.editContext?.profileData).toBe(profileData) + expect(heading.editContext?.onSaved).toBe(previousEditContext?.onSaved) + + heading.shadowRoot?.querySelector('.desktop-edit-button')?.click() + await vi.waitFor(() => expect(createHeadingEditDialogMock).toHaveBeenCalledOnce()) + expect(createHeadingEditDialogMock).toHaveBeenCalledWith( + expect.any(Event), store, subject, profileData, viewerMode, expect.any(Function) + ) + } else { + expect(heading.editContext).toBeUndefined() + expect(heading.shadowRoot?.querySelector('.desktop-edit-button')).toBeNull() + } + }) + + 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' + document.body.appendChild(mainContent) + window.history.replaceState({ + paneName: 'folder', + paneUri: storage.value + }, '', '/') + + await refreshProfileHeading(true) + + const mount = document.getElementById('profile-heading-mount') + const heading = mount?.querySelector('solid-panes-profile-heading') as (HTMLElement & { + profileData?: { name: string } + editContext?: unknown + compact?: boolean + updateComplete?: Promise + }) | null + await heading?.updateComplete + + expect(mount?.hidden).toBe(false) + expect(heading?.profileData?.name).toBe('Another Person') + expect(heading?.editContext).toBeUndefined() + expect(heading?.compact).toBe(true) + expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull() + + currentUser.mockRestore() + }) + + 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' + document.body.appendChild(mainContent) + + try { + window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', subject.value) + await refreshProfileHeading(true) + const mount = document.getElementById('profile-heading-mount')! + expect(mount.hidden).toBe(false) + + 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() + + // 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(false) + + window.history.replaceState({ paneName: 'folder', paneUri: ownStorages[1] }, '', subject.value) + await refreshProfileHeading() + expect(mount.hidden).toBe(false) + expect(mount.firstElementChild).not.toBeNull() + } finally { + currentUser.mockRestore() + } + }) + + it('shows a compact heading for the owner on a container in someone else\'s pod', async () => { + 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' + document.body.appendChild(mainContent) + + try { + for (const state of [{}, { paneName: 'folder' }, { paneName: 'source' }]) { + window.history.replaceState(state, '', '/other/container/') + await refreshProfileHeading(true) + + const mount = document.getElementById('profile-heading-mount')! + const heading = mount.querySelector('solid-panes-profile-heading') as HTMLElement & { + profileData?: { name: string } + editContext?: unknown + friendSubject?: unknown + compact?: boolean + updateComplete: Promise + } + await heading.updateComplete + expect(mount.hidden).toBe(false) + expect(heading.profileData?.name).toBe('Pod Owner') + expect(heading.compact).toBe(true) + expect(heading.editContext).toBeUndefined() + expect(heading.friendSubject).toBeUndefined() + } + } finally { + currentUser.mockRestore() + } + }) + + 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'], + ['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' + document.body.appendChild(mainContent) + podOf(owner === 'viewer' ? viewer.value : owner === 'other' ? other.value : undefined) + + try { + window.history.replaceState(state, '', '/other/container/') + await refreshProfileHeading(true) + + expect(document.getElementById('profile-heading-mount')?.hidden).toBe(true) + } finally { + currentUser.mockRestore() + } + }) + + it('hides the mount for panes other than profile and social', async () => { + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ paneName: 'folder' }, '', '/') + + await refreshProfileHeading() + + expect(document.getElementById('profile-heading-mount')?.hidden).toBe(true) + }) + + it.each(['profile', 'social'])('animates appearance on %s from a pane without a heading', async (paneName) => { + const subject = sym(`https://show-${paneName}.example/profile/card#me`) + getViewerModeMock.mockResolvedValue('owner') + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ paneName: 'folder' }, '', '/') + await refreshProfileHeading(true) + const mount = document.getElementById('profile-heading-mount')! + let complete!: () => void + const finished = new Promise(resolve => { complete = resolve }) + const cancel = vi.fn() + const animate = vi.fn(() => ({ finished, cancel })) + Object.defineProperty(mount, 'animate', { value: animate }) + window.history.replaceState({ paneName, paneUri: subject.value }, '', '/') + await refreshProfileHeading() + await refreshProfileHeading() + + expect(animate).toHaveBeenCalledOnce() + expect(animate).toHaveBeenCalledWith([ + expect.objectContaining({ height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px' }), + expect.objectContaining({ opacity: 1, marginTop: '18px', marginBottom: '20px' }) + ], { duration: 300, easing: 'ease', fill: 'both' }) + const heading = mount.firstElementChild as HTMLElement & { compact: boolean } + expect(heading.compact).toBe(paneName !== 'profile') + expect(heading.shadowRoot?.querySelector('.profile-heading')).not.toBeNull() + expect(mount.hidden).toBe(false) + expect(mount.inert).toBe(false) + complete() + await finished + expect(cancel).toHaveBeenCalledOnce() + expect(mount.firstElementChild).toBe(heading) + expect(mount.hidden).toBe(false) + }) + + it('cancels appearance when navigating away before it finishes', async () => { + const subject = sym('https://cancel-show.example/profile/card#me') + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ paneName: 'folder' }, '', '/') + await refreshProfileHeading(true) + const mount = document.getElementById('profile-heading-mount')! + let completeShow!: () => void + let completeHide!: () => void + const shown = new Promise(resolve => { completeShow = resolve }) + const hidden = new Promise(resolve => { completeHide = resolve }) + const cancelShow = vi.fn() + const animate = vi.fn() + .mockReturnValueOnce({ finished: shown, cancel: cancelShow }) + .mockReturnValueOnce({ finished: hidden, cancel: vi.fn() }) + Object.defineProperty(mount, 'animate', { value: animate }) + window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/') + await refreshProfileHeading() + window.history.replaceState({ paneName: 'folder' }, '', '/') + await refreshProfileHeading() + expect(cancelShow).toHaveBeenCalledOnce() + expect(animate).toHaveBeenCalledTimes(2) + completeShow() + await shown + expect(mount.hidden).toBe(false) + completeHide() + await hidden + expect(mount.hidden).toBe(true) + expect(mount.firstElementChild).toBeNull() + }) + + it.each(['profile', 'social'])('animates disappearance from %s before removing the heading', async (paneName) => { + const subject = sym(`https://hide-${paneName}.example/profile/card#me`) + getViewerModeMock.mockResolvedValue('owner') + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ paneName, paneUri: subject.value }, '', '/') + await refreshProfileHeading(true) + + const mount = document.getElementById('profile-heading-mount')! + const heading = mount.firstElementChild + let complete!: () => void + const finished = new Promise(resolve => { complete = resolve }) + const cancel = vi.fn() + const animate = vi.fn(() => ({ finished, cancel })) + Object.defineProperty(mount, 'animate', { value: animate }) + window.history.replaceState({ paneName: 'folder' }, '', '/') + await refreshProfileHeading() + await refreshProfileHeading() + + expect(animate).toHaveBeenCalledOnce() + expect(animate).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px' }) + ]), { duration: 300, easing: 'ease', fill: 'forwards' }) + expect(mount.hidden).toBe(false) + expect(mount.firstElementChild).toBe(heading) + expect(mount.inert).toBe(true) + expect(mount).toHaveAttribute('aria-hidden', 'true') + + complete() + await finished + expect(mount.hidden).toBe(true) + expect(mount.firstElementChild).toBeNull() + expect(cancel).toHaveBeenCalledOnce() + }) + + it('cancels disappearance when navigating back before it finishes', async () => { + const subject = sym('https://cancel-hide.example/profile/card#me') + getViewerModeMock.mockResolvedValue('owner') + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/') + await refreshProfileHeading(true) + + const mount = document.getElementById('profile-heading-mount')! + const heading = mount.firstElementChild + let complete!: () => void + const finished = new Promise(resolve => { complete = resolve }) + const cancel = vi.fn() + Object.defineProperty(mount, 'animate', { value: vi.fn(() => ({ finished, cancel })) }) + window.history.replaceState({ paneName: 'folder' }, '', '/') + await refreshProfileHeading() + window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/') + await refreshProfileHeading() + expect(cancel).toHaveBeenCalledOnce() + expect(mount.inert).toBe(false) + expect(mount).not.toHaveAttribute('aria-hidden') + + complete() + await finished + expect(mount.hidden).toBe(false) + expect(mount.firstElementChild).toBe(heading) + }) + + it('skips appearance and disappearance animations when reduced motion is requested', async () => { + const subject = sym('https://reduced-hide.example/profile/card#me') + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/') + await refreshProfileHeading(true) + const mount = document.getElementById('profile-heading-mount')! + const animate = vi.fn() + Object.defineProperty(mount, 'animate', { value: animate }) + vi.stubGlobal('matchMedia', vi.fn(() => ({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + }))) + try { + window.history.replaceState({ paneName: 'folder' }, '', '/') + await refreshProfileHeading() + expect(animate).not.toHaveBeenCalled() + expect(mount.hidden).toBe(true) + expect(mount.firstElementChild).toBeNull() + window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/') + await refreshProfileHeading() + expect(animate).not.toHaveBeenCalled() + expect(mount.hidden).toBe(false) + expect(mount.firstElementChild).not.toBeNull() + } finally { + 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/profileHeadingData.test.ts b/test/unit/mainPage/profileHeadingData.test.ts new file mode 100644 index 00000000..5a684fd9 --- /dev/null +++ b/test/unit/mainPage/profileHeadingData.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { authn, store } from 'solid-logic' +import { lit, sym } from 'rdflib' +import { ns } from 'solid-ui' +import { getViewerMode, presentProfile } from '../../../src/components/profile-heading/profileHeadingData' + +describe('local profile heading presentation', () => { + it('presents profile and contact data from the local RDF store', () => { + const subject = sym('https://local-profile-heading.example/profile/card#me') + const doc = subject.doc() + const email = sym('https://local-profile-heading.example/profile/card#email') + const address = sym('https://local-profile-heading.example/profile/card#address') + const membership = sym('https://local-profile-heading.example/profile/card#role') + + store.add(subject, ns.vcard('fn'), lit('Local Heading'), doc) + store.add(subject, ns.vcard('nickname'), lit('Local Nickname'), doc) + store.add(subject, ns.vcard('bday'), lit('2000-01-02'), doc) + store.add(subject, ns.solid('preferredSubjectPronoun'), lit('they'), doc) + store.add(subject, ns.solid('preferredObjectPronoun'), lit('them'), doc) + store.add(subject, ns.vcard('hasEmail'), email, doc) + store.add(email, ns.vcard('value'), lit('person@example.com'), doc) + store.add(email, ns.rdf('type'), ns.vcard('Work'), doc) + store.add(subject, ns.vcard('hasAddress'), address, doc) + store.add(address, ns.vcard('locality'), lit('Example City'), doc) + store.add(address, ns.vcard('country-name'), lit('Example Country'), doc) + store.add(membership, ns.org('member'), subject, doc) + store.add(membership, ns.rdf('type'), ns.solid('CurrentRole'), doc) + store.add(membership, ns.vcard('role'), lit('Engineer'), doc) + + const profile = presentProfile(subject, store) + + expect(profile.name).toBe('Local Heading') + expect(profile.nickname).toBe('Local Nickname') + expect(profile.dateOfBirth).toBe('2000-01-02') + expect(profile.pronouns).toBe('they/them') + expect(profile.jobTitle).toBe('Engineer') + expect(profile.primaryEmail?.valueNode.value).toBe('person@example.com') + expect(profile.location).toBe('Example City, Example Country') + expect(profile.entryNode).toBe(subject) + }) + + it('determines anonymous and authenticated viewing modes locally', async () => { + const subject = sym('https://local-viewer-mode.example/profile/card#me') + const currentUser = vi.spyOn(authn, 'currentUser') + + try { + currentUser.mockReturnValue(null) + await expect(getViewerMode(subject)).resolves.toBe('anonymous') + + currentUser.mockReturnValue(sym('https://another-user.example/profile/card#me')) + await expect(getViewerMode(subject)).resolves.toBe('authenticated') + } finally { + currentUser.mockRestore() + } + }) +}) diff --git a/test/unit/mainPage/profileHeadingEditor.test.ts b/test/unit/mainPage/profileHeadingEditor.test.ts new file mode 100644 index 00000000..c7e8c664 --- /dev/null +++ b/test/unit/mainPage/profileHeadingEditor.test.ts @@ -0,0 +1,111 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { authn, store } from 'solid-logic' +import { lit, sym } from 'rdflib' +import { ns } from 'solid-ui' +import { createHeadingEditDialog, type ProfileDetails } from '../../../src/components/profile-heading/editor' +import { refreshProfileHeading } from '../../../src/mainPage/profileHeading' +import { + getSharedDialogCancelButton, + getSharedDialogSaveButton +} from '../../../src/components/profile-heading/editor/ui/dialog' + +const attachInternalsDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'attachInternals') + +vi.mock('../../../src/utils/webIdUtils', () => ({ + loadProfileFromURI: async (subject: ReturnType) => subject +})) + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'attachInternals', { + configurable: true, + value: () => ({ + setFormValue: () => undefined, + setValidity: () => undefined, + checkValidity: () => true, + reportValidity: () => true, + validity: {}, + validationMessage: '', + willValidate: false, + states: new Set() + }) + }) +}) + +afterEach(() => { + document.body.replaceChildren() + window.history.replaceState({}, '', '/') + vi.restoreAllMocks() +}) + +afterAll(() => { + if (attachInternalsDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'attachInternals', attachInternalsDescriptor) + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'attachInternals') + } +}) + +describe('local profile heading editor', () => { + it('opens the local dialog when the owner clicks Edit on the mounted heading', async () => { + const subject = sym('https://local-heading-editor.example/profile/card#owner') + store.add(subject, ns.vcard('fn'), lit('Heading Owner'), subject.doc()) + vi.spyOn(authn, 'currentUser').mockReturnValue(subject) + vi.spyOn(store.updater, 'editable').mockReturnValue(true) + const consoleError = vi.spyOn(console, 'error') + + const mainContent = document.createElement('main') + mainContent.id = 'MainContent' + document.body.appendChild(mainContent) + window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/') + await refreshProfileHeading(true) + + const heading = document.querySelector }>('solid-panes-profile-heading') + await heading?.updateComplete + const editButton = heading?.shadowRoot?.querySelector('.desktop-edit-button') + expect(editButton).not.toBeNull() + editButton?.click() + + await vi.waitFor(() => { + expect(document.querySelector('#profile-modal')).toHaveAttribute('open') + expect(document.querySelector('input[name="name"]')?.value).toBe('Heading Owner') + }) + expect(consoleError).not.toHaveBeenCalled() + + getSharedDialogCancelButton(document)?.click() + await vi.waitFor(() => { + expect(document.querySelector('#profile-modal')).not.toHaveAttribute('open') + }) + }) + + it('validates phone input and closes through the local dialog controls', async () => { + const subject = sym('https://local-heading-editor.example/profile/card#me') + const profileData: ProfileDetails = { + entryNode: subject, + name: 'Jane Doe' + } + const resultPromise = createHeadingEditDialog( + new MouseEvent('click', { bubbles: true, composed: true }), + store, + subject, + profileData, + 'owner' + ) + + await vi.waitFor(() => { + expect(document.querySelector('input[name="phone-value"]')).not.toBeNull() + }) + + const phoneInput = document.querySelector('input[name="phone-value"]')! + phoneInput.value = '555 123 4567' + phoneInput.dispatchEvent(new Event('input', { bubbles: true })) + getSharedDialogSaveButton(document)?.click() + + await vi.waitFor(() => { + expect(document.querySelector('#modal-error')?.textContent) + .toBe('Phone Number 1 should contain only numbers.') + }) + + getSharedDialogCancelButton(document)?.click() + await resultPromise + }) +}) diff --git a/test/unit/mainPage/profileHeadingFriends.test.ts b/test/unit/mainPage/profileHeadingFriends.test.ts new file mode 100644 index 00000000..5ef31ae5 --- /dev/null +++ b/test/unit/mainPage/profileHeadingFriends.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { authn, store } from 'solid-logic' +import { lit, st, sym } from 'rdflib' +import { ns } from 'solid-ui' +import ProfileHeading from '../../../src/components/profile-heading/ProfileHeading' +import { refreshProfileHeading } from '../../../src/mainPage/profileHeading' + +vi.mock('../../../src/utils/webIdUtils', () => ({ + loadProfileFromURI: async (subject: ReturnType) => subject +})) + +const viewer = sym('https://friends-viewer.example/profile/card#me') +const subject = sym('https://friends-subject.example/profile/card#me') + +beforeEach(() => { + vi.spyOn(authn, 'currentUser').mockReturnValue(viewer) + vi.spyOn(store.fetcher, 'load').mockResolvedValue(new Response()) + vi.spyOn(store, 'whether').mockReturnValue(0) + vi.spyOn(store.updater, 'update').mockResolvedValue(undefined) +}) + +afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + window.history.replaceState({}, '', '/') +}) + +async function createHeading (friendSubject = subject) { + const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading + heading.profileData = { name: 'Friend Test' } + heading.friendSubject = friendSubject + document.body.appendChild(heading) + await vi.waitFor(() => expect(heading.friendState).not.toBe('loading')) + await heading.updateComplete + return heading +} + +function button (heading: ProfileHeading) { + return heading.shadowRoot?.querySelector('.profile__btn-friends') +} + +describe('profile heading friends', () => { + it.each(['success', 'error'])('dismisses %s feedback after exactly 10 seconds', async (result) => { + const heading = await createHeading() + if (result === 'error') { + vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Write denied')) + } + vi.useFakeTimers() + button(heading)?.click() + await vi.advanceTimersByTimeAsync(0) + expect(heading.shadowRoot?.querySelector('.friend-message')).not.toBeNull() + await vi.advanceTimersByTimeAsync(9999) + expect(heading.shadowRoot?.querySelector('.friend-message')).not.toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(heading.shadowRoot?.querySelector('.friend-message')).toBeNull() + expect(heading.friendState).toBe(result === 'success' ? 'exists' : 'error') + }) + + it('restarts the dismissal timer for new feedback', async () => { + const heading = await createHeading() + vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Write denied')) + vi.useFakeTimers() + button(heading)?.click() + await vi.advanceTimersByTimeAsync(5000) + button(heading)?.click() + await vi.advanceTimersByTimeAsync(0) + expect(heading.friendMessage).toBe('Friend was added!') + await vi.advanceTimersByTimeAsync(5000) + expect(heading.friendMessage).toBe('Friend was added!') + await vi.advanceTimersByTimeAsync(5000) + expect(heading.friendMessage).toBe('') + }) + + it('clears the dismissal timer when disconnected', async () => { + const heading = await createHeading() + vi.useFakeTimers() + button(heading)?.click() + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(1) + heading.remove() + expect(vi.getTimerCount()).toBe(0) + expect(heading.friendMessage).toBe('') + }) + + it('ignores a pending friend check after disconnection', async () => { + let finish: (() => void) | undefined + vi.mocked(store.fetcher.load).mockImplementationOnce(() => new Promise(resolve => { + finish = () => resolve(new Response()) + })) + const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading + heading.profileData = { name: 'Pending' } + heading.friendSubject = subject + document.body.appendChild(heading) + await heading.updateComplete + heading.remove() + vi.mocked(store.whether).mockReturnValue(1) + finish?.() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(heading.friendState).toBe('loading') + }) + + it('ignores a pending friend addition after disconnection', async () => { + const heading = await createHeading() + let finish: (() => void) | undefined + vi.mocked(store.updater.update).mockImplementationOnce(() => new Promise(resolve => { + finish = () => resolve(undefined) + })) + vi.useFakeTimers() + button(heading)?.click() + await vi.advanceTimersByTimeAsync(0) + expect(heading.friendState).toBe('saving') + heading.remove() + finish?.() + await vi.advanceTimersByTimeAsync(0) + expect(heading.friendState).toBe('saving') + expect(heading.friendMessage).toBe('') + expect(vi.getTimerCount()).toBe(0) + }) + + it('rechecks an interrupted friend request when reconnected', async () => { + const heading = await createHeading() + let finish: (() => void) | undefined + vi.mocked(store.updater.update).mockImplementationOnce(() => new Promise(resolve => { + finish = () => resolve(undefined) + })) + button(heading)?.click() + await vi.waitFor(() => expect(store.updater.update).toHaveBeenCalled()) + heading.remove() + vi.mocked(store.whether).mockReturnValue(1) + finish?.() + document.body.appendChild(heading) + await vi.waitFor(() => expect(heading.friendState).toBe('exists')) + expect(heading.friendMessage).toBe('') + }) + + it('checks the viewer profile and adds foaf:knows in that document', async () => { + const heading = await createHeading() + expect(store.fetcher.load).toHaveBeenCalledWith(viewer) + expect(button(heading)?.textContent).toBe('Add as friend') + expect(button(heading)?.querySelector('icon-lucide-user-round-plus')).toHaveAttribute('slot', 'icon') + button(heading)?.click() + button(heading)?.click() + await vi.waitFor(() => expect(heading.friendState).toBe('exists')) + await heading.updateComplete + expect(button(heading)?.querySelector('icon-lucide-user-round-plus')).toBeNull() + expect(store.updater.update).toHaveBeenCalledExactlyOnceWith( + [], [st(viewer, ns.foaf('knows'), subject, viewer.doc())] + ) + expect(button(heading)).toHaveAttribute('disabled') + expect(button(heading)?.textContent).toBe('Already a friend') + const message = heading.shadowRoot?.querySelector('.friend-message') + expect(message).toHaveAttribute('role', 'status') + expect(message?.textContent).toContain('Friend was added!') + expect(heading.shadowRoot?.activeElement).toBe(message) + }) + + it('disables the button for an existing friend', async () => { + vi.mocked(store.whether).mockReturnValue(1) + const heading = await createHeading() + expect(button(heading)).toHaveAttribute('disabled') + expect(button(heading)?.textContent).toBe('Already a friend') + expect(button(heading)?.querySelector('icon-lucide-user-round-plus')).toBeNull() + button(heading)?.dispatchEvent(new Event('click')) + expect(store.updater.update).not.toHaveBeenCalled() + }) + + it('shows update errors and supports retry without stale feedback', async () => { + vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Write denied')) + const heading = await createHeading() + button(heading)?.click() + await vi.waitFor(() => expect(heading.friendState).toBe('error')) + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain('Write denied') + button(heading)?.click() + await vi.waitFor(() => expect(heading.friendState).toBe('exists')) + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('[role="alert"]')).toBeNull() + expect(heading.friendMessage).toBe('Friend was added!') + }) + + it('shows load errors rather than enabling an unchecked action', async () => { + vi.mocked(store.fetcher.load).mockRejectedValueOnce(new Error('Profile unavailable')) + const heading = await createHeading() + expect(heading.friendState).toBe('error') + expect(heading.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain('Profile unavailable') + expect(store.updater.update).not.toHaveBeenCalled() + }) + + it('maps unauthenticated update errors to the original login message', async () => { + vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Unauthenticated')) + const heading = await createHeading() + button(heading)?.click() + await vi.waitFor(() => expect(heading.friendState).toBe('error')) + expect(heading.friendMessage).toBe('Current user not found! Not logged in?') + }) + + it('rejects a friendship that was added after the initial check', async () => { + const heading = await createHeading() + vi.mocked(store.whether).mockReturnValue(1) + button(heading)?.click() + await vi.waitFor(() => expect(heading.friendState).toBe('error')) + expect(heading.friendMessage).toBe('Friend already exists') + expect(store.updater.update).not.toHaveBeenCalled() + }) + + it('does not write if the viewer logs out while loading their profile', async () => { + const heading = await createHeading() + vi.mocked(store.fetcher.load).mockImplementationOnce(async () => { + vi.mocked(authn.currentUser).mockReturnValue(null) + return new Response() + }) + button(heading)?.click() + await vi.waitFor(() => expect(heading.friendState).toBe('error')) + expect(store.updater.update).not.toHaveBeenCalled() + }) + + it('ignores a stale friend check after navigation', async () => { + let finish: (() => void) | undefined + vi.mocked(store.fetcher.load).mockImplementationOnce(() => new Promise(resolve => { + finish = () => resolve(new Response()) + })) + const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading + heading.profileData = { name: 'First' } + heading.friendSubject = subject + document.body.appendChild(heading) + await heading.updateComplete + heading.friendSubject = sym('https://second-friend.example/#me') + await vi.waitFor(() => expect(heading.friendState).toBe('available')) + vi.mocked(store.whether).mockReturnValue(1) + finish?.() + await Promise.resolve() + await heading.updateComplete + expect(heading.friendState).toBe('available') + }) + + it('places friends before Edit when both actions are provided', async () => { + const heading = await createHeading() + heading.editContext = { + subject, + profileData: { name: 'Friend Test', entryNode: subject }, + viewerMode: 'owner', + onSaved: vi.fn() + } + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('.profile__actions solid-ui-button')).toBe(button(heading)) + expect(heading.shadowRoot?.querySelector('.desktop-edit-button')).not.toBeNull() + }) + + it.each([ + ['profile', viewer, subject, true], + ['profile', viewer, viewer, false], + ['profile', null, subject, false], + ['social', viewer, subject, false], + ['profile', viewer, sym('https://new-friend.example/#me'), true] + ])('gates the action for pane %s, viewer %s, subject %s', async (paneName, me, viewed, visible) => { + vi.mocked(authn.currentUser).mockReturnValue(me) + store.add(viewed, ns.vcard('fn'), lit('Gating Test'), viewed.doc()) + const main = document.createElement('main') + main.id = 'MainContent' + document.body.appendChild(main) + window.history.replaceState({ paneName, paneUri: viewed.value }, '', '/') + await refreshProfileHeading(true) + const heading = main.querySelector('solid-panes-profile-heading') + await heading?.updateComplete + expect(Boolean(button(heading!))).toBe(visible) + expect(Boolean(heading?.friendSubject)).toBe(visible) + }) +}) diff --git a/test/unit/mainPage/profileHeadingImage.test.ts b/test/unit/mainPage/profileHeadingImage.test.ts new file mode 100644 index 00000000..08a29276 --- /dev/null +++ b/test/unit/mainPage/profileHeadingImage.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type ProfileHeading from '../../../src/components/profile-heading/ProfileHeading' +import '../../../src/components/profile-heading/ProfileHeading' + +afterEach(() => { + document.body.replaceChildren() +}) + +describe('profile heading image fallback', () => { + it('retries an image source that failed earlier once the source changes and returns', async () => { + const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading + heading.profileData = { name: 'Image Test', imageSrc: 'https://example.org/a.png' } + document.body.appendChild(heading) + await heading.updateComplete + + heading.shadowRoot?.querySelector('img')?.dispatchEvent(new Event('error')) + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('img')).toBeNull() + + heading.profileData = { name: 'Image Test', imageSrc: 'https://example.org/b.png' } + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('img')?.getAttribute('src')).toBe('https://example.org/b.png') + + heading.profileData = { name: 'Image Test', imageSrc: 'https://example.org/a.png' } + await heading.updateComplete + expect(heading.shadowRoot?.querySelector('img')?.getAttribute('src')).toBe('https://example.org/a.png') + }) +}) diff --git a/test/unit/mainPage/reload.test.ts b/test/unit/mainPage/reload.test.ts new file mode 100644 index 00000000..9aaf5e5d --- /dev/null +++ b/test/unit/mainPage/reload.test.ts @@ -0,0 +1,241 @@ +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, 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: '', + label: () => name, + render: (subject: { value: string }) => { + const div = document.createElement('div') + div.textContent = `${name} pane for ${subject.value}` + return div + } + }) +})) + +vi.mock('pane-registry', () => ({ byName })) + +vi.mock('../../../src/components/profile-heading/profileHeadingData', async (importOriginal) => ({ + ...await importOriginal(), + getViewerMode: getViewerModeMock +})) +vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({ + ...await importOriginal(), + loadProfileFromURI: loadProfile +})) +vi.mock('../../../src/utils/podUtils', async (importOriginal) => ({ + ...await importOriginal(), + getPodStorages: async () => [], + loadContainerRepresentation: async () => undefined +})) +vi.mock('../../../src/mainPage/header', () => ({ createHeader: vi.fn() })) +vi.mock('../../../src/index', async () => { + const { default: OutlineManager } = await import('../../../src/outline/manager') + const paneRegistry = { list: [], byName } + return { + 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({}, '', '/') +}) + +describe('page reload of a WebID', () => { + it.each([ + ['after using the account menu', (webId: string) => ({ + paneName: 'folder', + paneUri: `${window.location.origin}/storage/`, + viaAccountMenu: true + })], + ['after selecting Friends in the navbar', (webId: string) => ({ paneName: 'social', paneUri: webId })] + ])('shows the profile with the profile selected in the navbar and its heading %s', async (_name, savedState) => { + const viewer = sym(`${window.location.origin}/profile/card#me`) + const other = sym(`${window.location.origin}/other/profile/card#me`) + store.add(other, ns.vcard('fn'), lit('Someone Else'), other.doc()) + vi.spyOn(authn, 'currentUser').mockReturnValue(viewer) + vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer) + vi.spyOn(authSession.events, 'on').mockImplementation(() => {}) + getViewerModeMock.mockResolvedValue('visitor') + window.history.replaceState(savedState(other.value), '', other.value) + + await initMainPage(store) + const mount = document.getElementById('profile-heading-mount')! + await vi.waitFor(() => expect(mount.querySelector('solid-panes-profile-heading')).not.toBeNull()) + const heading = mount.querySelector('solid-panes-profile-heading') as HTMLElement & { + profileData?: { name: string } + } + const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & { + 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') + expect(mount.hidden).toBe(false) + expect(heading.profileData?.name).toBe('Someone Else') + expect(navbar).not.toHaveClass('navbar--hidden') + 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').mockReturnValue(authSession.events) + 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').mockReturnValue(authSession.events) + 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').mockReturnValue(authSession.events) + 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').mockReturnValue(authSession.events) + 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').mockReturnValue(authSession.events) + 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..4136ff2e --- /dev/null +++ b/test/unit/navigation/chrome.test.ts @@ -0,0 +1,206 @@ +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], + [true, false, true], + [false, true, true], + [true, true, true] + ])('account menu %s, own profile %s -> hidden %s', (viaAccountMenu, ownProfile, hidden) => { + expect(isNavbarHidden({ viaAccountMenu, ownProfile })).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/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 693663a4..3e060812 100644 --- a/test/unit/outline/manager.test.ts +++ b/test/unit/outline/manager.test.ts @@ -1,4 +1,12 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { authn, store } from 'solid-logic' + +const { isInViewerPodMock } = vi.hoisted(() => ({ isInViewerPodMock: vi.fn() })) + +vi.mock('../../../src/navigation/podContext', async (importOriginal) => ({ + ...await importOriginal(), + isPageInViewerPod: isInViewerPodMock +})) vi.mock('solid-ui', async () => { const actual = await vi.importActual('solid-ui') @@ -12,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' @@ -32,6 +41,352 @@ const mockPaneRegistry = { } describe('manager', () => { + describe('navbar visibility during navigation', () => { + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it.each([ + ['https://owner.example/profile/card#me', true], + ['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) => { + 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) + document.body.appendChild(navbar) + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } }) + + manager.GotoSubject(sym(uri), false) + + expect(navbar.classList.contains('navbar--hidden')).toBe(hidden) + }) + + it('preserves account-menu hiding when automatic reveal is suppressed', () => { + vi.spyOn(authn, 'currentUser').mockReturnValue(sym('https://owner.example/profile/card#me')) + const navbar = document.createElement('solid-panes-navbar') + navbar.classList.add('navbar--hidden') + document.body.appendChild(navbar) + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } }) + + manager.GotoSubject(sym('https://visitor.example/profile/card#me'), false, undefined, false, undefined, undefined, false) + + expect(navbar).toHaveClass('navbar--hidden') + }) + }) + + describe('account-menu history state', () => { + afterEach(() => { + document.body.replaceChildren() + 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`) } + const subject = sym(`${window.location.origin}/other/profile/card#me`) + + manager.GotoSubject(subject, false, pane, true, undefined, undefined, false) + expect(window.history.state).toMatchObject({ paneName: 'mock', viaAccountMenu: true }) + expect(window.location.href).toBe(subject.uri) + + manager.GotoSubject(subject, false, pane, true) + expect(window.history.state).toMatchObject({ paneName: 'mock' }) + expect(window.history.state).not.toHaveProperty('viaAccountMenu') + }) + + it('displays the account-menu pane\'s own subject while the URL stays on the page subject', () => { + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } }) + const container = sym(`${window.location.origin}/other/container/`) + const profile = sym(`${window.location.origin}/profile/card#me`) + window.history.replaceState({}, '', container.uri) + const table = document.createElement('table') + const pane = { ...MockPane, subject: profile } + + manager.GotoSubject(container, false, pane, true, undefined, table, false) + + expect(table.querySelector('td.obj')).toHaveAttribute('about', `<${profile.uri}>`) + expect(window.location.href).toBe(container.uri) + expect(window.history.state).toMatchObject({ paneName: 'mock', paneUri: profile.uri, viaAccountMenu: true }) + }) + + it('displays a navbar pane\'s own subject, and the page subject for panes without one', () => { + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } }) + const page = sym(`${window.location.origin}/other/container/sub/`) + const storage = sym(`${window.location.origin}/other/`) + window.history.replaceState({}, '', page.uri) + + const navbarTable = document.createElement('table') + manager.GotoSubject(page, false, { ...MockPane, subject: storage }, true, undefined, navbarTable) + expect(navbarTable.querySelector('td.obj')).toHaveAttribute('about', `<${storage.uri}>`) + expect(window.location.href).toBe(page.uri) + expect(window.history.state).toMatchObject({ paneName: 'mock', paneUri: storage.uri }) + + const homeTable = document.createElement('table') + manager.GotoSubject(page, false, MockPane, true, undefined, homeTable, false) + expect(homeTable.querySelector('td.obj')).toHaveAttribute('about', `<${page.uri}>`) + }) + + describe('opening the URL inside a storage', () => { + const root = `${window.location.origin}/other/` + + // A folder pane that lists known children as expandable rows, like the real one + function setup (children: Record) { + const folderPane = { + name: 'folder', + icon: '', + label: () => 'Contents', + render: (subject: { uri: string }, context: { getOutliner: (dom: Document) => OutlineManager, dom: Document }) => { + const div = document.createElement('div') + const rows = document.createElement('table') + for (const child of children[subject.uri] ?? []) { + const tr = document.createElement('tr') + tr.appendChild(context.getOutliner(context.dom).outlineObjectTD(sym(child), undefined, undefined, undefined)) + rows.appendChild(tr) + } + div.appendChild(rows) + return div + } + } + const manager: OutlineManager = new OutlineManager({ + dom: document, + getOutliner: () => manager, + session: { paneRegistry: { list: [folderPane], byName: () => folderPane }, store } + }) + return { manager, folderPane } + } + + async function opened (target: string, children: Record) { + const { manager, folderPane } = setup(children) + window.history.replaceState({}, '', target) + const table = document.createElement('table') + document.body.appendChild(table) + manager.GotoSubject(sym(target), true, { ...folderPane, subject: sym(root) }, true, undefined, table) + return table + } + + const isExpanded = (table: HTMLElement, uri: string) => + table.querySelector(`td.obj[about="<${uri}>"]`)?.firstElementChild?.nodeName === 'TABLE' + + const tree = { + [root]: [`${root}a/`, `${root}z/`], + [`${root}a/`]: [`${root}a/b/`, `${root}a/other.ttl`], + [`${root}a/b/`]: [`${root}a/b/file.ttl`] + } + + it('expands each level down to a container in the URL, leaving siblings closed', async () => { + const table = await opened(`${root}a/b/`, tree) + + await vi.waitFor(() => { + expect(isExpanded(table, `${root}a/`)).toBe(true) + expect(isExpanded(table, `${root}a/b/`)).toBe(true) + }) + expect(isExpanded(table, `${root}z/`)).toBe(false) + expect(table.querySelector(`td.obj[about="<${root}a/other.ttl>"]`)).not.toBeNull() + expect(window.location.href).toBe(`${root}a/b/`) + }) + + it('opens the containers of a resource in the URL', async () => { + const table = await opened(`${root}a/b/file.ttl`, tree) + + await vi.waitFor(() => { + expect(isExpanded(table, `${root}a/`)).toBe(true) + expect(isExpanded(table, `${root}a/b/`)).toBe(true) + }) + }) + + it('opens nothing when the URL is the storage itself or outside it', async () => { + const atRoot = await opened(root, tree) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(isExpanded(atRoot, `${root}a/`)).toBe(false) + + const outside = await opened(`${window.location.origin}/elsewhere/a/`, tree) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(isExpanded(outside, `${root}a/`)).toBe(false) + }) + }) + + it('renders the account-menu profile for the viewer without a file explorer header, even on another person\'s container URL', async () => { + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry, store } }) + const container = sym(`${window.location.origin}/other/container/`) + const profile = sym(`${window.location.origin}/me/profile/card#me`) + window.history.replaceState({}, '', container.uri) + const table = document.createElement('table') + document.body.appendChild(table) + const pane = { ...MockPane, name: 'profile', paneName: 'profile', subject: profile } + + manager.GotoSubject(container, true, pane, true, undefined, table, false) + const provider = await vi.waitFor(() => { + const element = table.querySelector('file-explorer-provider') + expect(element).not.toBeNull() + return element! + }) + + expect(provider.subjectUri).toBe(profile.uri) + expect(provider.showHeader).toBe(false) + expect(table).toHaveTextContent(`Mock Pane for ${profile.uri}`) + expect(window.location.href).toBe(container.uri) + }) + + describe('file explorer header', () => { + const container = sym(`${window.location.origin}/other/folder/file.ttl`) + + beforeEach(() => { + isInViewerPodMock.mockReset() + }) + async function headerShown (showNavbar: boolean, pane: object = MockPane) { + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry, store } }) + window.history.replaceState({}, '', container.uri) + const table = document.createElement('table') + document.body.appendChild(table) + manager.GotoSubject(container, true, pane, true, undefined, table, showNavbar) + const provider = await vi.waitFor(() => { + const element = table.querySelector('file-explorer-provider') + expect(element).not.toBeNull() + return element! + }) + return provider.showHeader + } + + it('is hidden for account-menu views when the URL is not in the viewer\'s pod', async () => { + isInViewerPodMock.mockResolvedValue(false) + + expect(await headerShown(false)).toBe(false) + expect(await headerShown(false, { ...MockPane, subject: sym(`${window.location.origin}/me/storage/docs/`) })).toBe(false) + expect(isInViewerPodMock).toHaveBeenCalledWith(container.uri) + }) + + it('is kept for account-menu views when the URL is in the viewer\'s pod', async () => { + isInViewerPodMock.mockResolvedValue(true) + + expect(await headerShown(false)).toBe(true) + }) + + it('is not affected for navbar navigation to a pane without its own subject', async () => { + isInViewerPodMock.mockResolvedValue(false) + + expect(await headerShown(true)).toBe(true) + expect(isInViewerPodMock).not.toHaveBeenCalled() + }) + + it('is hidden for navbar navigation to a pane with its own subject outside the viewer\'s pod', async () => { + isInViewerPodMock.mockResolvedValue(false) + const storage = { ...MockPane, subject: sym(`${window.location.origin}/other/storage/`) } + + expect(await headerShown(true, storage)).toBe(false) + expect(isInViewerPodMock).toHaveBeenCalledWith(container.uri) + }) + + it('is kept for navbar navigation to a pane with its own subject inside the viewer\'s pod', async () => { + isInViewerPodMock.mockResolvedValue(true) + const storage = { ...MockPane, subject: sym(`${window.location.origin}/other/storage/`) } + + expect(await headerShown(true, storage)).toBe(true) + }) + }) + + it('does not carry pane details into the entry of a different subject', () => { + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } }) + const first = sym(`${window.location.origin}/a/profile/card#me`) + const second = sym(`${window.location.origin}/b/profile/card#me`) + window.history.replaceState({ other: 'kept' }, '', first.uri) + manager.GotoSubject(first, false, { ...MockPane, subject: first }, true) + const entries = window.history.length + + manager.GotoSubject(second, false, undefined, true) + + expect(window.history.length).toBe(entries + 1) + expect(window.location.href).toBe(second.uri) + expect(window.history.state).toEqual({ other: 'kept' }) + }) + + it('keeps pane details when the same subject is rendered again without a pane', () => { + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } }) + const subject = sym(`${window.location.origin}/a/profile/card#me`) + window.history.replaceState({}, '', subject.uri) + manager.GotoSubject(subject, false, { ...MockPane, subject }, true) + + manager.GotoSubject(subject, false, undefined, true) + + expect(window.history.state).toEqual({ paneName: 'mock', paneUri: subject.uri }) + }) + + it('replaces a saved pane URI when the new pane does not render another subject', () => { + const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } }) + const subject = sym(`${window.location.origin}/a/profile/card#me`) + window.history.replaceState({}, '', subject.uri) + manager.GotoSubject(subject, false, { ...MockPane, subject: sym(`${window.location.origin}/storage/`) }, true) + + manager.GotoSubject(subject, false, { ...MockPane, name: 'other' }, true) + + expect(window.history.state).toEqual({ paneName: 'other' }) + }) + }) + describe('outline object td', () => { describe('for a named node', () => { let result diff --git a/vite.config.mts b/vite.config.mts index b9def924..e3272f70 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -16,6 +16,8 @@ if (isWatch && build && Array.isArray(build.rolldownOptions?.output)) { build.rolldownOptions.output = build.rolldownOptions.output.filter( (o: { format?: string }) => o.format === 'es', ) + + build.watch = { exclude: ['**/dist/**'] } } type ConcretePlugin = Extract