Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/components/NoteItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@
{{ t('notes', 'Share') }}
</NcActionButton>

<NcActionButton @click="onShowVersions">
<template #icon>
<BackupRestoreIcon :size="20" />
</template>
{{ t('notes', 'Versions') }}
</NcActionButton>

<NcActionButton v-if="!showCategorySelect" @click="showCategorySelect = true">
<template #icon>
<FolderOutlineIcon :size="20" />
Expand Down Expand Up @@ -106,6 +113,7 @@ import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import AlertOctagonOutlineIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
import BackupRestoreIcon from 'vue-material-design-icons/BackupRestore.vue'
import FolderOutlineIcon from 'vue-material-design-icons/FolderOutline.vue'
import PencilOutlineIcon from 'vue-material-design-icons/PencilOutline.vue'
import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue'
Expand All @@ -120,6 +128,7 @@ export default {

components: {
AlertOctagonOutlineIcon,
BackupRestoreIcon,
FolderOutlineIcon,
NcActionButton,
NcListItem,
Expand Down Expand Up @@ -337,6 +346,11 @@ export default {
emit('notes:share:open', { noteId: this.note.id })
},

onShowVersions() {
this.actionsOpen = false
emit('notes:sidebar:open', { noteId: this.note.id, tab: 'files_versions' })
},

async onShareCreated(event) {
const { share } = event

Expand Down
159 changes: 111 additions & 48 deletions src/components/NoteShareSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@
data-cy-notes-share-sidebar
forceMenu
:loading="isOpen && loading"
:name="note?.title || t('notes', 'Share')"
:name="note?.title || t('notes', 'Note')"
noToggle
:open="isOpen"
@closed="onClosed"
@update:open="onToggle"
>
<NcAppSidebarTab
v-if="sharingTab"
:id="sharingTab.id"
:name="sharingTab.displayName"
:order="sharingTab.order"
<NcAppSidebarTab v-for="tab in tabs"
:id="tab.id"
:key="tab.id"
:name="tab.displayName"
:order="tab.order"
>
<template #icon>
<NcIconSvgWrapper :svg="sharingTab.iconSvgInline" />
<NcIconSvgWrapper :svg="tab.iconSvgInline" />
</template>

<NcEmptyContent v-if="loading">
Expand All @@ -33,26 +33,26 @@

<NcEmptyContent v-else-if="!currentNode || error">
<template #icon>
<ShareVariantOutlineIcon :size="44" />
<FileOutlineIcon :size="44" />
</template>
{{ error || t('notes', 'Unable to load the selected note for sharing.') }}
{{ error || t('notes', 'Unable to load the selected note.') }}
</NcEmptyContent>

<component
:is="sharingTab.tagName"
:is="tab.tagName"
v-else
:active.prop="activeTab === sharingTab.id"
:active.prop="activeTab === tab.id"
:folder.prop="currentFolder"
:node.prop="currentNode"
:view.prop="currentView"
/>
</NcAppSidebarTab>

<NcEmptyContent v-else-if="isOpen">
<NcEmptyContent v-if="isOpen && tabs.length === 0">
<template #icon>
<ShareVariantOutlineIcon :size="44" />
<FileOutlineIcon :size="44" />
</template>
{{ t('notes', 'Sharing is not available right now.') }}
{{ t('notes', 'Sharing and versions are not available right now.') }}
</NcEmptyContent>
</NcAppSidebar>
</template>
Expand All @@ -65,8 +65,9 @@ import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue'
import FileOutlineIcon from 'vue-material-design-icons/FileOutline.vue'
import logger from '../Logger.js'
import { selectNoteSidebarTabs } from '../sidebarTabs.js'
import store from '../store.js'
import { fetchDavNode } from '../WebdavService.js'

Expand All @@ -79,7 +80,7 @@ export default {
NcEmptyContent,
NcIconSvgWrapper,
NcLoadingIcon,
ShareVariantOutlineIcon,
FileOutlineIcon,
},

data() {
Expand All @@ -89,7 +90,7 @@ export default {
contextRequestToken: 0,
currentFolder: null,
currentNode: null,
initializingTabs: new Set(),
pendingTabs: new Map(),
initializedTabs: new Set(),
isOpen: false,
loadingContext: false,
Expand All @@ -115,8 +116,12 @@ export default {
return store.notes.getNote(this.noteId)
},

sharingTab() {
return getSidebarTabs().find((tab) => tab.id === 'sharing') || null
tabs() {
return selectNoteSidebarTabs(getSidebarTabs(), {
node: this.currentNode,
folder: this.currentFolder,
view: this.currentView,
})
},

currentView() {
Expand All @@ -127,58 +132,96 @@ export default {
},
},

watch: {
// the versions tab drops out once the node says it is not applicable,
// so what was requested is not necessarily still renderable
tabs(tabs) {
this.activeTab = this.resolveTab(this.activeTab, tabs)
},
},

mounted() {
// the share event is kept so anything already emitting it keeps working
subscribe('notes:share:open', this.onShareOpen)
subscribe('notes:sidebar:open', this.onSidebarOpen)
},

unmounted() {
unsubscribe('notes:share:open', this.onShareOpen)
unsubscribe('notes:sidebar:open', this.onSidebarOpen)
},

methods: {
async initializeSharingTab() {
const tab = this.sharingTab
if (!tab) {
async initializeTabs() {
const tabs = this.tabs
if (tabs.length === 0) {
this.loadingTab = false
this.tabError = this.t('notes', 'Sharing is not available right now.')
this.tabError = this.t('notes', 'Sharing and versions are not available right now.')
return
}

// One tab failing to define its element must not hide the others, so
// they are initialised independently and only a total failure is
// reported as an error.
const results = await Promise.all(tabs.map((tab) => this.initializeTab(tab)))

this.loadingTab = false
this.tabError = results.includes(true)
? ''
: this.t('notes', 'Failed to load the note sidebar.')
},

/**
* @param {object} tab a registered Files sidebar tab
* @return {Promise<boolean>} whether the tab is usable
*/
async initializeTab(tab) {
if (window.customElements.get(tab.tagName) || this.initializedTabs.has(tab.tagName)) {
this.loadingTab = false
this.tabError = ''
return
return true
}

if (this.initializingTabs.has(tab.tagName)) {
this.loadingTab = true
return
this.loadingTab = true

// an open while another one is still initializing the same element
// has to await that initialization, not assume it succeeded
const pending = this.pendingTabs.get(tab.tagName)
if (pending) {
return pending
}
Comment thread
AndyScherzinger marked this conversation as resolved.

this.initializingTabs.add(tab.tagName)
this.loadingTab = true
this.tabError = ''
const initialization = this.defineTabElement(tab)
this.pendingTabs.set(tab.tagName, initialization)

try {
return await initialization
} finally {
this.pendingTabs.delete(tab.tagName)
}
},

/**
* @param {object} tab a registered Files sidebar tab
* @return {Promise<boolean>} whether its custom element got defined
*/
async defineTabElement(tab) {
try {
await tab.onInit?.()
await window.customElements.whenDefined(tab.tagName)
this.initializedTabs.add(tab.tagName)
return true
} catch (error) {
logger.error('Failed to initialize the sharing sidebar tab in Notes', { error })
this.tabError = this.t('notes', 'Failed to load the sharing sidebar.')
} finally {
this.initializingTabs.delete(tab.tagName)
this.loadingTab = false
logger.error('Failed to initialize a sidebar tab in Notes', { error, tab: tab.id })
return false
}
},

async loadShareContext() {
async loadNodeContext() {
const internalPath = this.note?.internalPath
if (!internalPath) {
this.loadingContext = false
this.currentNode = null
this.currentFolder = null
this.contextError = this.t('notes', 'Unable to load the selected note for sharing.')
this.contextError = this.t('notes', 'Unable to load the selected note.')
return
}

Expand All @@ -193,7 +236,7 @@ export default {
try {
folder = await fetchDavNode(node.dirname || '/')
} catch (error) {
logger.error('Failed to load the parent folder for the Notes sharing sidebar', { error })
logger.error('Failed to load the parent folder for the Notes sidebar', { error })
}

if (requestToken !== this.contextRequestToken) {
Expand All @@ -207,37 +250,57 @@ export default {
return
}

logger.error('Failed to load the selected note for the Notes sharing sidebar', { error })
logger.error('Failed to load the selected note for the Notes sidebar', { error })
this.currentNode = null
this.currentFolder = null
this.contextError = this.t('notes', 'Unable to load the selected note for sharing.')
this.contextError = this.t('notes', 'Unable to load the selected note.')
} finally {
if (requestToken === this.contextRequestToken) {
this.loadingContext = false
}
}
},

async onShareOpen({ noteId }) {
/**
* NcAppSidebar falls back to its first tab when the active one is not
* among them, but does not report that back, so the tab id here has to
* be clamped as well for `active` to reach the right custom element.
*
* @param {string} tab the requested tab id
* @param {Array<object>} tabs the tabs currently rendered
* @return {string} the requested tab if renderable, the first one otherwise
*/
resolveTab(tab, tabs) {
if (tabs.length === 0 || tabs.some(({ id }) => id === tab)) {
return tab
}
return tabs[0].id
},

onShareOpen({ noteId }) {
return this.onSidebarOpen({ noteId, tab: 'sharing' })
},

async onSidebarOpen({ noteId, tab = 'sharing' }) {
this.contextRequestToken += 1
this.noteId = Number(noteId)
this.activeTab = 'sharing'
this.isOpen = true
Comment thread
AndyScherzinger marked this conversation as resolved.
this.contextError = ''
this.tabError = ''
this.currentNode = null
this.currentFolder = null
this.loadingContext = false
this.loadingTab = false
this.activeTab = this.resolveTab(tab, this.tabs)

if (!this.sharingTab) {
await this.initializeSharingTab()
if (this.tabs.length === 0) {
await this.initializeTabs()
return
}

await Promise.all([
this.initializeSharingTab(),
this.loadShareContext(),
this.initializeTabs(),
this.loadNodeContext(),
])
},

Expand Down
51 changes: 51 additions & 0 deletions src/sidebarTabs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import logger from './Logger.js'

/**
* Files sidebar tabs the Notes sidebar hosts, and nothing else.
*
* Notes dispatches OCA\Files\Event\LoadSidebar when rendering its page, so every
* app that registers a sidebar tab has registered one by the time this runs —
* including tabs that make no sense for a note. This is an allow-list so a newly
* installed app cannot start appearing in the Notes sidebar unannounced.
*
* @type {string[]}
*/
export const NOTE_SIDEBAR_TAB_IDS = ['sharing', 'files_versions']

/**
* The tabs to render, in the order the registering apps asked for.
*
* A tab's own `enabled()` predicate has the final say — the versions tab for
* instance hides itself on public shares and for anything that is not a file —
* but it needs a node to judge, so while the node is still loading the tabs are
* kept and filtered again once it arrives. A predicate that throws is treated as
* "not usable" rather than being allowed to take the sidebar down.
*
* @param {Array<object>} tabs all registered tabs, from getSidebarTabs()
* @param {object} context what the tab is being asked about
* @param {object|null} context.node the note's DAV node, null while loading
* @param {object|null} context.folder the note's parent folder
* @param {object|null} context.view the pseudo view Notes reports
* @return {Array<object>} tabs to render, sorted by their declared order
*/
export function selectNoteSidebarTabs(tabs, { node = null, folder = null, view = null } = {}) {
return (tabs ?? [])
.filter((tab) => NOTE_SIDEBAR_TAB_IDS.includes(tab?.id))
.filter((tab) => {
if (typeof tab.enabled !== 'function' || node === null) {
return true
}
try {
return tab.enabled({ node, folder, view })
} catch (error) {
logger.error('Sidebar tab predicate failed in Notes, dropping the tab', { error, tab: tab.id })
return false
}
Comment thread
AndyScherzinger marked this conversation as resolved.
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
}
Loading