diff --git a/package-lock.json b/package-lock.json index 7f2bce31..754d12d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "chat-pane": "4.0.0", "contacts-pane": "4.0.0", "dompurify": "^3.4.4", - "folder-pane": "4.0.1-0", + "folder-pane": "4.0.1-1", "issue-pane": "4.0.0", "lit-html": "^3.3.2", "marked": "^18.0.3", @@ -522,9 +522,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "devOptional": true, "license": "MIT", "engines": { @@ -581,13 +581,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -894,9 +894,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", - "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.11.tgz", + "integrity": "sha512-iARF4jjHSM0v8DImCACPXrwEltBP3DgqDpXTvjAMBAVpyEY0LiGss78wvI+KtmW+IJNEvZ5sOhPDCsOsXsIybA==", "dev": true, "license": "MIT", "dependencies": { @@ -944,9 +944,9 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", - "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.11.tgz", + "integrity": "sha512-f8kTDM/nTvpLeafb+TJ4jboFr/Y3BbC+QzgPpPGzSKJqWLnj9LW0WLqv4XKA11LX5sr+EgvLE6Ix3YsCEusodw==", "dev": true, "license": "MIT", "dependencies": { @@ -955,7 +955,7 @@ "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.10" }, "engines": { "node": ">=6.9.0" @@ -1581,9 +1581,9 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", - "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.11.tgz", + "integrity": "sha512-5xWGhfuMgbOfIjsk/I2c4uMnoqRwQdKYi4QjvybQrcEVE0G9/p3qmIkp2Sxe8YF8DGNNVs5whfwNqIM5684XoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1921,13 +1921,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { @@ -3649,9 +3649,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz", - "integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", "dev": true, "license": "MIT", "engines": { @@ -4554,9 +4554,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.31.tgz", - "integrity": "sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==", + "version": "2.11.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", + "integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5337,9 +5337,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.463", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz", - "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==", + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", "dev": true, "license": "ISC" }, @@ -6145,9 +6145,9 @@ "license": "ISC" }, "node_modules/folder-pane": { - "version": "4.0.1-0", - "resolved": "https://registry.npmjs.org/folder-pane/-/folder-pane-4.0.1-0.tgz", - "integrity": "sha512-BbgIQa/2QX9oCKK5wxIlKXdsazyIMxQRJMtYHEe3I4kzE/udzKS7SjN5mQsR2E/yrTu3WC7l1OEcYBzivqEi2A==", + "version": "4.0.1-1", + "resolved": "https://registry.npmjs.org/folder-pane/-/folder-pane-4.0.1-1.tgz", + "integrity": "sha512-cQSKnPUYPhDGHpK7ayUFYCeDpHoJ3XgMUPjig2Xx9YYYLye5G0RVzMGMdNi8utRCFkR46mHyMu/XNNazuOBOcw==", "license": "MIT", "dependencies": { "lit": "^3.3.3", diff --git a/package.json b/package.json index 0efc3c66..7a501c5a 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "chat-pane": "4.0.0", "contacts-pane": "4.0.0", "dompurify": "^3.4.4", - "folder-pane": "4.0.1-0", + "folder-pane": "4.0.1-1", "issue-pane": "4.0.0", "lit-html": "^3.3.2", "marked": "^18.0.3", diff --git a/src/components/profile-heading/ProfileHeading.styles.css b/src/components/profile-heading/ProfileHeading.styles.css index b1c04103..ed988b04 100644 --- a/src/components/profile-heading/ProfileHeading.styles.css +++ b/src/components/profile-heading/ProfileHeading.styles.css @@ -241,6 +241,10 @@ align-self: center; } +:host([compact]) .profile__heading-edit-action { + align-self: center; +} + :host([compact]) .name { white-space: normal; overflow-wrap: anywhere; diff --git a/src/components/profile-heading/ProfileHeading.ts b/src/components/profile-heading/ProfileHeading.ts index 1198fc5c..759250c6 100644 --- a/src/components/profile-heading/ProfileHeading.ts +++ b/src/components/profile-heading/ProfileHeading.ts @@ -221,7 +221,7 @@ export default class ProfileHeading extends WebComponent { - ${(this.editContext || showFriend) && !this.compact + ${(this.editContext && !this.compact) || showFriend ? html`
${showFriend @@ -246,7 +246,7 @@ export default class ProfileHeading extends WebComponent {
` : nothing} - ${this.editContext + ${this.editContext && !this.compact ? html` { + resetPodContexts() + navigation.show({ kind: 'restore', forceHeading: true }).catch((error: unknown) => { + console.error('Failed to refresh navigation after an authentication change.', error) + }) + } + authSession.events.on('login', refreshNavigationAfterAuth) + authSession.events.on('sessionRestore', refreshNavigationAfterAuth) + authSession.events.on('logout', refreshNavigationAfterAuth) + authSession.events.on('sessionChange', refreshNavigationAfterAuth) + authSession.events.on('identityReplaced', refreshNavigationAfterAuth) await navigation.show({ kind: 'restore', subject: subject.uri }) const header = await createHeader(navigation) diff --git a/src/mainPage/profileHeading.ts b/src/mainPage/profileHeading.ts index 084f8fff..15720cc8 100644 --- a/src/mainPage/profileHeading.ts +++ b/src/mainPage/profileHeading.ts @@ -7,7 +7,7 @@ import type { ViewerMode } from '../types' import { loadProfileFromURI } from '../utils/webIdUtils' import '../components/profile-heading' import './profileHeading.css' -import { deriveHeading } from '../navigation/chrome' +import { deriveHeading, offersFriendAction } from '../navigation/chrome' import { getPodContext } from '../navigation/podContext' import { onNavigated } from '../navigation/events' import { readNavigationState } from '../navigation/state' @@ -212,7 +212,7 @@ export async function refreshProfileHeading (force = false): Promise { } heading.profileData = profileData const viewer = authn.currentUser() - heading.friendSubject = isProfilePane && viewer && !viewer.sameTerm(subject) + heading.friendSubject = offersFriendAction(decision.paneName) && viewer && !viewer.sameTerm(subject) ? subject : undefined heading.editContext = canEdit @@ -243,7 +243,7 @@ export function placeProfileHeading (): void { ensureMountPoint() } -export function initializeProfileHeading (): void { +export function initializeProfileHeading (): Promise { ensureMountPoint() if (!profileSavedListenerRegistered) { @@ -296,4 +296,5 @@ export function initializeProfileHeading (): void { readiness.then(() => refreshProfileHeading(true)).catch((error: unknown) => { console.error('Failed to refresh profile heading.', error) }) + return readiness } diff --git a/src/navigation/chrome.ts b/src/navigation/chrome.ts index 7cd9eacc..5a37a295 100644 --- a/src/navigation/chrome.ts +++ b/src/navigation/chrome.ts @@ -9,6 +9,7 @@ */ import { type NavigationView } from './state' +import type { FolderView } from './options' /** Registry names of the panes that present a person rather than a resource. */ export const PERSON_PANES = ['profile', 'social'] @@ -17,6 +18,11 @@ export function isPersonPane (paneName?: string): boolean { return paneName !== undefined && PERSON_PANES.includes(paneName) } +/** Whether the heading over this pane offers to add its person as a friend: on the Profile and Friends tabs. */ +export function offersFriendAction (paneName?: string): boolean { + return isPersonPane(paneName) +} + /** The parts of a navbar item that the rules look at. */ export interface NavbarItemRef { paneName?: string @@ -121,6 +127,24 @@ export function isNavbarHidden (input: { viaAccountMenu: boolean, ownProfile: bo return input.viaAccountMenu || input.ownProfile || input.inViewerPod } +// Storage + +export interface StorageViewInput { + viaAccountMenu: boolean + /** The storage is one of the viewer's own. */ + inViewerPod: boolean + /** The storage belongs to a person whose profile was found; the server root has none. */ + hasOwner: boolean +} + +/** + * The viewer's own storage, and the server root, come with the folder pane's storage header. + * Someone else's storage sits under the navbar of their pod, which already says whose it is. + */ +export function deriveStorageView (input: StorageViewInput): Extract { + return input.viaAccountMenu || input.inViewerPod || !input.hasOwner ? 'explorer' : 'storage' +} + // File explorer header export interface ExplorerHeaderInput { @@ -140,7 +164,11 @@ export function deriveExplorerHeader (input: ExplorerHeaderInput): boolean { 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 { +/** + * The header is never shown over a person's profile or friends, whatever the resource, + * nor over a storage view, which has its own header or the navbar. + */ +export function explorerHeaderForPane (resourceHeader: boolean, paneName?: string, folderView?: FolderView): boolean { + if (paneName === 'folder' && (folderView === 'explorer' || folderView === 'storage')) return false return resourceHeader && !isPersonPane(paneName) } diff --git a/src/navigation/controller.ts b/src/navigation/controller.ts index 2d405409..01fbfdc6 100644 --- a/src/navigation/controller.ts +++ b/src/navigation/controller.ts @@ -13,8 +13,10 @@ 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 type { FolderView, NavigationSource } from './options' import { isWebIdUri } from '../utils/webIdUtils' +import { deriveStorageView } from './chrome' +import { getPodContext, isInViewerPod, type PodContext } from './podContext' export type NavigationIntent = /** Display what the current history entry says was displayed, for the page's own URL. */ @@ -42,6 +44,8 @@ export interface NavigationDeps { outliner: OutlineManager byName: (name: string) => PaneDefinition | null | undefined effects: NavigationEffects + /** Whose pod a URL is in; `getPodContext` unless a test supplies its own. */ + podContextOf?: (url: string) => Promise } export interface NavigationController { @@ -55,9 +59,28 @@ export interface NavigationController { type DisplayedPane = PaneItem | PaneDefinition | null | undefined export function createNavigationController (deps: NavigationDeps): NavigationController { - const { store, outliner, byName, effects } = deps + const { store, outliner, byName, effects, podContextOf = getPodContext } = deps let latest = 0 + /** The storage pane for a page that is a storage of its pod, or the server root. */ + async function storageAt (subject: NamedNode): Promise { + if (!subject.uri.endsWith('/')) return undefined + const isServerRoot = subject.site().uri === subject.uri + const { storagePanes, viewerStorages } = await podContextOf(subject.uri) + const isStorage = storagePanes.some(pane => pane.subject.uri === subject.uri) || + viewerStorages.includes(subject.uri) + return isServerRoot || isStorage ? getFolderPaneForStorage(subject) : undefined + } + + async function folderViewFor (storage: string, viaAccountMenu: boolean): Promise { + const context = viaAccountMenu ? undefined : await podContextOf(storage) + return deriveStorageView({ + viaAccountMenu, + inViewerPod: !!context && isInViewerPod(context, storage), + hasOwner: !!context?.owner + }) + } + async function paneForView (subject: NamedNode, view: NavigationView): Promise { // These panes render a subject other than the page URL. if (view.subject) { @@ -67,7 +90,8 @@ export function createNavigationController (deps: NavigationDeps): NavigationCon if (view.kind === 'storage') return getFolderPaneForStorage(paneSubject) } if (view.pane) return byName(view.pane) ?? undefined - return isWebIdUri(subject) ? getProfilePaneFromURI(subject, byName) : undefined + // With nothing recorded, the URL decides: a WebID shows its profile, a storage its storage view + return isWebIdUri(subject) ? getProfilePaneFromURI(subject, byName) : storageAt(subject) } async function paneFor (intent: NavigationIntent, subject: NamedNode, state: NavigationState): Promise { @@ -101,7 +125,11 @@ export function createNavigationController (deps: NavigationDeps): NavigationCon return } - outliner.GotoSubject(subject, true, pane ?? undefined, true, undefined, undefined, { source }) + const storage = pane && pane.name === 'folder' ? (pane as PaneItem).subject?.uri : undefined + const folderView = storage ? await folderViewFor(storage, source === 'accountMenu') : undefined + if (request !== latest) return + + outliner.GotoSubject(subject, true, pane ?? undefined, true, undefined, undefined, folderView ? { source, folderView } : { source }) if (source !== 'accountMenu') { const { view } = readNavigationState() diff --git a/src/navigation/options.ts b/src/navigation/options.ts index 5e93c49a..00102f56 100644 --- a/src/navigation/options.ts +++ b/src/navigation/options.ts @@ -6,8 +6,22 @@ */ export type NavigationSource = 'default' | 'accountMenu' +/** + * How the folder pane presents a container, when the caller decides it: + * - `explorer`: storage header, sidebar and content + * - `storage`: sidebar and content, under the navbar of someone else's pod + * - `container`: the container alone + */ +export type FolderView = 'explorer' | 'storage' | 'container' + export interface GotoSubjectOptions { source?: NavigationSource + folderView?: FolderView +} + +/** The folder view a `GotoSubject` caller asked for, if any. */ +export function resolveFolderView (argument?: boolean | GotoSubjectOptions | null): FolderView | undefined { + return typeof argument === 'object' && argument !== null ? argument.folderView : undefined } /** diff --git a/src/navigation/podContext.ts b/src/navigation/podContext.ts index 143963f1..71f6d2a0 100644 --- a/src/navigation/podContext.ts +++ b/src/navigation/podContext.ts @@ -81,8 +81,9 @@ function ownerOf (pageUrl: string): Promise { }, undefined) } -function storagesOf (pageUrl: string): Promise { - return remember(storages, podKey(pageUrl), 'storages of the pod', () => getFolderPanesFromURI(sym(pageUrl)), []) +function storagesOf (pageUrl: string, owner?: string): Promise { + const storageSubject = owner ? sym(owner) : sym(pageUrl) + return remember(storages, podKey(pageUrl), 'storages of the pod', () => getFolderPanesFromURI(storageSubject), []) } function storagesOfViewer (viewer: string | undefined): Promise { @@ -95,9 +96,10 @@ function storagesOfViewer (viewer: string | undefined): Promise { /** 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) + const [owner, ownStorages] = await Promise.all([ + ownerOf(pageUrl), storagesOfViewer(viewer) ]) + const storagePanes = await storagesOf(pageUrl, owner) return { viewer, owner, storagePanes, viewerStorages: ownStorages } } diff --git a/src/outline/manager.js b/src/outline/manager.js index b48c72c1..513de6d1 100644 --- a/src/outline/manager.js +++ b/src/outline/manager.js @@ -17,12 +17,13 @@ import { deriveExplorerHeader, explorerHeaderForPane } from '../navigation/chrom import { isPageInViewerPod } from '../navigation/podContext' import { nextHistoryState } from '../navigation/state' import { announceNavigation } from '../navigation/events' -import { resolveSource } from '../navigation/options' +import { resolveFolderView, resolveSource } from '../navigation/options' import { refreshNavbarVisibility } from '../navigation/navbarVisibility' import { createOutlineDomHelpers } from './outlineDomHelpers' import { getPaneIcon, getPaneItems } from './fileExplorerHelpers' import 'solid-ui/components/file-explorer-provider' import 'solid-ui/components/file-explorer-header' +import 'solid-ui/components/error-state' import AccessControlModal from 'solid-ui/components/access-control-modal' const EXPLORER_PROVIDER_TAG = 'solid-ui-file-explorer-provider' @@ -312,7 +313,7 @@ export default function (context) { subjectUri: subject.uri, pane, relevantPanes: provider.relevantPanes || [], - header: state && explorerHeaderForPane(state.showHeader, pane?.name) + header: state && explorerHeaderForPane(state.showHeader, pane?.name, state.options.folderView) ? { paneIcon: getPaneIcon(pane, subject, context), menuItems: state.menuItems } : undefined, paneElement @@ -380,12 +381,12 @@ export default function (context) { : await loadRelevantPanes(subject, context) if (explorerStates.get(provider) !== state) return provider - // Resolve indexed containers only for a resource URL, never for an explicit Storage view. + // Resolve indexed containers only for a resource URL, never for an explicit storage or folder view. const resourceLogic = context.session.logic?.resource || solidLogicSingleton.resource const indexDocument = subject.uri.endsWith('/') ? $rdf.sym(`${subject.uri}index.ttl`) : null - const hasIndexDocument = options.solo && !requiredPane && indexDocument && ( + const hasIndexDocument = options.solo && !requiredPane && !options.folderView && indexDocument && ( (typeof resourceLogic?.containerHasIndexDocument === 'function' && resourceLogic.containerHasIndexDocument(subject)) || kb.holds(subject, UI.ns.ldp('contains'), indexDocument, subject.doc()) @@ -1515,8 +1516,16 @@ export default function (context) { let subject = kb.canon(subject1) // var requTerm = subject.uri ? kb.sym($rdf.uri.docpart(subject.uri)) : subject + // A solo view the viewer may not read shows only its access error, never a fallback view. + let soloAccessDenied = false function render () { + if (soloAccessDenied) { + // A denial seen before the session was ready is lifted once the document loads. + if (sf.getState(subject.doc().uri) !== 'fetched') return false + soloAccessDenied = false + already = false + } subject = kb.canon(subject) if (!p || !p.parentNode || !p.parentNode.parentNode) return false @@ -1610,6 +1619,12 @@ export default function (context) { if (options.solo) { setUrlBarAndTitle(subject) } + // Global panes render their own application view and do not depend on the page URL. + // Do not let an inaccessible URL replace a dashboard or other global pane with an error. + if (pane?.global) { + render() + return + } UI.log.debug('outlineExpand: dereferencing ' + subject) const status = dom.createElement('span') p.appendChild(status) @@ -1650,12 +1665,49 @@ export default function (context) { if (detailText.includes('status: 401') || detailText.includes('status 401')) return 401 return null } + const soloAccessMessage = function () { + const isRootResource = !!(subject && subject.uri && subject.site && subject.site().uri === subject.uri) + const loggedIn = !!solidLogicSingleton.authn.currentUser() + if (isRootResource) { + return loggedIn + ? 'You are logged in, but this root resource is not publicly readable. Select a storage location from the account menu to continue.' + : 'This root resource is not publicly readable. Try logging in or opening a profile document.' + } + return loggedIn + ? 'You are logged in, but your account does not have permission to view this resource.' + : 'This resource is not publicly readable. Log in to view it.' + } + const showSoloAccessError = function () { + soloAccessDenied = true + const errorState = dom.createElement('solid-ui-error-state') + errorState.setAttribute('title', 'Access denied') + errorState.setAttribute('message', soloAccessMessage()) + if (docUri) errorState.setAttribute('data-outline-error-for', docUri) + UI.utils.emptyNode(p).appendChild(errorState) + sf.addCallback('done', function soloAccessRecovered (uri) { + if (!soloAccessDenied || !p.isConnected) return false + if (uri !== docUri) return true + render() + return false + }) + if (sf.getState(docUri) === 'fetched') render() + } const appendOutlineError = function (detail, errObj) { if (p.querySelector && docUri) { const existing = p.querySelector('[data-outline-error-for="' + docUri + '"]') if (existing) return } const statusCode = authErrorStatus(detail, errObj) + if (statusCode && options.solo) { + // The denial may answer a request made before the session was restored. + if (!retriedAuthenticated && solidLogicSingleton.authn.currentUser()) { + retriedAuthenticated = true + loadSubjectDocument(true) + return + } + showSoloAccessError() + return + } const friendlyDetail = statusCode ? outlineAuthMessage(statusCode) : detail @@ -1669,28 +1721,34 @@ export default function (context) { p.appendChild(message) } // Wait till at least the main URI is loaded before expanding: - let errorReported = false - const fetchPromise = sf.nowOrWhenFetched(subject.doc(), undefined, function (ok, body) { - if (ok) { - sf.lookUpThing(subject) - render() // inital open, or else full if re-open - if (options.solo) { - // Update window title with new information - // dom.title = UI.utils.label(subject) - setUrlBarAndTitle(subject) + let retriedAuthenticated = false + const loadSubjectDocument = function (force) { + let errorReported = false + const fetchPromise = sf.nowOrWhenFetched(subject.doc(), force ? { force: true } : {}, function (ok, body) { + if (ok) { + sf.lookUpThing(subject) + render() // inital open, or else full if re-open + if (options.solo) { + // Update window title with new information + // dom.title = UI.utils.label(subject) + setUrlBarAndTitle(subject) + } + } else { + errorReported = true + appendOutlineError(body, undefined) } - } else { - errorReported = true - appendOutlineError(body, undefined) - } - }) - if (fetchPromise && typeof fetchPromise.catch === 'function') { - fetchPromise.catch(function (err) { - if (errorReported) return - errorReported = true - appendOutlineError(err, err) }) + if (fetchPromise && typeof fetchPromise.catch === 'function') { + fetchPromise.catch(function (err) { + if (errorReported) return + errorReported = true + appendOutlineError(err, err) + }) + } } + // A solo view retries a document an earlier, possibly unauthenticated, request was denied. + const previousStatus = sf.requested[docUri] + loadSubjectDocument(!!options.solo && (previousStatus === 401 || previousStatus === 403)) } else { render() } @@ -1835,6 +1893,7 @@ export default function (context) { @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. + `folderView` ('explorer', 'storage' or 'container') says how a folder is presented. Afterwards, when `solo`, `solid-panes:navigated` is dispatched on the document. */ @@ -1875,6 +1934,7 @@ export default function (context) { outlineExpand(subjectBlock, displayedSubject, { pane, solo, + folderView: resolveFolderView(options), headerOnlyInViewerPod: !showNavbar || !!(pane && pane.subject && pane.subject.uri) }) } else if (!solo) { diff --git a/test/unit/mainPage/index.test.ts b/test/unit/mainPage/index.test.ts index 59b3c12c..52a841a0 100644 --- a/test/unit/mainPage/index.test.ts +++ b/test/unit/mainPage/index.test.ts @@ -304,7 +304,7 @@ describe('browser navigation without a reload', () => { const [subject, , pane, , , , options] = GotoSubject.mock.calls[0] expect(subject.value).toBe(pageUrl) expect(pane).toBe(storagePane) - expect(options).toEqual({ source: 'accountMenu' }) + expect(options).toEqual({ source: 'accountMenu', folderView: 'explorer' }) expect(window.location.href).toBe(pageUrl) expect(window.history.state).toMatchObject({ paneName: 'folder', paneUri: storage, viaAccountMenu: true }) expect(navbar).toHaveClass('navbar--hidden') diff --git a/test/unit/mainPage/profileHeadingFriends.test.ts b/test/unit/mainPage/profileHeadingFriends.test.ts index 5ef31ae5..b1d38c66 100644 --- a/test/unit/mainPage/profileHeadingFriends.test.ts +++ b/test/unit/mainPage/profileHeadingFriends.test.ts @@ -234,6 +234,20 @@ describe('profile heading friends', () => { expect(heading.friendState).toBe('available') }) + it('keeps the friend action but hides Edit in the compact heading', async () => { + const heading = await createHeading() + heading.editContext = { + subject, + profileData: { name: 'Friend Test', entryNode: subject }, + viewerMode: 'owner', + onSaved: vi.fn() + } + heading.compact = true + await heading.updateComplete + expect(button(heading)).not.toBeNull() + expect(heading.shadowRoot?.querySelector('.desktop-edit-button')).toBeNull() + }) + it('places friends before Edit when both actions are provided', async () => { const heading = await createHeading() heading.editContext = { @@ -251,7 +265,8 @@ describe('profile heading friends', () => { ['profile', viewer, subject, true], ['profile', viewer, viewer, false], ['profile', null, subject, false], - ['social', viewer, subject, false], + ['social', viewer, subject, true], + ['social', viewer, viewer, 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) diff --git a/test/unit/navigation/chrome.test.ts b/test/unit/navigation/chrome.test.ts index 944c0a29..0c2e16fa 100644 --- a/test/unit/navigation/chrome.test.ts +++ b/test/unit/navigation/chrome.test.ts @@ -3,10 +3,12 @@ import { deriveExplorerHeader, deriveHeading, deriveNavbarSelection, + deriveStorageView, explorerHeaderForPane, isNavbarHidden, isNavbarItemSelected, isPersonPane, + offersFriendAction, pickNavbarItem, type HeadingInput } from '../../../src/navigation/chrome' @@ -39,6 +41,14 @@ describe('isPersonPane', () => { ) }) +describe('offersFriendAction', () => { + it.each([['profile', true], ['social', true], ['folder', false], [undefined, false]])( + 'the heading over %s offers the friend action: %s', (pane, expected) => { + expect(offersFriendAction(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', () => { @@ -205,4 +215,30 @@ describe('file explorer header', () => { expect(explorerHeaderForPane(true, undefined)).toBe(true) expect(explorerHeaderForPane(false, 'folder')).toBe(false) }) + + it('is never shown over a storage view, which has its own header or the navbar', () => { + expect(explorerHeaderForPane(true, 'folder', 'explorer')).toBe(false) + expect(explorerHeaderForPane(true, 'folder', 'storage')).toBe(false) + expect(explorerHeaderForPane(true, 'folder', 'container')).toBe(true) + // A pane opened in place of the folder keeps its resource header + expect(explorerHeaderForPane(true, 'source', 'explorer')).toBe(true) + }) +}) + +describe('storage view', () => { + it('shows the viewer\'s own storage with the storage header', () => { + expect(deriveStorageView({ viaAccountMenu: false, inViewerPod: true, hasOwner: true })).toBe('explorer') + }) + + it('shows the storage header for the account menu, whatever the page', () => { + expect(deriveStorageView({ viaAccountMenu: true, inViewerPod: false, hasOwner: true })).toBe('explorer') + }) + + it('shows the server root, which nobody owns, with the storage header', () => { + expect(deriveStorageView({ viaAccountMenu: false, inViewerPod: false, hasOwner: false })).toBe('explorer') + }) + + it('shows someone else\'s storage with only the sidebar and content, under their navbar', () => { + expect(deriveStorageView({ viaAccountMenu: false, inViewerPod: false, hasOwner: true })).toBe('storage') + }) }) diff --git a/test/unit/navigation/controller.test.ts b/test/unit/navigation/controller.test.ts index 914f639d..0bf69eaf 100644 --- a/test/unit/navigation/controller.test.ts +++ b/test/unit/navigation/controller.test.ts @@ -34,6 +34,7 @@ let GotoSubject: Mock<(...args: any[]) => void> let selectNavbarPane: Mock let refreshHeading: Mock let byName: Mock +let podContextOf: Mock> let controller: NavigationController let navbar: HTMLElement @@ -54,11 +55,13 @@ beforeEach(() => { selectNavbarPane = vi.fn(() => { calls.push('select') }) refreshHeading = vi.fn(async () => { calls.push('heading') }) byName = vi.fn((name: string) => (name === 'home' ? homePane as never : null)) + podContextOf = vi.fn(async () => ({ owner, storagePanes: [], viewerStorages: [] })) controller = createNavigationController({ store, outliner: { GotoSubject } as unknown as OutlineManager, byName, - effects: { selectNavbarPane, refreshHeading } + effects: { selectNavbarPane, refreshHeading }, + podContextOf }) navbar = document.createElement('solid-panes-navbar') document.body.appendChild(navbar) @@ -268,3 +271,92 @@ describe('when intents overlap', () => { expect(GotoSubject).toHaveBeenCalledTimes(2) }) }) + +describe('how a storage is presented', () => { + const folderViewOfCall = (index = 0) => GotoSubject.mock.calls[index][6]?.folderView + const storageItem = (uri: string) => ({ name: 'folder', paneName: 'folder', subject: store.sym(uri) }) + + it('shows someone else\'s storage from the navbar with only the sidebar and content', async () => { + await controller.show({ kind: 'storage', pane: storagePane as never }) + + expect(podContextOf).toHaveBeenCalledWith('https://pod.example/timea/') + expect(GotoSubject.mock.calls[0][6]).toEqual({ source: 'default', folderView: 'storage' }) + }) + + it('shows a storage from the account menu with the storage header, without looking up its pod', async () => { + await controller.show({ kind: 'storage', pane: storagePane as never, source: 'accountMenu' }) + + expect(podContextOf).not.toHaveBeenCalled() + expect(folderViewOfCall()).toBe('explorer') + }) + + it('shows someone else\'s pod root as their storage, selected in the navbar', async () => { + const root = `${window.location.origin}/timea/` + const rootPane = storageItem(root) + getFolderPane.mockResolvedValue(rootPane) + podContextOf.mockResolvedValue({ owner, storagePanes: [rootPane as never], viewerStorages: [] }) + window.history.replaceState({}, '', '/timea/') + + await controller.show({ kind: 'restore' }) + + expect(getFolderPane).toHaveBeenCalledWith(store.sym(root)) + expect(GotoSubject.mock.calls[0][2]).toBe(rootPane) + expect(folderViewOfCall()).toBe('storage') + expect(selectNavbarPane).toHaveBeenCalledWith('folder', root) + }) + + it('shows the viewer\'s own pod root with the storage header', async () => { + const root = `${window.location.origin}/timea/` + const rootPane = storageItem(root) + getFolderPane.mockResolvedValue(rootPane) + podContextOf.mockResolvedValue({ owner, viewer: owner, storagePanes: [rootPane as never], viewerStorages: [root] }) + window.history.replaceState({}, '', '/timea/') + + await controller.show({ kind: 'restore' }) + + expect(folderViewOfCall()).toBe('explorer') + }) + + it('recognizes the viewer\'s exact storage URL when it is not listed as a storage pane', async () => { + const root = `${window.location.origin}/timea/` + const rootPane = storageItem(root) + getFolderPane.mockResolvedValue(rootPane) + podContextOf.mockResolvedValue({ owner, viewer: owner, storagePanes: [], viewerStorages: [root] }) + window.history.replaceState({}, '', '/timea/') + + await controller.show({ kind: 'restore' }) + + expect(folderViewOfCall()).toBe('explorer') + }) + + it('does not treat a subcontainer of the viewer\'s storage as the storage itself', async () => { + const root = `${window.location.origin}/timea/` + const subcontainer = `${root}documents/` + getFolderPane.mockResolvedValue(storageItem(subcontainer)) + podContextOf.mockResolvedValue({ owner, viewer: owner, storagePanes: [], viewerStorages: [root] }) + window.history.replaceState({}, '', '/timea/documents/') + + await controller.show({ kind: 'restore' }) + + expect(folderViewOfCall()).toBeUndefined() + }) + + it('shows the server root, which nobody owns, with the storage header', async () => { + const root = `${window.location.origin}/` + getFolderPane.mockResolvedValue(storageItem(root)) + podContextOf.mockResolvedValue({ storagePanes: [], viewerStorages: [] }) + window.history.replaceState({}, '', '/') + + await controller.show({ kind: 'restore' }) + + expect(getFolderPane).toHaveBeenCalledWith(store.sym(root)) + expect(folderViewOfCall()).toBe('explorer') + }) + + it('leaves a container that is not a storage to its own default view', async () => { + await controller.show({ kind: 'restore' }) + + expect(getFolderPane).not.toHaveBeenCalled() + expect(GotoSubject.mock.calls[0][6]).toEqual({ source: 'default' }) + }) +}) diff --git a/test/unit/navigation/podContext.test.ts b/test/unit/navigation/podContext.test.ts index 4d03f0bf..4603f00d 100644 --- a/test/unit/navigation/podContext.test.ts +++ b/test/unit/navigation/podContext.test.ts @@ -52,6 +52,7 @@ describe('getPodContext', () => { viewerStorages: ['https://pod.example/other/'] }) expect(context.storagePanes.map(pane => pane.subject.value)).toEqual(['https://pod.example/timea/']) + expect(getFolderPanes).toHaveBeenCalledWith(owner) }) it('looks up the storages of a pod and of the viewer once, however many of its pages are asked about', async () => { diff --git a/test/unit/navigation/storagePane.test.ts b/test/unit/navigation/storagePane.test.ts index 6579605a..e07a6a20 100644 --- a/test/unit/navigation/storagePane.test.ts +++ b/test/unit/navigation/storagePane.test.ts @@ -14,6 +14,16 @@ describe('explicit Storage navigation', () => { expect(render).toHaveBeenCalledWith(storage, context, { solo: false, fullView: false }) }) + it('passes on the folder view the navigation chose', () => { + const render = vi.fn() + const storage = sym('https://pod.example/storage/') + const pane = createPaneItem({ name: 'folder', render }, storage, 'folder', 'Storage', '') + + pane.render(storage, {}, { solo: true, folderView: 'storage' }) + + expect(render).toHaveBeenCalledWith(storage, {}, { solo: false, fullView: false, folderView: 'storage' }) + }) + it('preserves solo rendering options for non-storage panes', () => { const render = vi.fn() const subject = sym('https://pod.example/profile/card#me')