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
108 changes: 67 additions & 41 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 Down Expand Up @@ -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 @@ -128,57 +133,74 @@ export default {
},

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)) {
// another open is already awaiting this one
this.loadingTab = true
return
return true
}
Comment on lines +170 to 179

this.initializingTabs.add(tab.tagName)
this.loadingTab = true
this.tabError = ''

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.')
logger.error('Failed to initialize a sidebar tab in Notes', { error, tab: tab.id })
return false
} finally {
this.initializingTabs.delete(tab.tagName)
this.loadingTab = 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 +215,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,21 +229,25 @@ 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 }) {
onShareOpen({ noteId }) {
return this.onSidebarOpen({ noteId, tab: 'sharing' })
},

async onSidebarOpen({ noteId, tab = 'sharing' }) {
this.contextRequestToken += 1
this.noteId = Number(noteId)
this.activeTab = 'sharing'
this.activeTab = tab
this.isOpen = true
Comment on lines +247 to 251
this.contextError = ''
this.tabError = ''
Expand All @@ -230,14 +256,14 @@ export default {
this.loadingContext = false
this.loadingTab = false

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
48 changes: 48 additions & 0 deletions src/sidebarTabs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/**
* 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 {
return false
}
Comment on lines +41 to +45
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
}
Loading