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