diff options
46 files changed, 4607 insertions, 109 deletions
diff --git a/.babelrc.js b/.babelrc.js index 5cfbddd7a0a..004c14b5119 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -1,5 +1,8 @@ module.exports = { - plugins: ['@babel/plugin-syntax-dynamic-import'], + plugins: [ + '@babel/plugin-syntax-dynamic-import', + ['@babel/plugin-proposal-class-properties', { loose: true }] + ], presets: [ [ '@babel/preset-env', @@ -30,6 +30,7 @@ lint-fix-watch: clean: rm -rf apps/accessibility/js/ rm -rf apps/comments/js/ + rm -rf apps/files/js/dist/ rm -rf apps/files_sharing/js/dist/ rm -rf apps/files_trashbin/js/ rm -rf apps/files_versions/js/ @@ -47,6 +48,7 @@ clean-dev: clean-git: clean git checkout -- apps/accessibility/js/ git checkout -- apps/comments/js/ + git checkout -- apps/files/js/dist/ git checkout -- apps/files_sharing/js/dist/ git checkout -- apps/files_trashbin/js/ git checkout -- apps/files_versions/js/ diff --git a/apps/comments/src/filesplugin.js b/apps/comments/src/filesplugin.js index e315dd2fef8..3e0cdd7f706 100644 --- a/apps/comments/src/filesplugin.js +++ b/apps/comments/src/filesplugin.js @@ -104,7 +104,7 @@ actionHandler: function(fileName, context) { context.$file.find('.action-comment').tooltip('hide') // open sidebar in comments section - context.fileList.showDetailsView(fileName, 'commentsTabView') + context.fileList.showDetailsView(fileName, 'comments') } }) diff --git a/apps/files/css/files.scss b/apps/files/css/files.scss index 54f83f25be2..9c1869d1ffc 100644 --- a/apps/files/css/files.scss +++ b/apps/files/css/files.scss @@ -85,8 +85,9 @@ } /* fit app list view heights */ -.app-files #app-content>.viewcontainer { +.app-files #app-content > .viewcontainer { min-height: 0%; + width: 100%; } .app-files #app-content { diff --git a/apps/files/js/fileactions.js b/apps/files/js/fileactions.js index d800f2b8eb5..571cdcf6c38 100644 --- a/apps/files/js/fileactions.js +++ b/apps/files/js/fileactions.js @@ -704,6 +704,12 @@ } context.fileList.do_delete(fileName, context.dir); $('.tipsy').remove(); + + // close sidebar on delete + const path = context.dir + '/' + fileName + if (OCA.Files.Sidebar && OCA.Files.Sidebar.file === path) { + OCA.Files.Sidebar.file = undefined + } } }); diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index 58e2bfae7ff..8cca43d5749 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -610,11 +610,11 @@ * @param {string} [tabId] optional tab id to select */ showDetailsView: function(fileName, tabId) { + console.warn('showDetailsView is deprecated! Use OCA.Files.Sidebar.activeTab. It will be removed in nextcloud 20.'); this._updateDetailsView(fileName); if (tabId) { - this._detailsView.selectTab(tabId); + OCA.Files.Sidebar.activeTab = tabId; } - OC.Apps.showAppSidebar(this._detailsView.$el); }, /** @@ -623,48 +623,23 @@ * @param {string|OCA.Files.FileInfoModel} fileName file name from the current list or a FileInfoModel object * @param {boolean} [show=true] whether to open the sidebar if it was closed */ - _updateDetailsView: function(fileName, show) { - if (!this._detailsView) { + _updateDetailsView: function(fileName) { + if (!(OCA.Files && OCA.Files.Sidebar)) { + console.error('No sidebar available'); return; } - // show defaults to true - show = _.isUndefined(show) || !!show; - var oldFileInfo = this._detailsView.getFileInfo(); - if (oldFileInfo) { - // TODO: use more efficient way, maybe track the highlight - this.$fileList.children().filterAttr('data-id', '' + oldFileInfo.get('id')).removeClass('highlighted'); - oldFileInfo.off('change', this._onSelectedModelChanged, this); - } - if (!fileName) { - this._detailsView.setFileInfo(null); - if (this._currentFileModel) { - this._currentFileModel.off(); - } - this._currentFileModel = null; - OC.Apps.hideAppSidebar(this._detailsView.$el); - return; + OCA.Files.Sidebar.file = null + return + } else if (typeof fileName !== 'string') { + fileName = '' } - if (show && this._detailsView.$el.hasClass('disappear')) { - OC.Apps.showAppSidebar(this._detailsView.$el); - } - - if (fileName instanceof OCA.Files.FileInfoModel) { - var model = fileName; - } else { - var $tr = this.findFileEl(fileName); - var model = this.getModelForFile($tr); - $tr.addClass('highlighted'); - } - - this._currentFileModel = model; - - this._replaceDetailsViewElementIfNeeded(); - - this._detailsView.setFileInfo(model); - this._detailsView.$el.scrollTop(0); + // open sidebar and set file + const dir = `${this.dirInfo.path}/${this.dirInfo.name}` + const path = `${dir}/${fileName}` + OCA.Files.Sidebar.file = path.replace('//', '/') }, /** @@ -1404,6 +1379,13 @@ return OC.MimeType.getIconUrl('dir-external'); } else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') { return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType); + } else if (fileInfo.shareTypes && ( + fileInfo.shareTypes.indexOf(OC.Share.SHARE_TYPE_LINK) > -1 + || fileInfo.shareTypes.indexOf(OC.Share.SHARE_TYPE_EMAIL) > -1) + ) { + return OC.MimeType.getIconUrl('dir-public') + } else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) { + return OC.MimeType.getIconUrl('dir-shared') } return OC.MimeType.getIconUrl('dir'); } @@ -3654,8 +3636,10 @@ * Register a tab view to be added to all views */ registerTabView: function(tabView) { - if (this._detailsView) { - this._detailsView.addTabView(tabView); + console.warn('registerTabView is deprecated! It will be removed in nextcloud 20.'); + const name = tabView.getLabel() + if (name) { + OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab(name, tabView, true)) } }, @@ -3663,8 +3647,9 @@ * Register a detail view to be added to all views */ registerDetailView: function(detailView) { - if (this._detailsView) { - this._detailsView.addDetailView(detailView); + console.warn('registerDetailView is deprecated! It will be removed in nextcloud 20.'); + if (detailView.el) { + OCA.Files.Sidebar.registerSecondaryView(detailView) } }, diff --git a/apps/files/js/merged-index.json b/apps/files/js/merged-index.json index 8d25daa6b3c..b673da858cb 100644 --- a/apps/files/js/merged-index.json +++ b/apps/files/js/merged-index.json @@ -1,33 +1,34 @@ [ + "dist/sidebar.js", "app.js", - "templates.js", - "file-upload.js", - "newfilemenu.js", - "jquery.fileupload.js", - "jquery-visibility.js", - "fileinfomodel.js", - "filesummary.js", - "filemultiselectmenu.js", "breadcrumb.js", - "filelist.js", - "search.js", - "favoritesfilelist.js", - "recentfilelist.js", - "tagsplugin.js", - "gotoplugin.js", - "favoritesplugin.js", - "recentplugin.js", "detailfileinfoview.js", - "sidebarpreviewmanager.js", - "sidebarpreviewtext.js", - "detailtabview.js", - "semaphore.js", - "mainfileinfodetailview.js", - "operationprogressbar.js", "detailsview.js", + "detailtabview.js", + "favoritesfilelist.js", + "favoritesplugin.js", + "file-upload.js", "fileactions.js", "fileactionsmenu.js", + "fileinfomodel.js", + "filelist.js", + "filemultiselectmenu.js", "files.js", + "filesummary.js", + "gotoplugin.js", + "jquery-visibility.js", + "jquery.fileupload.js", "keyboardshortcuts.js", - "navigation.js" + "mainfileinfodetailview.js", + "navigation.js", + "newfilemenu.js", + "operationprogressbar.js", + "recentfilelist.js", + "recentplugin.js", + "search.js", + "semaphore.js", + "sidebarpreviewmanager.js", + "sidebarpreviewtext.js", + "tagsplugin.js", + "templates.js" ] diff --git a/apps/files/src/components/LegacyTab.vue b/apps/files/src/components/LegacyTab.vue new file mode 100644 index 00000000000..9a85ee7f073 --- /dev/null +++ b/apps/files/src/components/LegacyTab.vue @@ -0,0 +1,89 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <AppSidebarTab :icon="icon" + :name="name" + :active-tab="activeTab" /> +</template> +<script> +import AppSidebarTab from 'nextcloud-vue/dist/Components/AppSidebarTab' + +export default { + name: 'LegacyTab', + components: { + AppSidebarTab: AppSidebarTab + }, + props: { + component: { + type: Object, + required: true + }, + name: { + type: String, + default: '', + required: true + }, + fileInfo: { + type: Object, + default: () => {}, + required: true + } + }, + computed: { + icon() { + return this.component.getIcon() + }, + id() { + // copied from AppSidebarTab + return this.name.toLowerCase().replace(/ /g, '-') + }, + order() { + return this.component.order + ? this.component.order + : 0 + }, + // needed because AppSidebarTab also uses $parent.activeTab + activeTab() { + return this.$parent.activeTab + } + }, + watch: { + activeTab(activeTab) { + if (activeTab === this.id && this.fileInfo) { + this.setFileInfo(this.fileInfo) + } + } + }, + mounted() { + // append the backbone element and set the FileInfo + this.component.$el.appendTo(this.$el) + }, + methods: { + setFileInfo(fileInfo) { + this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo)) + } + } +} +</script> +<style> +</style> diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue new file mode 100644 index 00000000000..e4a07ac3e5e --- /dev/null +++ b/apps/files/src/components/LegacyView.vue @@ -0,0 +1,59 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <div /> +</template> +<script> +export default { + name: 'LegacyView', + props: { + component: { + type: Object, + required: true + }, + fileInfo: { + type: Object, + default: () => {}, + required: true + } + }, + watch: { + fileInfo(fileInfo) { + // update the backbone model FileInfo + this.setFileInfo(fileInfo) + } + }, + mounted() { + // append the backbone element and set the FileInfo + this.component.$el.replaceAll(this.$el) + this.setFileInfo(this.fileInfo) + }, + methods: { + setFileInfo(fileInfo) { + this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo)) + } + } +} +</script> +<style> +</style> diff --git a/apps/files/src/models/Tab.js b/apps/files/src/models/Tab.js new file mode 100644 index 00000000000..28902b0e754 --- /dev/null +++ b/apps/files/src/models/Tab.js @@ -0,0 +1,59 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export default class Tab { + + #component; + #legacy; + #name; + + /** + * Create a new tab instance + * + * @param {string} name the name of this tab + * @param {Object} component the vue component + * @param {boolean} [legacy] is this a legacy tab + */ + constructor(name, component, legacy) { + this.#name = name + this.#component = component + this.#legacy = legacy === true + + if (this.#legacy) { + console.warn('Legacy tabs are deprecated! They will be removed in nextcloud 20.') + } + + } + + get name() { + return this.#name + } + + get component() { + return this.#component + } + + get isLegacyTab() { + return this.#legacy === true + } + +} diff --git a/apps/files/src/services/FileInfo.js b/apps/files/src/services/FileInfo.js new file mode 100644 index 00000000000..aa026df1445 --- /dev/null +++ b/apps/files/src/services/FileInfo.js @@ -0,0 +1,67 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import axios from '@nextcloud/axios' + +export default async function(url) { + const response = await axios({ + method: 'PROPFIND', + url, + data: `<?xml version="1.0"?> + <d:propfind xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:prop> + <d:getlastmodified /> + <d:getetag /> + <d:getcontenttype /> + <d:resourcetype /> + <oc:fileid /> + <oc:permissions /> + <oc:size /> + <d:getcontentlength /> + <nc:has-preview /> + <nc:mount-type /> + <nc:is-encrypted /> + <ocs:share-permissions /> + <oc:tags /> + <oc:favorite /> + <oc:comments-unread /> + <oc:owner-id /> + <oc:owner-display-name /> + <oc:share-types /> + </d:prop> + </d:propfind>` + }) + + // TODO: create new parser or use cdav-lib when available + const file = OCA.Files.App.fileList.filesClient._client.parseMultiStatus(response.data) + // TODO: create new parser or use cdav-lib when available + const fileInfo = OCA.Files.App.fileList.filesClient._parseFileInfo(file[0]) + + // TODO remove when no more legacy backbone is used + fileInfo.get = (key) => fileInfo[key] + fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory' + + return fileInfo +} diff --git a/apps/files/src/services/Sidebar.js b/apps/files/src/services/Sidebar.js new file mode 100644 index 00000000000..8f02a1b51ab --- /dev/null +++ b/apps/files/src/services/Sidebar.js @@ -0,0 +1,109 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export default class Sidebar { + + #state; + #view; + + constructor() { + // init empty state + this.#state = {} + + // init default values + this.#state.tabs = [] + this.#state.views = [] + this.#state.file = '' + this.#state.activeTab = '' + console.debug('OCA.Files.Sidebar initialized') + } + + /** + * Get the sidebar state + * + * @readonly + * @memberof Sidebar + * @returns {Object} the data state + */ + get state() { + return this.#state + } + + /** + * @memberof Sidebar + * Register a new tab view + * + * @param {Object} tab a new unregistered tab + * @memberof Sidebar + * @returns {Boolean} + */ + registerTab(tab) { + const hasDuplicate = this.#state.tabs.findIndex(check => check.name === tab.name) > -1 + if (!hasDuplicate) { + this.#state.tabs.push(tab) + return true + } + console.error(`An tab with the same name ${tab.name} already exists`, tab) + return false + } + + registerSecondaryView(view) { + const hasDuplicate = this.#state.views.findIndex(check => check.cid === view.cid) > -1 + if (!hasDuplicate) { + this.#state.views.push(view) + return true + } + console.error(`A similar view already exists`, view) + return false + } + + /** + * Set the current sidebar file data + * + * @param {string} path the file path to load + * @memberof Sidebar + */ + set file(path) { + this.#state.file = path + } + + /** + * Set the current sidebar file data + * + * @returns {String} the current opened file + * @memberof Sidebar + */ + get file() { + return this.#state.file + } + + /** + * Set the current sidebar tab + * + * @param {string} id the tab unique id + * @memberof Sidebar + */ + set activeTab(id) { + this.#state.activeTab = id + } + +} diff --git a/apps/files/src/sidebar.js b/apps/files/src/sidebar.js new file mode 100644 index 00000000000..b508e8aee20 --- /dev/null +++ b/apps/files/src/sidebar.js @@ -0,0 +1,59 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import Vue from 'vue' +import SidebarView from './views/Sidebar.vue' +import Sidebar from './services/Sidebar' +import Tab from './models/Tab' +import VueClipboard from 'vue-clipboard2' + +Vue.use(VueClipboard) + +Vue.prototype.t = t + +window.addEventListener('DOMContentLoaded', () => { + // Init Sidebar Service + if (window.OCA && window.OCA.Files) { + Object.assign(window.OCA.Files, { Sidebar: new Sidebar() }) + Object.assign(window.OCA.Files.Sidebar, { Tab }) + } + + // Make sure we have a proper layout + if (document.getElementById('content')) { + + // Make sure we have a mountpoint + if (!document.getElementById('app-sidebar')) { + var contentElement = document.getElementById('content') + var sidebarElement = document.createElement('div') + sidebarElement.id = 'app-sidebar' + contentElement.appendChild(sidebarElement) + } + } + + // Init vue app + const AppSidebar = new Vue({ + // eslint-disable-next-line vue/match-component-file-name + name: 'SidebarRoot', + render: h => h(SidebarView) + }) + AppSidebar.$mount('#app-sidebar') +}) diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue new file mode 100644 index 00000000000..9a00df17377 --- /dev/null +++ b/apps/files/src/views/Sidebar.vue @@ -0,0 +1,345 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <AppSidebar + v-if="file" + ref="sidebar" + v-bind="appSidebar" + @close="onClose" + @update:starred="toggleStarred" + @[defaultActionListener].stop.prevent="onDefaultAction"> + <!-- TODO: create a standard to allow multiple elements here? --> + <template v-if="fileInfo" #primary-actions> + <LegacyView v-for="view in views" + :key="view.cid" + :component="view" + :file-info="fileInfo" /> + </template> + + <!-- Error display --> + <div v-if="error" class="emptycontent"> + <div class="icon-error" /> + <h2>{{ error }}</h2> + </div> + + <!-- If fileInfo fetch is complete, display tabs --> + <template v-for="tab in tabs" v-else-if="fileInfo"> + <component + :is="tabComponent(tab).is" + v-if="canDisplay(tab)" + :key="tab.id" + :component="tabComponent(tab).component" + :name="tab.name" + :file-info="fileInfo" /> + </template> + </AppSidebar> +</template> +<script> +import $ from 'jquery' +import axios from '@nextcloud/axios' +import AppSidebar from 'nextcloud-vue/dist/Components/AppSidebar' +import FileInfo from '../services/FileInfo' +import LegacyTab from '../components/LegacyTab' +import LegacyView from '../components/LegacyView' + +export default { + name: 'Sidebar', + + components: { + AppSidebar, + LegacyView + }, + + data() { + return { + // reactive state + Sidebar: OCA.Files.Sidebar.state, + error: null, + fileInfo: null, + starLoading: false + } + }, + + computed: { + /** + * Current filename + * This is bound to the Sidebar service and + * is used to load a new file + * @returns {string} + */ + file() { + return this.Sidebar.file + }, + + /** + * List of all the registered tabs + * @returns {Array} + */ + tabs() { + return this.Sidebar.tabs + }, + + /** + * List of all the registered views + * @returns {Array} + */ + views() { + return this.Sidebar.views + }, + + /** + * Current user dav root path + * @returns {string} + */ + davPath() { + const user = OC.getCurrentUser().uid + return OC.linkToRemote(`dav/files/${user}${encodeURIComponent(this.file)}`) + }, + + /** + * Current active tab handler + * @param {string} id the tab id to set as active + * @returns {string} the current active tab + */ + activeTab: { + get: function() { + return this.Sidebar.activeTab + }, + set: function(id) { + OCA.Files.Sidebar.activeTab = id + } + }, + + /** + * Sidebar subtitle + * @returns {string} + */ + subtitle() { + return `${this.size}, ${this.time}` + }, + + /** + * File last modified formatted string + * @returns {string} + */ + time() { + return OC.Util.relativeModifiedDate(this.fileInfo.mtime) + }, + + /** + * File size formatted string + * @returns {string} + */ + size() { + return OC.Util.humanFileSize(this.fileInfo.size) + }, + + /** + * File background/figure to illustrate the sidebar header + * @returns {string} + */ + background() { + return this.getPreviewIfAny(this.fileInfo) + }, + + /** + * App sidebar v-binding object + * + * @returns {Object} + */ + appSidebar() { + if (this.fileInfo) { + return { + background: this.background, + active: this.activeTab, + class: { 'has-preview': this.fileInfo.hasPreview }, + compact: !this.fileInfo.hasPreview, + 'star-loading': this.starLoading, + starred: this.fileInfo.isFavourited, + subtitle: this.subtitle, + title: this.fileInfo.name + } + } else if (this.error) { + return { + key: 'error', // force key to re-render + subtitle: '', + title: '' + } + } else { + return { + class: 'icon-loading', + subtitle: '', + title: '' + } + } + }, + + /** + * Default action object for the current file + * + * @returns {Object} + */ + defaultAction() { + return this.fileInfo + && OCA.Files && OCA.Files.App && OCA.Files.App.fileList + && OCA.Files.App.fileList + .fileActions.getDefaultFileAction(this.fileInfo.mimetype, this.fileInfo.type, OC.PERMISSION_READ) + + }, + + /** + * Dynamic header click listener to ensure + * nothing is listening for a click if there + * is no default action + * + * @returns {string|null} + */ + defaultActionListener() { + return this.defaultAction ? 'figure-click' : null + } + }, + + watch: { + // update the sidebar data + async file(curr, prev) { + this.resetData() + if (curr && curr.trim() !== '') { + try { + this.fileInfo = await FileInfo(this.davPath) + // adding this as fallback because other apps expect it + this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') + + // DEPRECATED legacy views + // TODO: remove + this.views.forEach(view => { + view.setFileInfo(this.fileInfo) + }) + + this.$nextTick(() => { + if (this.$refs.sidebar) { + this.$refs.sidebar.updateTabs() + } + }) + } catch (error) { + this.error = t('files', 'Error while loading the file data') + console.error('Error while loading the file data') + } + } + } + }, + + methods: { + /** + * Can this tab be displayed ? + * + * @param {Object} tab a registered tab + * @returns {boolean} + */ + canDisplay(tab) { + if (tab.isLegacyTab) { + return this.fileInfo && tab.component.canDisplay && tab.component.canDisplay(this.fileInfo) + } + // if the tab does not have an enabled method, we assume it's always available + return tab.enabled ? tab.enabled(this.fileInfo) : true + }, + onClose() { + this.resetData() + OCA.Files.Sidebar.file = '' + }, + resetData() { + this.error = null + this.fileInfo = null + this.$nextTick(() => { + if (this.$refs.sidebar) { + this.$refs.sidebar.updateTabs() + } + }) + }, + getPreviewIfAny(fileInfo) { + if (fileInfo.hasPreview) { + return OC.generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`) + } + return OCA.Files.App.fileList._getIconUrl(fileInfo) + }, + + tabComponent(tab) { + if (tab.isLegacyTab) { + return { + is: LegacyTab, + component: tab.component + } + } + return { + is: tab.component + } + }, + + /** + * Toggle favourite state + * TODO: better implementation + * + * @param {Boolean} state favourited or not + */ + async toggleStarred(state) { + try { + this.starLoading = true + await axios({ + method: 'PROPPATCH', + url: this.davPath, + data: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> + ${state ? '<d:set>' : '<d:remove>'} + <d:prop> + <oc:favorite>1</oc:favorite> + </d:prop> + ${state ? '</d:set>' : '</d:remove>'} + </d:propertyupdate>` + }) + } catch (error) { + OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file')) + console.error('Unable to change favourite state', error) + } + this.starLoading = false + }, + + onDefaultAction() { + if (this.defaultAction) { + // generate fake context + this.defaultAction.action(this.fileInfo.name, { + fileInfo: this.fileInfo, + dir: this.fileInfo.dir, + fileList: OCA.Files.App.fileList, + $file: $('body') + }) + } + } + } +} +</script> +<style lang="scss" scoped> +#app-sidebar { + &.has-preview::v-deep .app-sidebar-header__figure { + background-size: cover; + } +} +</style> diff --git a/apps/files/webpack.js b/apps/files/webpack.js new file mode 100644 index 00000000000..4007722031b --- /dev/null +++ b/apps/files/webpack.js @@ -0,0 +1,13 @@ +const path = require('path'); + +module.exports = { + entry: { + 'sidebar': path.join(__dirname, 'src', 'sidebar.js'), + }, + output: { + path: path.resolve(__dirname, './js/dist/'), + publicPath: '/js/', + filename: '[name].js', + chunkFilename: 'files.[id].js' + } +} diff --git a/apps/files_sharing/appinfo/app.php b/apps/files_sharing/appinfo/app.php index 747c2020746..32159f7b973 100644 --- a/apps/files_sharing/appinfo/app.php +++ b/apps/files_sharing/appinfo/app.php @@ -43,6 +43,7 @@ $eventDispatcher->addListener( 'OCA\Files::loadAdditionalScripts', function() { \OCP\Util::addScript('files_sharing', 'dist/additionalScripts'); + \OCP\Util::addStyle('files_sharing', 'icons'); } ); \OC::$server->getEventDispatcher()->addListener('\OCP\Collaboration\Resources::loadAdditionalScripts', function () { diff --git a/apps/files_sharing/css/icons.scss b/apps/files_sharing/css/icons.scss new file mode 100644 index 00000000000..002235b6e32 --- /dev/null +++ b/apps/files_sharing/css/icons.scss @@ -0,0 +1,32 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +// This is the icons used in the sharing ui (multiselect) +.icon-room { + @include icon-color('app', 'spreed', $color-black); +} +.icon-circle { + @include icon-color('circles', 'circles', $color-black, 3, false); +} +.icon-guests { + @include icon-color('app', 'guests', $color-black); +}
\ No newline at end of file diff --git a/apps/files_sharing/list.php b/apps/files_sharing/list.php index 219fe2863ed..5517c39971f 100644 --- a/apps/files_sharing/list.php +++ b/apps/files_sharing/list.php @@ -33,6 +33,7 @@ $tmpl = new OCP\Template('files_sharing', 'list', ''); $tmpl->assign('showgridview', $showgridview && !$isIE); OCP\Util::addScript('files_sharing', 'dist/files_sharing'); +OCP\Util::addScript('files_sharing', 'dist/files_sharing_tab'); \OC::$server->getEventDispatcher()->dispatch('\OCP\Collaboration\Resources::loadAdditionalScripts'); $tmpl->printPage(); diff --git a/apps/files_sharing/src/components/SharingEntry.vue b/apps/files_sharing/src/components/SharingEntry.vue new file mode 100644 index 00000000000..857b57adbd0 --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntry.vue @@ -0,0 +1,249 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <li class="sharing-entry"> + <Avatar class="sharing-entry__avatar" + :user="share.shareWith" + :display-name="share.shareWithDisplayName" + :url="share.shareWithAvatar" /> + <div v-tooltip.auto="tooltip" class="sharing-entry__desc"> + <h5>{{ title }}</h5> + </div> + <Actions menu-align="right" class="sharing-entry__actions"> + <!-- edit permission --> + <ActionCheckbox + ref="canEdit" + :checked.sync="canEdit" + :value="permissionsEdit" + :disabled="saving"> + {{ t('files_sharing', 'Allow editing') }} + </ActionCheckbox> + + <!-- reshare permission --> + <ActionCheckbox + ref="canReshare" + :checked.sync="canReshare" + :value="permissionsShare" + :disabled="saving"> + {{ t('files_sharing', 'Can reshare') }} + </ActionCheckbox> + + <!-- expiration date --> + <ActionCheckbox :checked.sync="hasExpirationDate" + :disabled="config.isDefaultExpireDateEnforced || saving" + @uncheck="onExpirationDisable"> + {{ config.isDefaultExpireDateEnforced + ? t('files_sharing', 'Expiration date enforced') + : t('files_sharing', 'Set expiration date') }} + </ActionCheckbox> + <ActionInput v-if="hasExpirationDate" + ref="expireDate" + v-tooltip.auto="{ + content: errors.expireDate, + show: errors.expireDate, + trigger: 'manual' + }" + :class="{ error: errors.expireDate}" + :disabled="saving" + :first-day-of-week="firstDay" + :lang="lang" + :value="share.expireDate" + icon="icon-calendar-dark" + type="date" + :not-before="dateTomorrow" + :not-after="dateMaxEnforced" + @update:value="onExpirationChange"> + {{ t('files_sharing', 'Enter a date') }} + </ActionInput> + + <!-- note --> + <template v-if="canHaveNote"> + <ActionCheckbox + :checked.sync="hasNote" + :disabled="saving" + @uncheck="queueUpdate('note')"> + {{ t('files_sharing', 'Note to recipient') }} + </ActionCheckbox> + <ActionTextEditable v-if="hasNote" + ref="note" + v-tooltip.auto="{ + content: errors.note, + show: errors.note, + trigger: 'manual' + }" + :class="{ error: errors.note}" + :disabled="saving" + :value.sync="share.note" + icon="icon-edit" + @update:value="debounceQueueUpdate('note')" /> + </template> + + <ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete"> + {{ t('files_sharing', 'Unshare') }} + </ActionButton> + </Actions> + </li> +</template> + +<script> +import Avatar from 'nextcloud-vue/dist/Components/Avatar' +import Actions from 'nextcloud-vue/dist/Components/Actions' +import ActionButton from 'nextcloud-vue/dist/Components/ActionButton' +import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox' +import ActionInput from 'nextcloud-vue/dist/Components/ActionInput' +import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable' +import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip' + +// eslint-disable-next-line no-unused-vars +import Share from '../models/Share' +import SharesMixin from '../mixins/SharesMixin' + +export default { + name: 'SharingEntry', + + components: { + Actions, + ActionButton, + ActionCheckbox, + ActionInput, + ActionTextEditable, + Avatar + }, + + directives: { + Tooltip + }, + + mixins: [SharesMixin], + + data() { + return { + permissionsEdit: OC.PERMISSION_UPDATE, + permissionsRead: OC.PERMISSION_READ, + permissionsShare: OC.PERMISSION_SHARE + } + }, + + computed: { + title() { + let title = this.share.shareWithDisplayName + if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) { + title += ` (${t('files_sharing', 'group')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) { + title += ` (${t('files_sharing', 'conversation')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) { + title += ` (${t('files_sharing', 'remote')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) { + title += ` (${t('files_sharing', 'remote group')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) { + title += ` (${t('files_sharing', 'guest')})` + } + return title + }, + + tooltip() { + if (this.share.owner !== this.share.uidFileOwner) { + const data = { + // todo: strong or italic? + // but the t function escape any html from the data :/ + user: this.share.shareWithDisplayName, + owner: this.share.owner + } + + if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) { + return t('files_sharing', 'Shared with the group {user} by {owner}', data) + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) { + return t('files_sharing', 'Shared with the conversation {user} by {owner}', data) + } + + return t('files_sharing', 'Shared with {user} by {owner}', data) + } + return null + }, + + canHaveNote() { + return this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE + && this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP + }, + + /** + * Can the sharee edit the shared file ? + */ + canEdit: { + get: function() { + return this.share.hasUpdatePermission + }, + set: function(checked) { + this.updatePermissions(checked, this.canReshare) + } + }, + + /** + * Can the sharee reshare the file ? + */ + canReshare: { + get: function() { + return this.share.hasSharePermission + }, + set: function(checked) { + this.updatePermissions(this.canEdit, checked) + } + } + + }, + + methods: { + updatePermissions(isEditChecked, isReshareChecked) { + // calc permissions if checked + const permissions = this.permissionsRead + | (isEditChecked ? this.permissionsEdit : 0) + | (isReshareChecked ? this.permissionsShare : 0) + + this.share.permissions = permissions + this.queueUpdate('permissions') + } + } + +} +</script> + +<style lang="scss" scoped> +.sharing-entry { + display: flex; + align-items: center; + height: 44px; + &__desc { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 8px; + line-height: 1.2em; + p { + color: var(--color-text-maxcontrast); + } + } + &__actions { + margin-left: auto; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue new file mode 100644 index 00000000000..720c016b82e --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntryInternal.vue @@ -0,0 +1,117 @@ + +<template> + <SharingEntrySimple + class="sharing-entry__internal" + :title="t('files_sharing', 'Internal link')" + :subtitle="internalLinkSubtitle"> + <template #avatar> + <div class="avatar-external icon-external-white" /> + </template> + + <ActionLink ref="copyButton" + :href="internalLink" + target="_blank" + :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" + @click.prevent="copyLink"> + {{ clipboardTooltip }} + </ActionLink> + </SharingEntrySimple> +</template> + +<script> +import { generateUrl } from '@nextcloud/router' +import ActionLink from 'nextcloud-vue/dist/Components/ActionLink' +import SharingEntrySimple from './SharingEntrySimple' + +export default { + name: 'SharingEntryInternal', + + components: { + ActionLink, + SharingEntrySimple + }, + + props: { + fileInfo: { + type: Object, + default: () => {}, + required: true + } + }, + + data() { + return { + copied: false, + copySuccess: false + } + }, + + computed: { + /** + * Get the internal link to this file id + * @returns {string} + */ + internalLink() { + return window.location.protocol + '//' + window.location.host + generateUrl('/f/') + this.fileInfo.id + }, + + /** + * Clipboard v-tooltip message + * @returns {string} + */ + clipboardTooltip() { + if (this.copied) { + return this.copySuccess + ? t('files_sharing', 'Link copied') + : t('files_sharing', 'Cannot copy, please copy the link manually') + } + return t('files_sharing', 'Copy to clipboard') + }, + + internalLinkSubtitle() { + if (this.fileInfo.type === 'dir') { + return t('files_sharing', 'Only works for users with access to this folder') + } + return t('files_sharing', 'Only works for users with access to this file') + } + }, + + methods: { + async copyLink() { + try { + await this.$copyText(this.internalLink) + // focus and show the tooltip + this.$refs.copyButton.$el.focus() + this.copySuccess = true + this.copied = true + } catch (error) { + this.copySuccess = false + this.copied = true + console.error(error) + } finally { + setTimeout(() => { + this.copySuccess = false + this.copied = false + }, 4000) + } + } + } +} +</script> + +<style lang="scss" scoped> +.sharing-entry__internal { + .avatar-external { + width: 32px; + height: 32px; + line-height: 32px; + font-size: 18px; + background-color: var(--color-text-maxcontrast); + border-radius: 50%; + flex-shrink: 0; + } + .icon-checkmark-color { + opacity: 1; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue new file mode 100644 index 00000000000..afeaee06bde --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -0,0 +1,769 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <li :class="{'sharing-entry--share': share}" class="sharing-entry sharing-entry__link"> + <Avatar :is-no-user="true" + :class="isEmailShareType ? 'icon-mail-white' : 'icon-public-white'" + class="sharing-entry__avatar" /> + <div class="sharing-entry__desc"> + <h5>{{ title }}</h5> + </div> + + <!-- clipboard --> + <Actions v-if="share && !isEmailShareType && share.token" + ref="copyButton" + class="sharing-entry__copy"> + <ActionLink :href="shareLink" + target="_blank" + :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" + @click.stop.prevent="copyLink"> + {{ clipboardTooltip }} + </ActionLink> + </Actions> + + <!-- pending actions --> + <Actions v-if="!loading && (pendingPassword || pendingExpirationDate)" + class="sharing-entry__actions" + menu-align="right" + :open.sync="open" + @close="onNewLinkShare"> + <!-- pending data menu --> + <ActionText v-if="errors.pending" + icon="icon-error" + :class="{ error: errors.pending}"> + {{ errors.pending }} + </ActionText> + <ActionText v-else icon="icon-info"> + {{ t('files_sharing', 'Please enter the following required information before creating the share') }} + </ActionText> + + <!-- password --> + <ActionText v-if="pendingPassword" icon="icon-password"> + {{ t('files_sharing', 'Password protection (enforced)') }} + </ActionText> + <ActionCheckbox v-else-if="config.enableLinkPasswordByDefault" + :checked.sync="isPasswordProtected" + :disabled="config.enforcePasswordForPublicLink || saving" + class="share-link-password-checkbox" + @uncheck="onPasswordDisable"> + {{ t('files_sharing', 'Password protection') }} + </ActionCheckbox> + <ActionInput v-if="pendingPassword || share.password" + v-tooltip.auto="{ + content: errors.password, + show: errors.password, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-password" + :value.sync="share.password" + :disabled="saving" + :required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink" + :minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength" + icon="" + autocomplete="new-password" + @submit="onNewLinkShare"> + {{ t('files_sharing', 'Enter a password') }} + </ActionInput> + + <!-- expiration date --> + <ActionText v-if="pendingExpirationDate" icon="icon-calendar-dark"> + {{ t('files_sharing', 'Expiration date (enforced)') }} + </ActionText> + <ActionInput v-if="pendingExpirationDate" + v-model="share.expireDate" + v-tooltip.auto="{ + content: errors.expireDate, + show: errors.expireDate, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-expire-date" + :disabled="saving" + :first-day-of-week="firstDay" + :lang="lang" + icon="" + type="date" + :not-before="dateTomorrow" + :not-after="dateMaxEnforced"> + <!-- let's not submit when picked, the user + might want to still edit or copy the password --> + {{ t('files_sharing', 'Enter a date') }} + </ActionInput> + + <ActionButton icon="icon-close" @click.prevent.stop="onCancel"> + {{ t('files_sharing', 'Cancel') }} + </ActionButton> + </Actions> + + <!-- actions --> + <Actions v-else-if="!loading" + class="sharing-entry__actions" + menu-align="right" + :open.sync="open" + @close="onPasswordSubmit"> + <template v-if="share"> + <template v-if="isShareOwner"> + <!-- folder --> + <template v-if="isFolder && fileHasCreatePermission && config.isPublicUploadEnabled"> + <ActionRadio :checked="share.permissions === publicUploadRValue" + :value="publicUploadRValue" + :name="randomId" + :disabled="saving" + @change="togglePermissions"> + {{ t('files_sharing', 'Read only') }} + </ActionRadio> + <ActionRadio :checked="share.permissions === publicUploadRWValue" + :value="publicUploadRWValue" + :disabled="saving" + :name="randomId" + @change="togglePermissions"> + {{ t('files_sharing', 'Allow upload and editing') }} + </ActionRadio> + <ActionRadio :checked="share.permissions === publicUploadWValue" + :value="publicUploadWValue" + :disabled="saving" + :name="randomId" + class="sharing-entry__action--public-upload" + @change="togglePermissions"> + {{ t('files_sharing', 'File drop (upload only)') }} + </ActionRadio> + </template> + + <!-- file --> + <ActionCheckbox v-else + :checked.sync="canUpdate" + :disabled="saving" + @change="queueUpdate('permissions')"> + {{ t('files_sharing', 'Allow editing') }} + </ActionCheckbox> + + <ActionCheckbox + :checked.sync="share.hideDownload" + :disabled="saving" + @change="queueUpdate('hideDownload')"> + {{ t('files_sharing', 'Hide download') }} + </ActionCheckbox> + + <!-- password --> + <ActionCheckbox :checked.sync="isPasswordProtected" + :disabled="config.enforcePasswordForPublicLink || saving" + class="share-link-password-checkbox" + @uncheck="onPasswordDisable"> + {{ config.enforcePasswordForPublicLink + ? t('files_sharing', 'Password protection (enforced)') + : t('files_sharing', 'Password protect') }} + </ActionCheckbox> + <ActionInput v-if="isPasswordProtected" + ref="password" + v-tooltip.auto="{ + content: errors.password, + show: errors.password, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-password" + :class="{ error: errors.password}" + :disabled="saving" + :required="config.enforcePasswordForPublicLink" + :value="hasUnsavedPassword ? share.newPassword : '***************'" + icon="icon-password" + autocomplete="new-password" + :type="hasUnsavedPassword ? 'text': 'password'" + @update:value="onPasswordChange" + @submit="onPasswordSubmit"> + {{ t('files_sharing', 'Enter a password') }} + </ActionInput> + + <!-- expiration date --> + <ActionCheckbox :checked.sync="hasExpirationDate" + :disabled="config.isDefaultExpireDateEnforced || saving" + class="share-link-expire-date-checkbox" + @uncheck="onExpirationDisable"> + {{ config.isDefaultExpireDateEnforced + ? t('files_sharing', 'Expiration date (enforced)') + : t('files_sharing', 'Set expiration date') }} + </ActionCheckbox> + <ActionInput v-if="hasExpirationDate" + ref="expireDate" + v-tooltip.auto="{ + content: errors.expireDate, + show: errors.expireDate, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-expire-date" + :class="{ error: errors.expireDate}" + :disabled="saving" + :first-day-of-week="firstDay" + :lang="lang" + :value="share.expireDate" + icon="icon-calendar-dark" + type="date" + :not-before="dateTomorrow" + :not-after="dateMaxEnforced" + @update:value="onExpirationChange"> + {{ t('files_sharing', 'Enter a date') }} + </ActionInput> + + <!-- note --> + <ActionCheckbox :checked.sync="hasNote" + :disabled="saving" + @uncheck="queueUpdate('note')"> + {{ t('files_sharing', 'Note to recipient') }} + </ActionCheckbox> + <ActionTextEditable v-if="hasNote" + ref="note" + v-tooltip.auto="{ + content: errors.note, + show: errors.note, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + :class="{ error: errors.note}" + :disabled="saving" + :value.sync="share.note" + icon="icon-edit" + @update:value="debounceQueueUpdate('note')" /> + </template> + + <components :is="action" v-for="(action, index) in externalActions" :key="index" /> + + <ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete"> + {{ t('files_sharing', 'Delete share') }} + </ActionButton> + <ActionButton v-if="!isEmailShareType && canReshare" + class="new-share-link" + icon="icon-add" + @click.prevent.stop="onNewLinkShare"> + {{ t('files_sharing', 'Add another link') }} + </ActionButton> + </template> + + <!-- Create new share --> + <ActionButton v-else-if="canReshare" + class="new-share-link" + icon="icon-add" + @click.prevent.stop="onNewLinkShare"> + {{ t('files_sharing', 'Create a new share link') }} + </ActionButton> + </Actions> + + <!-- loading indicator to replace the menu --> + <div v-else class="icon-loading-small sharing-entry__loading" /> + </li> +</template> + +<script> +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +import ActionButton from 'nextcloud-vue/dist/Components/ActionButton' +import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox' +import ActionRadio from 'nextcloud-vue/dist/Components/ActionRadio' +import ActionInput from 'nextcloud-vue/dist/Components/ActionInput' +import ActionText from 'nextcloud-vue/dist/Components/ActionText' +import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable' +import ActionLink from 'nextcloud-vue/dist/Components/ActionLink' +import Actions from 'nextcloud-vue/dist/Components/Actions' +import Avatar from 'nextcloud-vue/dist/Components/Avatar' +import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip' + +import Share from '../models/Share' +import SharesMixin from '../mixins/SharesMixin' + +const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789' + +export default { + name: 'SharingEntryLink', + + components: { + Actions, + ActionButton, + ActionCheckbox, + ActionRadio, + ActionInput, + ActionLink, + ActionText, + ActionTextEditable, + Avatar + }, + + directives: { + Tooltip + }, + + mixins: [SharesMixin], + + props: { + canReshare: { + type: Boolean, + default: true + } + }, + + data() { + return { + copySuccess: true, + copied: false, + + publicUploadRWValue: OC.PERMISSION_UPDATE | OC.PERMISSION_CREATE | OC.PERMISSION_READ | OC.PERMISSION_DELETE, + publicUploadRValue: OC.PERMISSION_READ, + publicUploadWValue: OC.PERMISSION_CREATE, + + ExternalLinkActions: OCA.Sharing.ExternalLinkActions.state + } + }, + + computed: { + /** + * Generate a unique random id for this SharingEntryLink only + * This allows ActionRadios to have the same name prop + * but not to impact others SharingEntryLink + * @returns {string} + */ + randomId() { + return Math.random().toString(27).substr(2) + }, + + /** + * Link share label + * TODO: allow editing + * @returns {string} + */ + title() { + // if we have a valid existing share (not pending) + if (this.share && this.share.id) { + if (!this.isShareOwner && this.share.ownerDisplayName) { + return t('files_sharing', 'Shared via link by {initiator}', { + initiator: this.share.ownerDisplayName + }) + } + if (this.share.label && this.share.label.trim() !== '') { + return this.share.label + } + if (this.isEmailShareType) { + return this.share.shareWith + } + } + return t('files_sharing', 'Share link') + }, + + /** + * Is the current share password protected ? + * @returns {boolean} + */ + isPasswordProtected: { + get: function() { + return this.config.enforcePasswordForPublicLink + || !!this.share.password + }, + set: async function(enabled) { + // TODO: directly save after generation to make sure the share is always protected + this.share.password = enabled ? await this.generatePassword() : '' + this.share.newPassword = this.share.password + } + }, + + /** + * Is the current share an email share ? + * @returns {boolean} + */ + isEmailShareType() { + return this.share + ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL + : false + }, + + /** + * Pending data. + * If the share still doesn't have an id, it is not synced + * Therefore this is still not valid and requires user input + * @returns {boolean} + */ + pendingPassword() { + return this.config.enforcePasswordForPublicLink && this.share && !this.share.id + }, + pendingExpirationDate() { + return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id + }, + + /** + * Can the recipient edit the file ? + * @returns {boolean} + */ + canUpdate: { + get: function() { + return this.share.hasUpdatePermission + }, + set: function(enabled) { + this.share.permissions = enabled + ? OC.PERMISSION_READ | OC.PERMISSION_UPDATE + : OC.PERMISSION_READ + } + }, + + // if newPassword exists, but is empty, it means + // the user deleted the original password + hasUnsavedPassword() { + return this.share.newPassword !== undefined + }, + + /** + * Is the current share a folder ? + * TODO: move to a proper FileInfo model? + * @returns {boolean} + */ + isFolder() { + return this.fileInfo.type === 'dir' + }, + + /** + * Does the current file/folder have create permissions + * TODO: move to a proper FileInfo model? + * @returns {boolean} + */ + fileHasCreatePermission() { + return !!(this.fileInfo.permissions & OC.PERMISSION_CREATE) + }, + + /** + * Return the public share link + * @returns {string} + */ + shareLink() { + return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token + }, + + /** + * Clipboard v-tooltip message + * @returns {string} + */ + clipboardTooltip() { + if (this.copied) { + return this.copySuccess + ? t('files_sharing', 'Link copied') + : t('files_sharing', 'Cannot copy, please copy the link manually') + } + return t('files_sharing', 'Copy to clipboard') + }, + + /** + * External aditionnal actions for the menu + * @returns {Array} + */ + externalActions() { + return this.ExternalLinkActions.actions + }, + + isPasswordPolicyEnabled() { + return typeof this.config.passwordPolicy === 'object' + } + }, + + methods: { + /** + * Create a new share link and append it to the list + */ + async onNewLinkShare() { + const shareDefaults = { + share_type: OC.Share.SHARE_TYPE_LINK + } + if (this.config.isDefaultExpireDateEnforced) { + // default is empty string if not set + // expiration is the share object key, not expireDate + shareDefaults.expiration = this.config.defaultExpirationDateString + } + if (this.config.enableLinkPasswordByDefault) { + shareDefaults.password = await this.generatePassword() + } + + // do not push yet if we need a password or an expiration date + if (this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced) { + this.loading = true + // if a share already exists, pushing it + if (this.share && !this.share.id) { + if (this.checkShare(this.share)) { + await this.pushNewLinkShare(this.share, true) + return true + } else { + this.open = true + OC.Notification.showTemporary(t('files_sharing', 'Error, please enter proper password and/or expiration date')) + return false + } + } + + // ELSE, show the pending popovermenu + // if password enforced, pre-fill with random one + if (this.config.enforcePasswordForPublicLink) { + shareDefaults.password = await this.generatePassword() + } + + // create share & close menu + const share = new Share(shareDefaults) + const component = await new Promise(resolve => { + this.$emit('add:share', share, resolve) + }) + + // open the menu on the + // freshly created share component + this.open = false + this.loading = false + component.open = true + + // Nothing enforced, creating share directly + } else { + const share = new Share(shareDefaults) + await this.pushNewLinkShare(share) + } + }, + + /** + * Push a new link share to the server + * And update or append to the list + * accordingly + * + * @param {Share} share the new share + * @param {boolean} [update=false] do we update the current share ? + */ + async pushNewLinkShare(share, update) { + try { + this.loading = true + this.errors = {} + + const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') + const newShare = await this.createShare({ + path, + shareType: OC.Share.SHARE_TYPE_LINK, + password: share.password, + expireDate: share.expireDate + // we do not allow setting the publicUpload + // before the share creation. + // Todo: We also need to fix the createShare method in + // lib/Controller/ShareAPIController.php to allow file drop + // (currently not supported on create, only update) + }) + + this.open = false + + console.debug('Link share created', newShare) + + // if share already exists, copy link directly on next tick + let component + if (update) { + component = await new Promise(resolve => { + this.$emit('update:share', newShare, resolve) + }) + } else { + // adding new share to the array and copying link to clipboard + // using promise so that we can copy link in the same click function + // and avoid firefox copy permissions issue + component = await new Promise(resolve => { + this.$emit('add:share', newShare, resolve) + }) + } + + // Execute the copy link method + // freshly created share component + // ! somehow does not works on firefox ! + component.copyLink() + + } catch ({ response }) { + const message = response.data.ocs.meta.message + if (message.match(/password/i)) { + this.onSyncError('password', message) + } else if (message.match(/date/i)) { + this.onSyncError('expireDate', message) + } else { + this.onSyncError('pending', message) + } + } finally { + this.loading = false + } + }, + + /** + * On permissions change + * @param {Event} event js event + */ + togglePermissions(event) { + const permissions = parseInt(event.target.value, 10) + this.share.permissions = permissions + this.queueUpdate('permissions') + }, + + /** + * Generate a valid policy password or + * request a valid password if password_policy + * is enabled + * + * @returns {string} a valid password + */ + async generatePassword() { + // password policy is enabled, let's request a pass + if (this.config.passwordPolicy.api && this.config.passwordPolicy.api.generate) { + try { + const request = await axios.get(this.config.passwordPolicy.api.generate) + if (request.data.ocs.data.password) { + return request.data.ocs.data.password + } + } catch (error) { + console.info('Error generating password from password_policy', error) + } + } + + // generate password of 10 length based on passwordSet + return Array(10).fill(0) + .reduce((prev, curr) => { + prev += passwordSet.charAt(Math.floor(Math.random() * passwordSet.length)) + return prev + }, '') + }, + + async copyLink() { + try { + await this.$copyText(this.shareLink) + // focus and show the tooltip + this.$refs.copyButton.$el.focus() + this.copySuccess = true + this.copied = true + } catch (error) { + this.copySuccess = false + this.copied = true + console.error(error) + } finally { + setTimeout(() => { + this.copySuccess = false + this.copied = false + }, 4000) + } + }, + + /** + * Update newPassword values + * of share. If password is set but not newPassword + * then the user did not changed the password + * If both co-exists, the password have changed and + * we show it in plain text. + * Then on submit (or menu close), we sync it. + * @param {string} password the changed password + */ + onPasswordChange(password) { + this.$set(this.share, 'newPassword', password) + }, + + /** + * Uncheck password protection + * We need this method because @update:checked + * is ran simultaneously as @uncheck, so + * so we cannot ensure data is up-to-date + */ + onPasswordDisable() { + this.share.password = '' + + // reset password state after sync + this.$delete(this.share, 'newPassword') + + // only update if valid share. + if (this.share.id) { + this.queueUpdate('password') + } + }, + + /** + * Menu have been closed or password has been submited. + * The only property that does not get + * synced automatically is the password + * So let's check if we have an unsaved + * password. + * expireDate is saved on datepicker pick + * or close. + */ + onPasswordSubmit() { + if (this.hasUnsavedPassword) { + this.share.password = this.share.newPassword + this.queueUpdate('password') + } + }, + + /** + * Cancel the share creation + * Used in the pending popover + */ + onCancel() { + // this.share already exists at this point, + // but is incomplete as not pushed to server + // YET. We can safely delete the share :) + this.$emit('remove:share', this.share) + } + } + +} +</script> + +<style lang="scss" scoped> +.sharing-entry { + display: flex; + align-items: center; + height: 44px; + &__desc { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 8px; + line-height: 1.2em; + } + + &:not(.sharing-entry--share) &__actions { + .new-share-link { + border-top: 1px solid var(--color-border); + } + } + + .sharing-entry__action--public-upload { + border-bottom: 1px solid var(--color-border); + } + + &__loading { + width: 44px; + height: 44px; + margin: 0; + padding: 14px; + margin-left: auto; + } + + // put menus to the left + // but only the first one + .action-item { + margin-left: auto; + ~ .action-item, + ~ .sharing-entry__loading { + margin-left: 0; + } + } + + .icon-checkmark-color { + opacity: 1; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingEntrySimple.vue b/apps/files_sharing/src/components/SharingEntrySimple.vue new file mode 100644 index 00000000000..4538950a831 --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntrySimple.vue @@ -0,0 +1,97 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <li class="sharing-entry"> + <slot name="avatar" /> + <div v-tooltip="tooltip" class="sharing-entry__desc"> + <h5>{{ title }}</h5> + <p v-if="subtitle"> + {{ subtitle }} + </p> + </div> + <Actions v-if="$slots['default']" menu-align="right" class="sharing-entry__actions"> + <slot /> + </Actions> + </li> +</template> + +<script> +import Actions from 'nextcloud-vue/dist/Components/Actions' +import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip' + +export default { + name: 'SharingEntrySimple', + + components: { + Actions + }, + + directives: { + Tooltip + }, + + props: { + title: { + type: String, + default: '', + required: true + }, + tooltip: { + type: String, + default: '' + }, + subtitle: { + type: String, + default: '' + } + } + +} +</script> + +<style lang="scss" scoped> +.sharing-entry { + display: flex; + align-items: center; + height: 44px; + &__desc { + padding: 8px; + line-height: 1.2em; + position: relative; + flex: 1 1; + min-width: 0; + h5 { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: inherit; + } + p { + color: var(--color-text-maxcontrast); + } + } + &__actions { + margin-left: auto !important; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue new file mode 100644 index 00000000000..df222eafe0c --- /dev/null +++ b/apps/files_sharing/src/components/SharingInput.vue @@ -0,0 +1,444 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <Multiselect ref="multiselect" + class="sharing-input" + :disabled="!canReshare" + :hide-selected="true" + :internal-search="false" + :loading="loading" + :options="options" + :placeholder="inputPlaceholder" + :preselect-first="true" + :preserve-search="true" + :searchable="true" + :user-select="true" + @search-change="asyncFind" + @select="addShare"> + <template #noOptions> + {{ t('files_sharing', 'No recommendations. Start typing.') }} + </template> + <template #noResult> + {{ noResultText }} + </template> + </Multiselect> +</template> + +<script> +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import debounce from 'debounce' +import Multiselect from 'nextcloud-vue/dist/Components/Multiselect' + +import Config from '../services/ConfigService' +import Share from '../models/Share' +import ShareRequests from '../mixins/ShareRequests' +import ShareTypes from '../mixins/ShareTypes' + +export default { + name: 'SharingInput', + + components: { + Multiselect + }, + + mixins: [ShareTypes, ShareRequests], + + props: { + shares: { + type: Array, + default: () => [], + required: true + }, + linkShares: { + type: Array, + default: () => [], + required: true + }, + fileInfo: { + type: Object, + default: () => {}, + required: true + }, + reshare: { + type: Share, + default: null + }, + canReshare: { + type: Boolean, + required: true + } + }, + + data() { + return { + config: new Config(), + loading: false, + query: '', + recommendations: [], + ShareSearch: OCA.Sharing.ShareSearch.state, + suggestions: [] + } + }, + + computed: { + /** + * Implement ShareSearch + * allows external appas to inject new + * results into the autocomplete dropdown + * Used for the guests app + * + * @returns {Array} + */ + externalResults() { + return this.ShareSearch.results + }, + inputPlaceholder() { + const allowRemoteSharing = this.config.isRemoteShareAllowed + const allowMailSharing = this.config.isMailShareAllowed + + if (!this.canReshare) { + return t('files_sharing', 'Resharing is not allowed') + } + if (!allowRemoteSharing && allowMailSharing) { + return t('files_sharing', 'Name or email address...') + } + if (allowRemoteSharing && !allowMailSharing) { + return t('files_sharing', 'Name or federated cloud ID...') + } + if (allowRemoteSharing && allowMailSharing) { + return t('files_sharing', 'Name, federated cloud ID or email address...') + } + + return t('files_sharing', 'Name...') + }, + + isValidQuery() { + return this.query && this.query.trim() !== '' && this.query.length > this.config.minSearchStringLength + }, + + options() { + if (this.isValidQuery) { + return this.suggestions + } + return this.recommendations + }, + + noResultText() { + if (this.loading) { + return t('files_sharing', 'Searching...') + } + return t('files_sharing', 'No elements found.') + } + }, + + mounted() { + this.getRecommendations() + }, + + methods: { + async asyncFind(query, id) { + // save current query to check if we display + // recommendations or search results + this.query = query.trim() + if (this.isValidQuery) { + // start loading now to have proper ux feedback + // during the debounce + this.loading = true + await this.debounceGetSuggestions(query) + } + }, + + /** + * Get suggestions + * + * @param {string} search the search query + * @param {boolean} [lookup=false] search on lookup server + */ + async getSuggestions(search, lookup) { + this.loading = true + lookup = lookup || false + console.info(search, lookup) + + const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees', { + params: { + format: 'json', + itemType: this.fileInfo.type === 'dir' ? 'folder' : 'file', + search, + lookup, + perPage: this.config.maxAutocompleteResults + } + }) + + if (request.data.ocs.meta.statuscode !== 100) { + console.error('Error fetching suggestions', request) + return + } + + const data = request.data.ocs.data + const exact = request.data.ocs.data.exact + data.exact = [] // removing exact from general results + + // flatten array of arrays + const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), []) + const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), []) + + // remove invalid data and format to user-select layout + const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions) + .map(share => this.formatForMultiselect(share)) + const suggestions = this.filterOutExistingShares(rawSuggestions) + .map(share => this.formatForMultiselect(share)) + + // lookup clickable entry + const lookupEntry = [] + if (data.lookupEnabled) { + lookupEntry.push({ + isNoUser: true, + displayName: t('files_sharing', 'Search globally'), + lookup: true + }) + } + + // if there is a condition specified, filter it + const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this)) + + this.suggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry) + + this.loading = false + console.info('suggestions', this.suggestions) + }, + + /** + * Debounce getSuggestions + * + * @param {...*} args the arguments + */ + debounceGetSuggestions: debounce(function(...args) { + this.getSuggestions(...args) + }, 300), + + /** + * Get the sharing recommendations + */ + async getRecommendations() { + this.loading = true + + const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees_recommended', { + params: { + format: 'json', + itemType: this.fileInfo.type + } + }) + + if (request.data.ocs.meta.statuscode !== 100) { + console.error('Error fetching recommendations', request) + return + } + + const exact = request.data.ocs.data.exact + + // flatten array of arrays + const rawRecommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), []) + + // remove invalid data and format to user-select layout + this.recommendations = this.filterOutExistingShares(rawRecommendations) + .map(share => this.formatForMultiselect(share)) + + this.loading = false + console.info('recommendations', this.recommendations) + }, + + /** + * Filter out existing shares from + * the provided shares search results + * + * @param {Object[]} shares the array of shares object + * @returns {Object[]} + */ + filterOutExistingShares(shares) { + return shares.reduce((arr, share) => { + // only check proper objects + if (typeof share !== 'object') { + return arr + } + try { + // filter out current user + if (share.value.shareWith === getCurrentUser().uid) { + return arr + } + + // filter out the owner of the share + if (this.reshare && share.value.shareWith === this.reshare.owner) { + return arr + } + + // filter out existing mail shares + if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + const emails = this.linkShares.map(elem => elem.shareWith) + if (emails.indexOf(share.value.shareWith.trim()) !== -1) { + return arr + } + } else { // filter out existing shares + // creating an object of uid => type + const sharesObj = this.shares.reduce((obj, elem) => { + obj[elem.shareWith] = elem.type + return obj + }, {}) + + // if shareWith is the same and the share type too, ignore it + const key = share.value.shareWith.trim() + if (key in sharesObj + && sharesObj[key] === share.value.shareType) { + return arr + } + } + + // ALL GOOD + // let's add the suggestion + arr.push(share) + } catch { + return arr + } + return arr + }, []) + }, + + /** + * Get the icon based on the share type + * @param {number} type the share type + * @returns {string} the icon class + */ + shareTypeToIcon(type) { + switch (type) { + case this.SHARE_TYPES.SHARE_TYPE_GUEST: + // default is a user, other icons are here to differenciate + // themselves from it, so let's not display the user icon + // case this.SHARE_TYPES.SHARE_TYPE_REMOTE: + // case this.SHARE_TYPES.SHARE_TYPE_USER: + return 'icon-user' + case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP: + case this.SHARE_TYPES.SHARE_TYPE_GROUP: + return 'icon-group' + case this.SHARE_TYPES.SHARE_TYPE_EMAIL: + return 'icon-mail' + case this.SHARE_TYPES.SHARE_TYPE_CIRCLE: + return 'icon-circle' + case this.SHARE_TYPES.SHARE_TYPE_ROOM: + return 'icon-room' + + default: + return '' + } + }, + + /** + * Format shares for the multiselect options + * @param {Object} result select entry item + * @returns {Object} + */ + formatForMultiselect(result) { + let desc + if ((result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE + || result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP + ) && result.value.server) { + desc = t('files_sharing', 'on {server}', { server: result.value.server }) + } else if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + desc = result.value.shareWith + } + + return { + shareWith: result.value.shareWith, + shareType: result.value.shareType, + user: result.uuid || result.value.shareWith, + isNoUser: !result.uuid, + displayName: result.name || result.label, + desc, + icon: this.shareTypeToIcon(result.value.shareType) + } + }, + + /** + * Process the new share request + * @param {Object} value the multiselect option + */ + async addShare(value) { + if (value.lookup) { + return this.getSuggestions(this.query, true) + } + + // handle externalResults from OCA.Sharing.ShareSearch + if (value.handler) { + const share = await value.handler(this) + this.$emit('add:share', new Share(share)) + return true + } + + this.loading = true + try { + const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') + const share = await this.createShare({ + path, + shareType: value.shareType, + shareWith: value.shareWith + }) + this.$emit('add:share', share) + + this.getRecommendations() + + } catch (response) { + // focus back if any error + const input = this.$refs.multiselect.$el.querySelector('input') + if (input) { + input.focus() + } + this.query = value.shareWith + } finally { + this.loading = false + } + } + } +} +</script> + +<style lang="scss"> +.sharing-input { + width: 100%; + margin: 10px 0; + + // properly style the lookup entry + .multiselect__option { + span[lookup] { + .avatardiv { + background-image: var(--icon-search-fff); + background-repeat: no-repeat; + background-position: center; + background-color: var(--color-text-maxcontrast) !important; + div { + display: none; + } + } + } + } +} +</style> diff --git a/apps/files_sharing/src/files_sharing_tab.js b/apps/files_sharing/src/files_sharing_tab.js new file mode 100644 index 00000000000..18b4f4d7d1f --- /dev/null +++ b/apps/files_sharing/src/files_sharing_tab.js @@ -0,0 +1,39 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import SharingTab from './views/SharingTab' +import ShareSearch from './services/ShareSearch' +import ExternalLinkActions from './services/ExternalLinkActions' + +if (window.OCA && window.OCA.Sharing) { + Object.assign(window.OCA.Sharing, { ShareSearch: new ShareSearch() }) +} + +if (window.OCA && window.OCA.Sharing) { + Object.assign(window.OCA.Sharing, { ExternalLinkActions: new ExternalLinkActions() }) +} + +window.addEventListener('DOMContentLoaded', () => { + if (OCA.Files && OCA.Files.Sidebar) { + OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab('sharing', SharingTab)) + } +}) diff --git a/apps/files_sharing/src/mixins/ShareRequests.js b/apps/files_sharing/src/mixins/ShareRequests.js new file mode 100644 index 00000000000..c534e860707 --- /dev/null +++ b/apps/files_sharing/src/mixins/ShareRequests.js @@ -0,0 +1,114 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +// TODO: remove when ie not supported +import 'url-search-params-polyfill' + +import { generateOcsUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' +import Share from '../models/Share' + +const shareUrl = generateOcsUrl('apps/files_sharing/api/v1', 2) + 'shares' +const headers = { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' +} + +export default { + methods: { + /** + * Create a new share + * + * @param {Object} data destructuring object + * @param {string} data.path path to the file/folder which should be shared + * @param {number} data.shareType 0 = user; 1 = group; 3 = public link; 6 = federated cloud share + * @param {string} data.shareWith user/group id with which the file should be shared (optional for shareType > 1) + * @param {boolean} [data.publicUpload=false] allow public upload to a public shared folder + * @param {string} [data.password] password to protect public link Share with + * @param {number} [data.permissions=31] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1) + * @param {boolean} [data.sendPasswordByTalk=false] send the password via a talk conversation + * @param {string} [data.expireDate=''] expire the shareautomatically after + * @param {string} [data.label=''] custom label + * @returns {Share} the new share + * @throws {Error} + */ + async createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label }) { + try { + const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label }) + if (!('ocs' in request.data)) { + throw request + } + return new Share(request.data.ocs.data) + } catch (error) { + console.error('Error while creating share', error) + OC.Notification.showTemporary(t('files_sharing', 'Error creating the share'), { type: 'error' }) + throw error + } + }, + + /** + * Delete a share + * + * @param {number} id share id + * @throws {Error} + */ + async deleteShare(id) { + try { + const request = await axios.delete(shareUrl + `/${id}`) + if (!('ocs' in request.data)) { + throw request + } + return true + } catch (error) { + console.error('Error while deleting share', error) + OC.Notification.showTemporary(t('files_sharing', 'Error deleting the share'), { type: 'error' }) + throw error + } + }, + + /** + * Update a share + * + * @param {number} id share id + * @param {Object} data destructuring object + * @param {string} data.property property to update + * @param {any} data.value value to set + */ + async updateShare(id, { property, value }) { + try { + // ocs api requires x-www-form-urlencoded + const data = new URLSearchParams() + data.append(property, value) + + const request = await axios.put(shareUrl + `/${id}`, { [property]: value }, headers) + if (!('ocs' in request.data)) { + throw request + } + return true + } catch (error) { + console.error('Error while updating share', error) + OC.Notification.showTemporary(t('files_sharing', 'Error updating the share'), { type: 'error' }) + const message = error.response.data.ocs.meta.message + throw new Error(`${property}, ${message}`) + } + } + } +} diff --git a/apps/files_sharing/src/mixins/ShareTypes.js b/apps/files_sharing/src/mixins/ShareTypes.js new file mode 100644 index 00000000000..81e6af7d97c --- /dev/null +++ b/apps/files_sharing/src/mixins/ShareTypes.js @@ -0,0 +1,39 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export default { + data() { + return { + SHARE_TYPES: { + SHARE_TYPE_USER: OC.Share.SHARE_TYPE_USER, + SHARE_TYPE_GROUP: OC.Share.SHARE_TYPE_GROUP, + SHARE_TYPE_LINK: OC.Share.SHARE_TYPE_LINK, + SHARE_TYPE_EMAIL: OC.Share.SHARE_TYPE_EMAIL, + SHARE_TYPE_REMOTE: OC.Share.SHARE_TYPE_REMOTE, + SHARE_TYPE_CIRCLE: OC.Share.SHARE_TYPE_CIRCLE, + SHARE_TYPE_GUEST: OC.Share.SHARE_TYPE_GUEST, + SHARE_TYPE_REMOTE_GROUP: OC.Share.SHARE_TYPE_REMOTE_GROUP, + SHARE_TYPE_ROOM: OC.Share.SHARE_TYPE_ROOM + } + } + } +} diff --git a/apps/files_sharing/src/mixins/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js new file mode 100644 index 00000000000..d012f35591d --- /dev/null +++ b/apps/files_sharing/src/mixins/SharesMixin.js @@ -0,0 +1,303 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import PQueue from 'p-queue' +import debounce from 'debounce' + +import Share from '../models/Share' +import SharesRequests from './ShareRequests' +import ShareTypes from './ShareTypes' +import Config from '../services/ConfigService' +import { getCurrentUser } from '@nextcloud/auth' + +export default { + mixins: [SharesRequests, ShareTypes], + + props: { + fileInfo: { + type: Object, + default: () => {}, + required: true + }, + share: { + type: Share, + default: null + } + }, + + data() { + return { + config: new Config(), + + // errors helpers + errors: {}, + + // component status toggles + loading: false, + saving: false, + open: false, + + // concurrency management queue + // we want one queue per share + updateQueue: new PQueue({ concurrency: 1 }), + + /** + * ! This allow vue to make the Share class state reactive + * ! do not remove it ot you'll lose all reactivity here + */ + reactiveState: this.share && this.share.state, + + SHARE_TYPES: { + SHARE_TYPE_USER: OC.Share.SHARE_TYPE_USER, + SHARE_TYPE_GROUP: OC.Share.SHARE_TYPE_GROUP, + SHARE_TYPE_LINK: OC.Share.SHARE_TYPE_LINK, + SHARE_TYPE_EMAIL: OC.Share.SHARE_TYPE_EMAIL, + SHARE_TYPE_REMOTE: OC.Share.SHARE_TYPE_REMOTE, + SHARE_TYPE_CIRCLE: OC.Share.SHARE_TYPE_CIRCLE, + SHARE_TYPE_GUEST: OC.Share.SHARE_TYPE_GUEST, + SHARE_TYPE_REMOTE_GROUP: OC.Share.SHARE_TYPE_REMOTE_GROUP, + SHARE_TYPE_ROOM: OC.Share.SHARE_TYPE_ROOM + } + } + }, + + computed: { + + /** + * Does the current share have an expiration date + * @returns {boolean} + */ + hasExpirationDate: { + get: function() { + return this.config.isDefaultExpireDateEnforced || !!this.share.expireDate + }, + set: function(enabled) { + this.share.expireDate = enabled + ? this.config.defaultExpirationDateString !== '' + ? this.config.defaultExpirationDateString + : moment().format('YYYY-MM-DD') + : '' + } + }, + + /** + * Does the current share have a note + * @returns {boolean} + */ + hasNote: { + get: function() { + return !!this.share.note + }, + set: function(enabled) { + this.share.note = enabled + ? t('files_sharing', 'Enter a note for the share recipient') + : '' + } + }, + + dateTomorrow() { + return moment().add(1, 'days') + }, + + dateMaxEnforced() { + return this.config.isDefaultExpireDateEnforced + && moment().add(1 + this.config.defaultExpireDate, 'days') + }, + + /** + * Datepicker lang values + * https://github.com/nextcloud/nextcloud-vue/pull/146 + * TODO: have this in vue-components + * + * @returns {int} + */ + firstDay() { + return window.firstDay + ? window.firstDay + : 0 // sunday as default + }, + lang() { + // fallback to default in case of unavailable data + return { + days: window.dayNamesShort + ? window.dayNamesShort // provided by nextcloud + : ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.'], + months: window.monthNamesShort + ? window.monthNamesShort // provided by nextcloud + : ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'], + placeholder: { + date: 'Select Date' // TODO: Translate + } + } + }, + + isShareOwner() { + return this.share && this.share.owner === getCurrentUser().uid + } + + }, + + methods: { + /** + * Check if a share is valid before + * firing the request + * + * @param {Share} share the share to check + * @returns {Boolean} + */ + checkShare(share) { + if (share.password) { + if (typeof share.password !== 'string' || share.password.trim() === '') { + return false + } + } + if (share.expirationDate) { + const date = moment(share.expirationDate) + if (!date.isValid()) { + return false + } + } + return true + }, + + /** + * ActionInput can be a little tricky to work with. + * Since we expect a string and not a Date, + * we need to process the value here + * + * @param {Date} date js date to be parsed by moment.js + */ + onExpirationChange(date) { + // format to YYYY-MM-DD + const value = moment(date).format('YYYY-MM-DD') + this.share.expireDate = value + this.queueUpdate('expireDate') + }, + + /** + * Uncheck expire date + * We need this method because @update:checked + * is ran simultaneously as @uncheck, so + * so we cannot ensure data is up-to-date + */ + onExpirationDisable() { + this.share.expireDate = '' + this.queueUpdate('expireDate') + }, + + /** + * Delete share button handler + */ + async onDelete() { + try { + this.loading = true + this.open = false + await this.deleteShare(this.share.id) + console.debug('Share deleted', this.share.id) + this.$emit('remove:share', this.share) + } catch (error) { + // re-open menu if error + this.open = true + } finally { + this.loading = false + } + }, + + /** + * Send an update of the share to the queue + * + * @param {string} property the property to sync + */ + queueUpdate(property) { + if (this.share.id) { + // force value to string because that is what our + // share api controller accepts + const value = this.share[property].toString() + + this.updateQueue.add(async() => { + this.saving = true + this.errors = {} + try { + await this.updateShare(this.share.id, { + property, + value + }) + + // clear any previous errors + this.$delete(this.errors, property) + + // reset password state after sync + this.$delete(this.share, 'newPassword') + } catch ({ property, message }) { + this.onSyncError(property, message) + } finally { + this.saving = false + } + }) + } else { + console.error('Cannot update share.', this.share, 'No valid id') + } + }, + + /** + * Manage sync errors + * @param {string} property the errored property, e.g. 'password' + * @param {string} message the error message + */ + onSyncError(property, message) { + // re-open menu if closed + this.open = true + switch (property) { + case 'password': + case 'pending': + case 'expireDate': + case 'note': { + // show error + this.$set(this.errors, property, message) + + let propertyEl = this.$refs[property] + if (propertyEl) { + if (propertyEl.$el) { + propertyEl = propertyEl.$el + } + // focus if there is a focusable action element + const focusable = propertyEl.querySelector('.focusable') + if (focusable) { + focusable.focus() + } + } + break + } + } + }, + + /** + * Debounce queueUpdate to avoid requests spamming + * more importantly for text data + * + * @param {string} property the property to sync + */ + debounceQueueUpdate: debounce(function(property) { + this.queueUpdate(property) + }, 500) + } +} diff --git a/apps/files_sharing/src/models/Share.js b/apps/files_sharing/src/models/Share.js new file mode 100644 index 00000000000..e9d84fb5556 --- /dev/null +++ b/apps/files_sharing/src/models/Share.js @@ -0,0 +1,444 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export default class Share { + + #share; + + /** + * Create the share object + * + * @param {Object} ocsData ocs request response + */ + constructor(ocsData) { + if (ocsData.ocs && ocsData.ocs.data && ocsData.ocs.data[0]) { + ocsData = ocsData.ocs.data[0] + } + + // convert int into boolean + ocsData.hide_download = !!ocsData.hide_download + ocsData.mail_send = !!ocsData.mail_send + + // store state + this.#share = ocsData + } + + /** + * Get the share state + * ! used for reactivity purpose + * Do not remove. It allow vuejs to + * inject its watchers into the #share + * state and make the whole class reactive + * + * @returns {Object} the share raw state + * @readonly + * @memberof Sidebar + */ + get state() { + return this.#share + } + + /** + * get the share id + * + * @returns {int} + * @readonly + * @memberof Share + */ + get id() { + return this.#share.id + } + + /** + * Get the share type + * + * @returns {int} + * @readonly + * @memberof Share + */ + get type() { + return this.#share.share_type + } + + /** + * Get the share permissions + * See OC.PERMISSION_* variables + * + * @returns {int} + * @readonly + * @memberof Share + */ + get permissions() { + return this.#share.permissions + } + + /** + * Set the share permissions + * See OC.PERMISSION_* variables + * + * @param {int} permissions valid permission, See OC.PERMISSION_* variables + * @memberof Share + */ + set permissions(permissions) { + this.#share.permissions = permissions + } + + // SHARE OWNER -------------------------------------------------- + /** + * Get the share owner uid + * + * @returns {string} + * @readonly + * @memberof Share + */ + get owner() { + return this.#share.uid_owner + } + + /** + * Get the share owner's display name + * + * @returns {string} + * @readonly + * @memberof Share + */ + get ownerDisplayName() { + return this.#share.displayname_owner + } + + // SHARED WITH -------------------------------------------------- + /** + * Get the share with entity uid + * + * @returns {string} + * @readonly + * @memberof Share + */ + get shareWith() { + return this.#share.share_with + } + + /** + * Get the share with entity display name + * fallback to its uid if none + * + * @returns {string} + * @readonly + * @memberof Share + */ + get shareWithDisplayName() { + return this.#share.share_with_displayname + || this.#share.share_with + } + + /** + * Get the share with avatar if any + * + * @returns {string} + * @readonly + * @memberof Share + */ + get shareWithAvatar() { + return this.#share.share_with_avatar + } + + // SHARED FILE OR FOLDER OWNER ---------------------------------- + /** + * Get the shared item owner uid + * + * @returns {string} + * @readonly + * @memberof Share + */ + get uidFileOwner() { + return this.#share.uid_file_owner + } + + /** + * Get the shared item display name + * fallback to its uid if none + * + * @returns {string} + * @readonly + * @memberof Share + */ + get displaynameFileOwner() { + return this.#share.displayname_file_owner + || this.#share.uid_file_owner + } + + // TIME DATA ---------------------------------------------------- + /** + * Get the share creation timestamp + * + * @returns {int} + * @readonly + * @memberof Share + */ + get createdTime() { + return this.#share.stime + } + + /** + * Get the expiration date as a string format + * + * @returns {string} + * @readonly + * @memberof Share + */ + get expireDate() { + return this.#share.expiration + } + + /** + * Set the expiration date as a string format + * e.g. YYYY-MM-DD + * + * @param {string} date the share expiration date + * @memberof Share + */ + set expireDate(date) { + this.#share.expiration = date + } + + // EXTRA DATA --------------------------------------------------- + /** + * Get the public share token + * + * @returns {string} the token + * @readonly + * @memberof Share + */ + get token() { + return this.#share.token + } + + /** + * Get the share note if any + * + * @returns {string} + * @readonly + * @memberof Share + */ + get note() { + return this.#share.note + } + + /** + * Set the share note if any + * + * @param {string} note the note + * @memberof Share + */ + set note(note) { + this.#share.note = note.trim() + } + + /** + * Have a mail been sent + * + * @returns {boolean} + * @readonly + * @memberof Share + */ + get mailSend() { + return this.#share.mail_send === true + } + + /** + * Hide the download button on public page + * + * @returns {boolean} + * @readonly + * @memberof Share + */ + get hideDownload() { + return this.#share.hide_download === true + } + + /** + * Hide the download button on public page + * + * @param {boolean} state hide the button ? + * @memberof Share + */ + set hideDownload(state) { + this.#share.hide_download = state === true + } + + /** + * Password protection of the share + * + * @returns {string} + * @readonly + * @memberof Share + */ + get password() { + return this.#share.password + } + + /** + * Password protection of the share + * + * @param {string} password the share password + * @memberof Share + */ + set password(password) { + this.#share.password = password.trim() + } + + // SHARED ITEM DATA --------------------------------------------- + /** + * Get the shared item absolute full path + * + * @returns {string} + * @readonly + * @memberof Share + */ + get path() { + return this.#share.path + } + + /** + * Return the item type: file or folder + * + * @returns {string} 'folder' or 'file' + * @readonly + * @memberof Share + */ + get itemType() { + return this.#share.item_type + } + + /** + * Get the shared item mimetype + * + * @returns {string} + * @readonly + * @memberof Share + */ + get mimetype() { + return this.#share.mimetype + } + + /** + * Get the shared item id + * + * @returns {int} + * @readonly + * @memberof Share + */ + get fileSource() { + return this.#share.file_source + } + + /** + * Get the target path on the receiving end + * e.g the file /xxx/aaa will be shared in + * the receiving root as /aaa, the fileTarget is /aaa + * + * @returns {string} + * @readonly + * @memberof Share + */ + get fileTarget() { + return this.#share.file_target + } + + /** + * Get the parent folder id if any + * + * @returns {int} + * @readonly + * @memberof Share + */ + get fileParent() { + return this.#share.file_parent + } + + // PERMISSIONS Shortcuts + /** + * Does this share have CREATE permissions + * + * @returns {boolean} + * @readonly + * @memberof Share + */ + get hasCreatePermission() { + return !!((this.permissions & OC.PERMISSION_CREATE)) + } + + /** + * Does this share have DELETE permissions + * + * @returns {boolean} + * @readonly + * @memberof Share + */ + get hasDeletePermission() { + return !!((this.permissions & OC.PERMISSION_DELETE)) + } + + /** + * Does this share have UPDATE permissions + * + * @returns {boolean} + * @readonly + * @memberof Share + */ + get hasUpdatePermission() { + return !!((this.permissions & OC.PERMISSION_UPDATE)) + } + + /** + * Does this share have SHARE permissions + * + * @returns {boolean} + * @readonly + * @memberof Share + */ + get hasSharePermission() { + return !!((this.permissions & OC.PERMISSION_SHARE)) + } + + // TODO: SORT THOSE PROPERTIES + get label() { + return this.#share.label + } + + get parent() { + return this.#share.parent + } + + get storageId() { + return this.#share.storage_id + } + + get storage() { + return this.#share.storage + } + + get itemSource() { + return this.#share.item_source + } + +} diff --git a/apps/files_sharing/src/services/ConfigService.js b/apps/files_sharing/src/services/ConfigService.js new file mode 100644 index 00000000000..7058c714776 --- /dev/null +++ b/apps/files_sharing/src/services/ConfigService.js @@ -0,0 +1,223 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export default class Config { + + /** + * Is public upload allowed on link shares ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isPublicUploadEnabled() { + return document.getElementById('filestable') + && document.getElementById('filestable').dataset.allowPublicUpload === 'yes' + } + + /** + * Are link share allowed ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isShareWithLinkAllowed() { + return document.getElementById('allowShareWithLink') + && document.getElementById('allowShareWithLink').value === 'yes' + } + + /** + * Get the federated sharing documentation link + * + * @returns {string} + * @readonly + * @memberof Config + */ + get federatedShareDocLink() { + return OC.appConfig.core.federatedCloudShareDoc + } + + /** + * Get the default expiration date as string + * + * @returns {string} + * @readonly + * @memberof Config + */ + get defaultExpirationDateString() { + let expireDateString = '' + if (this.isDefaultExpireDateEnabled) { + const date = window.moment.utc() + const expireAfterDays = this.defaultExpireDate + date.add(expireAfterDays, 'days') + expireDateString = date.format('YYYY-MM-DD') + } + return expireDateString + } + + /** + * Are link shares password-enforced ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get enforcePasswordForPublicLink() { + return OC.appConfig.core.enforcePasswordForPublicLink === true + } + + /** + * Is password asked by default on link shares ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get enableLinkPasswordByDefault() { + return OC.appConfig.core.enableLinkPasswordByDefault === true + } + + /** + * Is link shares expiration enforced ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isDefaultExpireDateEnforced() { + return OC.appConfig.core.defaultExpireDateEnforced === true + } + + /** + * Is there a default expiration date for new link shares ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isDefaultExpireDateEnabled() { + return OC.appConfig.core.defaultExpireDateEnabled === true + } + + /** + * Are users on this server allowed to send shares to other servers ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isRemoteShareAllowed() { + return OC.appConfig.core.remoteShareAllowed === true + } + + /** + * Is sharing my mail (link share) enabled ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isMailShareAllowed() { + return OC.appConfig.shareByMailEnabled !== undefined + } + + /** + * Get the default days to expiration + * + * @returns {int} + * @readonly + * @memberof Config + */ + get defaultExpireDate() { + return OC.appConfig.core.defaultExpireDate + } + + /** + * Is resharing allowed ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isResharingAllowed() { + return OC.appConfig.core.resharingAllowed === true + } + + /** + * Is password enforced for mail shares ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get isPasswordForMailSharesRequired() { + return (OC.appConfig.shareByMail === undefined) ? false : OC.appConfig.shareByMail.enforcePasswordProtection === true + } + + /** + * Is sharing with groups allowed ? + * + * @returns {boolean} + * @readonly + * @memberof Config + */ + get allowGroupSharing() { + return OC.appConfig.core.allowGroupSharing === true + } + + /** + * Get the maximum results of a share search + * + * @returns {int} + * @readonly + * @memberof Config + */ + get maxAutocompleteResults() { + return parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200 + } + + /** + * Get the minimal string length + * to initiate a share search + * + * @returns {int} + * @readonly + * @memberof Config + */ + get minSearchStringLength() { + return parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0 + } + + /** + * Get the password policy config + * + * @returns {Object} + * @readonly + * @memberof Config + */ + get passwordPolicy() { + const capabilities = OC.getCapabilities() + return capabilities.password_policy ? capabilities.password_policy : {} + } + +} diff --git a/apps/files_sharing/src/services/ExternalLinkActions.js b/apps/files_sharing/src/services/ExternalLinkActions.js new file mode 100644 index 00000000000..f67a1cb1155 --- /dev/null +++ b/apps/files_sharing/src/services/ExternalLinkActions.js @@ -0,0 +1,63 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export default class ExternalLinkActions { + + #state; + + constructor() { + // init empty state + this.#state = {} + + // init default values + this.#state.actions = [] + console.debug('OCA.Sharing.ExternalLinkActions initialized') + } + + /** + * Get the state + * + * @readonly + * @memberof ExternalLinkActions + * @returns {Object} the data state + */ + get state() { + return this.#state + } + + /** + * Register a new action for the link share + * Mostly used by the social sharing app. + * + * @param {Object} action new action component to register + * @returns {boolean} + */ + registerAction(action) { + if (typeof action === 'object' && action.render && action.components) { + this.#state.actions.push(action) + return true + } + console.error(`Invalid action component provided`, action) + return false + } + +} diff --git a/apps/files_sharing/src/services/ShareSearch.js b/apps/files_sharing/src/services/ShareSearch.js new file mode 100644 index 00000000000..dda1feb30a2 --- /dev/null +++ b/apps/files_sharing/src/services/ShareSearch.js @@ -0,0 +1,71 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export default class ShareSearch { + + #state; + + constructor() { + // init empty state + this.#state = {} + + // init default values + this.#state.results = [] + console.debug('OCA.Sharing.ShareSearch initialized') + } + + /** + * Get the state + * + * @readonly + * @memberof ShareSearch + * @returns {Object} the data state + */ + get state() { + return this.#state + } + + /** + * Register a new result + * Mostly used by the guests app. + * We should consider deprecation and add results via php ? + * + * @param {Object} result entry to append + * @param {string} [result.user] entry user + * @param {string} result.displayName entry first line + * @param {string} [result.desc] entry second line + * @param {string} [result.icon] entry icon + * @param {function} result.handler function to run on entry selection + * @param {function} [result.condition] condition to add entry or not + * @returns {boolean} + */ + addNewResult(result) { + if (result.displayName.trim() !== '' + && typeof result.handler === 'function') { + this.#state.results.push(result) + return true + } + console.error(`Invalid search result provided`, result) + return false + } + +} diff --git a/apps/files_sharing/src/share.js b/apps/files_sharing/src/share.js index a66f166759f..46e46e37552 100644 --- a/apps/files_sharing/src/share.js +++ b/apps/files_sharing/src/share.js @@ -195,7 +195,7 @@ // do not open sidebar if permission is set and equal to 0 var permissions = parseInt(context.$file.data('share-permissions'), 10) if (isNaN(permissions) || permissions > 0) { - fileList.showDetailsView(fileName, 'shareTabView') + fileList.showDetailsView(fileName, 'sharing') } }, render: function(actionSpec, isDefault, context) { @@ -209,37 +209,37 @@ } }) - var shareTab = new OCA.Sharing.ShareTabView('shareTabView', { order: -20 }) - // detect changes and change the matching list entry - shareTab.on('sharesChanged', function(shareModel) { - var fileInfoModel = shareModel.fileInfoModel - var $tr = fileList.findFileEl(fileInfoModel.get('name')) + var shareTab = new OCA.Sharing.ShareTabView('sharing', {order: -20}) + // // detect changes and change the matching list entry + // shareTab.on('sharesChanged', function(shareModel) { + // var fileInfoModel = shareModel.fileInfoModel + // var $tr = fileList.findFileEl(fileInfoModel.get('name')) - // We count email shares as link share - var hasLinkShares = shareModel.hasLinkShares() - shareModel.get('shares').forEach(function(share) { - if (share.share_type === OC.Share.SHARE_TYPE_EMAIL) { - hasLinkShares = true - } - }) + // // We count email shares as link share + // var hasLinkShares = shareModel.hasLinkShares(); + // shareModel.get('shares').forEach(function (share) { + // if (share.share_type === OC.Share.SHARE_TYPE_EMAIL) { + // hasLinkShares = true; + // } + // }) - OCA.Sharing.Util._updateFileListDataAttributes(fileList, $tr, shareModel) - if (!OCA.Sharing.Util._updateFileActionIcon($tr, shareModel.hasUserShares(), hasLinkShares)) { - // remove icon, if applicable - OC.Share.markFileAsShared($tr, false, false) - } + // OCA.Sharing.Util._updateFileListDataAttributes(fileList, $tr, shareModel); + // if (!OCA.Sharing.Util._updateFileActionIcon($tr, shareModel.hasUserShares(), hasLinkShares)) { + // // remove icon, if applicable + // OC.Share.markFileAsShared($tr, false, false) + // } - // FIXME: this is too convoluted. We need to get rid of the above updates - // and only ever update the model and let the events take care of rerendering - fileInfoModel.set({ - shareTypes: shareModel.getShareTypes(), - // in case markFileAsShared decided to change the icon, - // we need to modify the model - // (FIXME: yes, this is hacky) - icon: $tr.attr('data-icon') - }) - }) - fileList.registerTabView(shareTab) + // // FIXME: this is too convoluted. We need to get rid of the above updates + // // and only ever update the model and let the events take care of rerendering + // fileInfoModel.set({ + // shareTypes: shareModel.getShareTypes(), + // // in case markFileAsShared decided to change the icon, + // // we need to modify the model + // // (FIXME: yes, this is hacky) + // icon: $tr.attr('data-icon') + // }) + // }) + // fileList.registerTabView(shareTab) var breadCrumbSharingDetailView = new OCA.Sharing.ShareBreadCrumbView({ shareTab: shareTab }) fileList.registerBreadCrumbDetailView(breadCrumbSharingDetailView) diff --git a/apps/files_sharing/src/sharebreadcrumbview.js b/apps/files_sharing/src/sharebreadcrumbview.js index a90c94b6d7d..c712229b2ee 100644 --- a/apps/files_sharing/src/sharebreadcrumbview.js +++ b/apps/files_sharing/src/sharebreadcrumbview.js @@ -93,7 +93,7 @@ dirInfo: self._dirInfo }) }) - OCA.Files.App.fileList.showDetailsView(fileInfoModel, 'shareTabView') + OCA.Files.App.fileList.showDetailsView(fileInfoModel, 'sharing') } }) diff --git a/apps/files_sharing/src/utils/SharedWithMe.js b/apps/files_sharing/src/utils/SharedWithMe.js new file mode 100644 index 00000000000..b2e2e34a9bb --- /dev/null +++ b/apps/files_sharing/src/utils/SharedWithMe.js @@ -0,0 +1,86 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Get the shared with me title + * + * @param {Share} share current share + * @returns {string} the title + */ +const shareWithTitle = function(share) { + if (share.type === OC.Share.type_GROUP) { + return t( + 'files_sharing', + 'Shared with you and the group {group} by {owner}', + { + group: share.shareWithDisplayName, + owner: share.ownerDisplayName + }, + undefined, + { escape: false } + ) + } else if (share.type === OC.Share.type_CIRCLE) { + return t( + 'files_sharing', + 'Shared with you and {circle} by {owner}', + { + circle: share.shareWithDisplayName, + owner: share.ownerDisplayName + }, + undefined, + { escape: false } + ) + } else if (share.type === OC.Share.type_ROOM) { + if (this.model.get('reshare').share_with_displayname) { + return t( + 'files_sharing', + 'Shared with you and the conversation {conversation} by {owner}', + { + conversation: share.shareWithDisplayName, + owner: share.ownerDisplayName + }, + undefined, + { escape: false } + ) + } else { + return t( + 'files_sharing', + 'Shared with you in a conversation by {owner}', + { + owner: share.ownerDisplayName + }, + undefined, + { escape: false } + ) + } + } else { + return t( + 'files_sharing', + 'Shared with you by {owner}', + { owner: share.ownerDisplayName }, + undefined, + { escape: false } + ) + } +} + +export { shareWithTitle } diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue new file mode 100644 index 00000000000..1c01886ca46 --- /dev/null +++ b/apps/files_sharing/src/views/SharingLinkList.vue @@ -0,0 +1,141 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <ul class="sharing-link-list"> + <!-- If no link shares, show the add link default entry --> + <SharingEntryLink v-if="!hasLinkShares && canReshare" + :can-reshare="canReshare" + :file-info="fileInfo" + @add:share="addShare" /> + + <!-- Else we display the list --> + <template v-if="hasShares"> + <!-- using shares[index] to work with .sync --> + <SharingEntryLink v-for="(share, index) in shares" + :key="share.id" + :can-reshare="canReshare" + :share.sync="shares[index]" + :file-info="fileInfo" + @add:share="addShare(...arguments)" + @update:share="awaitForShare(...arguments)" + @remove:share="removeShare" /> + </template> + </ul> +</template> + +<script> +// eslint-disable-next-line no-unused-vars +import Share from '../models/Share' +import ShareTypes from '../mixins/ShareTypes' +import SharingEntryLink from '../components/SharingEntryLink' + +export default { + name: 'SharingLinkList', + + components: { + SharingEntryLink + }, + + mixins: [ShareTypes], + + props: { + fileInfo: { + type: Object, + default: () => {}, + required: true + }, + shares: { + type: Array, + default: () => [], + required: true + }, + canReshare: { + type: Boolean, + required: true + } + }, + + computed: { + /** + * Do we have link shares? + * Using this to still show the `new link share` + * button regardless of mail shares + * + * @returns {Array} + */ + hasLinkShares() { + return this.shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK).length > 0 + }, + + /** + * Do we have any link or email shares? + * + * @returns {boolean} + */ + hasShares() { + return this.shares.length > 0 + } + }, + + methods: { + /** + * Add a new share into the link shares list + * and return the newly created share component + * + * @param {Share} share the share to add to the array + * @param {Function} resolve a function to run after the share is added and its component initialized + */ + addShare(share, resolve) { + this.shares.unshift(share) + this.awaitForShare(share, resolve) + }, + + /** + * Await for next tick and render after the list updated + * Then resolve with the matched vue component of the + * provided share object + * + * @param {Share} share newly created share + * @param {Function} resolve a function to execute after + */ + awaitForShare(share, resolve) { + this.$nextTick(() => { + const newShare = this.$children.find(component => component.share === share) + if (newShare) { + resolve(newShare) + } + }) + }, + + /** + * Remove a share from the shares list + * + * @param {Share} share the share to remove + */ + removeShare(share) { + const index = this.shares.findIndex(item => item === share) + this.shares.splice(index, 1) + } + } +} +</script> diff --git a/apps/files_sharing/src/views/SharingList.vue b/apps/files_sharing/src/views/SharingList.vue new file mode 100644 index 00000000000..c2ecbbbd1aa --- /dev/null +++ b/apps/files_sharing/src/views/SharingList.vue @@ -0,0 +1,76 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <ul class="sharing-sharee-list"> + <SharingEntry v-for="share in shares" + :key="share.id" + :file-info="fileInfo" + :share="share" + @remove:share="removeShare" /> + </ul> +</template> + +<script> +// eslint-disable-next-line no-unused-vars +import Share from '../models/Share' +import SharingEntry from '../components/SharingEntry' + +export default { + name: 'SharingList', + + components: { + SharingEntry + }, + + props: { + fileInfo: { + type: Object, + default: () => {}, + required: true + }, + shares: { + type: Array, + default: () => [], + required: true + } + }, + + computed: { + hasShares() { + return this.shares.length === 0 + } + }, + + methods: { + /** + * Remove a share from the shares list + * + * @param {Share} share the share to remove + */ + removeShare(share) { + const index = this.shares.findIndex(item => item === share) + this.shares.splice(index, 1) + } + } +} +</script> diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue new file mode 100644 index 00000000000..5a9b24c36b3 --- /dev/null +++ b/apps/files_sharing/src/views/SharingTab.vue @@ -0,0 +1,318 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <Tab :icon="icon" :name="name" :class="{ 'icon-loading': loading }"> + <!-- error message --> + <div v-if="error" class="emptycontent"> + <div class="icon icon-error" /> + <h2>{{ error }}</h2> + </div> + + <!-- shares content --> + <template v-else> + <!-- shared with me information --> + <SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare"> + <template #avatar> + <Avatar #avatar + :user="sharedWithMe.user" + :display-name="sharedWithMe.displayName" + class="sharing-entry__avatar" + tooltip-message="" /> + </template> + </SharingEntrySimple> + + <!-- add new share input --> + <SharingInput v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :link-shares="linkShares" + :reshare="reshare" + :shares="shares" + @add:share="addShare" /> + + <!-- link shares list --> + <SharingLinkList v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :shares="linkShares" /> + + <!-- other shares list --> + <SharingList v-if="!loading" + :shares="shares" + :file-info="fileInfo" /> + + <!-- internal link copy --> + <SharingEntryInternal :file-info="fileInfo" /> + </template> + </Tab> +</template> + +<script> +import { generateOcsUrl } from '@nextcloud/router' +import Tab from 'nextcloud-vue/dist/Components/AppSidebarTab' +import Avatar from 'nextcloud-vue/dist/Components/Avatar' +import axios from '@nextcloud/axios' + +import { shareWithTitle } from '../utils/SharedWithMe' +import Share from '../models/Share' +import ShareTypes from '../mixins/ShareTypes' +import SharingEntryInternal from '../components/SharingEntryInternal' +import SharingEntrySimple from '../components/SharingEntrySimple' +import SharingInput from '../components/SharingInput' + +import SharingLinkList from './SharingLinkList' +import SharingList from './SharingList' + +export default { + name: 'SharingTab', + + components: { + Avatar, + SharingEntryInternal, + SharingEntrySimple, + SharingInput, + SharingLinkList, + SharingList, + Tab + }, + + mixins: [ShareTypes], + + props: { + fileInfo: { + type: Object, + default: () => {}, + required: true + } + }, + + data() { + return { + error: '', + expirationInterval: null, + icon: 'icon-share', + loading: true, + name: t('files_sharing', 'Sharing'), + // reshare Share object + reshare: null, + sharedWithMe: {}, + shares: [], + linkShares: [], + sections: OCA.Sharing.ShareTabSections.getSections() + } + }, + + computed: { + /** + * Needed to differenciate the tabs + * pulled from the AppSidebarTab component + * + * @returns {string} + */ + id() { + return this.name.toLowerCase().replace(/ /g, '-') + }, + + /** + * Returns the current active tab + * needed because AppSidebarTab also uses $parent.activeTab + * + * @returns {string} + */ + activeTab() { + return this.$parent.activeTab + }, + + /** + * Is this share shared with me? + * + * @returns {boolean} + */ + isSharedWithMe() { + return Object.keys(this.sharedWithMe).length > 0 + }, + + canReshare() { + return !!(this.fileInfo.permissions & OC.PERMISSION_SHARE) + || !!(this.reshare && this.reshare.hasSharePermission) + } + }, + + watch: { + fileInfo() { + this.resetState() + this.getShares() + } + }, + + beforeMount() { + this.getShares() + }, + + methods: { + /** + * Get the existing shares infos + */ + async getShares() { + try { + this.loading = true + + // init params + const shareUrl = generateOcsUrl('apps/files_sharing/api/v1', 2) + 'shares' + const format = 'json' + // TODO: replace with proper getFUllpath implementation of our own FileInfo model + const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') + + // fetch shares + const fetchShares = axios.get(shareUrl, { + params: { + format, + path, + reshares: true + } + }) + const fetchSharedWithMe = axios.get(shareUrl, { + params: { + format, + path, + shared_with_me: true + } + }) + + // wait for data + const [shares, sharedWithMe] = await Promise.all([fetchShares, fetchSharedWithMe]) + this.loading = false + + // process results + this.processSharedWithMe(sharedWithMe) + this.processShares(shares) + } catch (error) { + this.error = t('files_sharing', 'Unable to load the shares list') + this.loading = false + console.error('Error loading the shares list', error) + } + }, + + /** + * Reset the current view to its default state + */ + resetState() { + clearInterval(this.expirationInterval) + this.loading = true + this.error = '' + this.sharedWithMe = {} + this.shares = [] + }, + + /** + * Update sharedWithMe.subtitle with the appropriate + * expiration time left + * + * @param {Share} share the sharedWith Share object + */ + updateExpirationSubtitle(share) { + const expiration = moment(share.expireDate).unix() + this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', { + relativetime: OC.Util.relativeModifiedDate(expiration * 1000) + })) + + // share have expired + if (moment().unix() > expiration) { + clearInterval(this.expirationInterval) + // TODO: clear ui if share is expired + this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'this share just expired.')) + } + }, + + /** + * Process the current shares data + * and init shares[] + * + * @param {Object} share the share ocs api request data + * @param {Object} share.data the request data + */ + processShares({ data }) { + if (data.ocs && data.ocs.data && data.ocs.data.length > 0) { + // create Share objects and sort by newest + const shares = data.ocs.data + .map(share => new Share(share)) + .sort((a, b) => b.createdTime - a.createdTime) + + this.linkShares = shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) + this.shares = shares.filter(share => share.type !== this.SHARE_TYPES.SHARE_TYPE_LINK && share.type !== this.SHARE_TYPES.SHARE_TYPE_EMAIL) + } + }, + + /** + * Process the sharedWithMe share data + * and init sharedWithMe + * + * @param {Object} share the share ocs api request data + * @param {Object} share.data the request data + */ + processSharedWithMe({ data }) { + if (data.ocs && data.ocs.data && data.ocs.data[0]) { + const share = new Share(data) + const title = shareWithTitle(share) + const displayName = share.ownerDisplayName + const user = share.owner + + this.sharedWithMe = { + displayName, + title, + user + } + this.reshare = share + + // If we have an expiration date, use it as subtitle + // Refresh the status every 10s and clear if expired + if (share.expireDate && moment(share.expireDate).unix() > moment().unix()) { + // first update + this.updateExpirationSubtitle(share) + // interval update + this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share) + } + } + }, + + /** + * Insert share at top of arrays + * + * @param {Share} share the share to insert + */ + addShare(share) { + // only catching share type MAIL as link shares are added differently + // meaning: not from the ShareInput + if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + this.linkShares.unshift(share) + } else { + this.shares.unshift(share) + } + } + } +} +</script> + +<style lang="scss" scoped> + +</style> diff --git a/apps/files_sharing/webpack.js b/apps/files_sharing/webpack.js index 3fc0628b202..43a34559d41 100644 --- a/apps/files_sharing/webpack.js +++ b/apps/files_sharing/webpack.js @@ -4,6 +4,7 @@ module.exports = { entry: { 'additionalScripts': path.join(__dirname, 'src', 'additionalScripts.js'), 'files_sharing': path.join(__dirname, 'src', 'files_sharing.js'), + 'files_sharing_tab': path.join(__dirname, 'src', 'files_sharing_tab.js'), 'collaboration': path.join(__dirname, 'src', 'collaborationresourceshandler.js'), }, output: { diff --git a/core/js/files/client.js b/core/js/files/client.js index 98874d165bf..0daf7c9dc3f 100644 --- a/core/js/files/client.js +++ b/core/js/files/client.js @@ -323,6 +323,13 @@ data.isEncrypted = false; } + var isFavouritedProp = props['{' + Client.NS_OWNCLOUD + '}favorite']; + if (!_.isUndefined(isFavouritedProp)) { + data.isFavourited = isFavouritedProp === '1'; + } else { + data.isFavourited = false; + } + var contentType = props[Client.PROPERTY_GETCONTENTTYPE]; if (!_.isUndefined(contentType)) { data.mimetype = contentType; diff --git a/core/src/Polyfill/closest.js b/core/src/Polyfill/closest.js new file mode 100644 index 00000000000..1c608646127 --- /dev/null +++ b/core/src/Polyfill/closest.js @@ -0,0 +1,19 @@ +// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill + +if (!Element.prototype.matches) { + Element.prototype.matches + = Element.prototype.msMatchesSelector + || Element.prototype.webkitMatchesSelector +} + +if (!Element.prototype.closest) { + Element.prototype.closest = function(s) { + var el = this + + do { + if (el.matches(s)) return el + el = el.parentElement || el.parentNode + } while (el !== null && el.nodeType === 1) + return null + } +} diff --git a/core/src/Polyfill/index.js b/core/src/Polyfill/index.js index 055ab6343a4..306c72a0777 100644 --- a/core/src/Polyfill/index.js +++ b/core/src/Polyfill/index.js @@ -1,4 +1,4 @@ -/* +/** * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> * * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> @@ -20,4 +20,5 @@ */ import './console' +import './closest' import './windows-phone' diff --git a/core/src/main.js b/core/src/main.js index 29c657f5db3..3f0e82df95f 100644 --- a/core/src/main.js +++ b/core/src/main.js @@ -1,4 +1,4 @@ -/* +/** * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at> * * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at> @@ -20,8 +20,8 @@ */ import $ from 'jquery' -import '@babel/polyfill' import './Polyfill/index' +import '@babel/polyfill' // If you remove the line below, tests won't pass // eslint-disable-next-line no-unused-vars diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue index 08538ec2fe9..c7958aac15b 100644 --- a/core/src/views/Login.vue +++ b/core/src/views/Login.vue @@ -27,7 +27,7 @@ <LoginForm :username.sync="user" :redirect-url="redirectUrl" - :directLogin="directLogin" + :direct-login="directLogin" :messages="messages" :errors="errors" :throttle-delay="throttleDelay" diff --git a/package-lock.json b/package-lock.json index 70643e8c37f..1529af0274e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -359,6 +359,16 @@ "@babel/plugin-syntax-async-generators": "^7.2.0" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz", + "integrity": "sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.5.5", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz", @@ -2384,6 +2394,11 @@ "integrity": "sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=", "dev": true }, + "debounce": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz", + "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -6085,8 +6100,7 @@ "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, "p-is-promise": { "version": "2.1.0", @@ -6110,6 +6124,23 @@ "p-limit": "^2.0.0" } }, + "p-queue": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.2.0.tgz", + "integrity": "sha512-B2LXNONcyn/G6uz2UBFsGjmSa0e/br3jznlzhEyCXg56c7VhEpiT2pZxGOfv32Q3FSyugAdys9KGpsv3kV+Sbg==", + "requires": { + "eventemitter3": "^4.0.0", + "p-timeout": "^3.1.0" + } + }, + "p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "requires": { + "p-finally": "^1.0.0" + } + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -8184,6 +8215,11 @@ } } }, + "url-search-params-polyfill": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-7.0.0.tgz", + "integrity": "sha512-0SEH3s+wCNbxEE/rWUalN004ICNi23Q74Ksc0gS2kG8EXnbayxGOrV97JdwnIVPKZ75Xk0hvKXvtIC4xReLMgg==" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index 2a8f7d9e95d..cd8be3d6d53 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@babel/polyfill": "^7.6.0", "@chenfengyuan/vue-qrcode": "^1.0.1", + "@nextcloud/auth": "^0.3.1", "@nextcloud/axios": "^0.5.0", "@nextcloud/event-bus": "^0.2.1", "@nextcloud/initial-state": "^0.2.0", @@ -37,6 +38,7 @@ "clipboard": "^2.0.4", "css-vars-ponyfill": "^2.1.2", "davclient.js": "git+https://github.com/owncloud/davclient.js.git#0.2.1", + "debounce": "^1.2.0", "dompurify": "^2.0.7", "escape-html": "^1.0.3", "handlebars": "^4.4.5", @@ -53,12 +55,14 @@ "nextcloud-router": "0.0.9", "nextcloud-vue": "^0.12.7", "nextcloud-vue-collections": "^0.6.0", + "p-queue": "^6.1.0", "query-string": "^5.1.1", "select2": "3.5.1", "snap.js": "^2.0.9", "strengthify": "git+https://github.com/MorrisJobke/strengthify.git#0.5.8", "toastify-js": "^1.6.1", "underscore": "^1.9.1", + "url-search-params-polyfill": "^7.0.0", "v-tooltip": "^2.0.2", "vue": "^2.6.10", "vue-click-outside": "^1.0.7", @@ -72,6 +76,7 @@ }, "devDependencies": { "@babel/core": "^7.6.4", + "@babel/plugin-proposal-class-properties": "^7.5.5", "@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/preset-env": "^7.6.3", "@nextcloud/browserslist-config": "^1.0.0", @@ -104,5 +109,8 @@ }, "browserslist": [ "extends @nextcloud/browserslist-config" - ] + ], + "engines": { + "node": ">=10.0.0" + } } diff --git a/webpack.common.js b/webpack.common.js index 32645146066..53c5d5e6769 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -3,10 +3,10 @@ const path = require('path') const merge = require('webpack-merge') const { VueLoaderPlugin } = require('vue-loader') -const core = require('./core/webpack') - const accessibility = require('./apps/accessibility/webpack') const comments = require('./apps/comments/webpack') +const core = require('./core/webpack') +const files = require('./apps/files/webpack') const files_sharing = require('./apps/files_sharing/webpack') const files_trashbin = require('./apps/files_trashbin/webpack') const files_versions = require('./apps/files_versions/webpack') @@ -18,14 +18,15 @@ const updatenotifications = require('./apps/updatenotification/webpack') const workflowengine = require('./apps/workflowengine/webpack') const modules = { - core, - settings, accessibility, comments, + core, + files, files_sharing, files_trashbin, files_versions, oauth2, + settings, systemtags, twofactor_backupscodes, updatenotifications, |