]> source.dussan.org Git - nextcloud-server.git/commitdiff
Add OCA.Files.Sidebar
authorJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Thu, 23 May 2019 15:03:04 +0000 (17:03 +0200)
committerDaniel Calviño Sánchez <danxuliu@gmail.com>
Tue, 29 Oct 2019 11:56:00 +0000 (12:56 +0100)
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
46 files changed:
.babelrc.js
Makefile
apps/comments/src/filesplugin.js
apps/files/css/files.scss
apps/files/js/fileactions.js
apps/files/js/filelist.js
apps/files/js/merged-index.json
apps/files/src/components/LegacyTab.vue [new file with mode: 0644]
apps/files/src/components/LegacyView.vue [new file with mode: 0644]
apps/files/src/models/Tab.js [new file with mode: 0644]
apps/files/src/services/FileInfo.js [new file with mode: 0644]
apps/files/src/services/Sidebar.js [new file with mode: 0644]
apps/files/src/sidebar.js [new file with mode: 0644]
apps/files/src/views/Sidebar.vue [new file with mode: 0644]
apps/files/webpack.js [new file with mode: 0644]
apps/files_sharing/appinfo/app.php
apps/files_sharing/css/icons.scss [new file with mode: 0644]
apps/files_sharing/list.php
apps/files_sharing/src/components/SharingEntry.vue [new file with mode: 0644]
apps/files_sharing/src/components/SharingEntryInternal.vue [new file with mode: 0644]
apps/files_sharing/src/components/SharingEntryLink.vue [new file with mode: 0644]
apps/files_sharing/src/components/SharingEntrySimple.vue [new file with mode: 0644]
apps/files_sharing/src/components/SharingInput.vue [new file with mode: 0644]
apps/files_sharing/src/files_sharing_tab.js [new file with mode: 0644]
apps/files_sharing/src/mixins/ShareRequests.js [new file with mode: 0644]
apps/files_sharing/src/mixins/ShareTypes.js [new file with mode: 0644]
apps/files_sharing/src/mixins/SharesMixin.js [new file with mode: 0644]
apps/files_sharing/src/models/Share.js [new file with mode: 0644]
apps/files_sharing/src/services/ConfigService.js [new file with mode: 0644]
apps/files_sharing/src/services/ExternalLinkActions.js [new file with mode: 0644]
apps/files_sharing/src/services/ShareSearch.js [new file with mode: 0644]
apps/files_sharing/src/share.js
apps/files_sharing/src/sharebreadcrumbview.js
apps/files_sharing/src/utils/SharedWithMe.js [new file with mode: 0644]
apps/files_sharing/src/views/SharingLinkList.vue [new file with mode: 0644]
apps/files_sharing/src/views/SharingList.vue [new file with mode: 0644]
apps/files_sharing/src/views/SharingTab.vue [new file with mode: 0644]
apps/files_sharing/webpack.js
core/js/files/client.js
core/src/Polyfill/closest.js [new file with mode: 0644]
core/src/Polyfill/index.js
core/src/main.js
core/src/views/Login.vue
package-lock.json
package.json
webpack.common.js

index 5cfbddd7a0a3d69e63e1881150bcaaf0130e4cff..004c14b5119072714801f408612e4041938358c8 100644 (file)
@@ -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',
index a3e1d4eab6221da6109a2c0ea10b6a2650b26a5d..0de4d002eeed2d685517e96db44d6c9fd94afedf 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -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/
index e315dd2fef850b2d9ff74cd1fc88599cdfb7353d..3e0cdd7f706b684cb1d67daa24561dd75cf2b822 100644 (file)
                                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')
                                }
                        })
 
index 54f83f25be2ed265cbb488f896b94215e53692f9..9c1869d1ffc989ef1bdb2c290acc97610fc0ba51 100644 (file)
@@ -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 {
index d800f2b8eb598d8f2cdb71fcdd216dd35b1973fe..571cdcf6c38080557a32401d50cd3810acaa2a9d 100644 (file)
                                        }
                                        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
+                                       }
                                }
                        });
 
index 58e2bfae7ff047d44f38861d49fc487f1cd45c90..8cca43d57493bbd9f5678e0145b7e382ee8a58a2 100644 (file)
                 * @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);
                },
 
                /**
                 * @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('//', '/')
                },
 
                /**
                                        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');
                        }
                 * 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))
                        }
                },
 
                 * 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)
                        }
                },
 
index 8d25daa6b3c61f4effe232bb719b008fba014157..b673da858cb261bb0e68b2db3946c44e217c1110 100644 (file)
@@ -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 (file)
index 0000000..9a85ee7
--- /dev/null
@@ -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 (file)
index 0000000..e4a07ac
--- /dev/null
@@ -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 (file)
index 0000000..28902b0
--- /dev/null
@@ -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 (file)
index 0000000..aa026df
--- /dev/null
@@ -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 (file)
index 0000000..8f02a1b
--- /dev/null
@@ -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 (file)
index 0000000..b508e8a
--- /dev/null
@@ -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 (file)
index 0000000..9a00df1
--- /dev/null
@@ -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 (file)
index 0000000..4007722
--- /dev/null
@@ -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'
+       }
+}
index 747c20207468837e25ac616c9c9159e7f7f9c781..32159f7b97331d3b30eedcd0db37ff1323ec245a 100644 (file)
@@ -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 (file)
index 0000000..002235b
--- /dev/null
@@ -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
index 219fe2863ed127bf045a5b416cbace83612ecb66..5517c39971f30e62050d8f555ae49f8a1ce8da49 100644 (file)
@@ -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 (file)
index 0000000..857b57a
--- /dev/null
@@ -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 (file)
index 0000000..720c016
--- /dev/null
@@ -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 (file)
index 0000000..afeaee0
--- /dev/null
@@ -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 (file)
index 0000000..4538950
--- /dev/null
@@ -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 (file)
index 0000000..df222ea
--- /dev/null
@@ -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 (file)
index 0000000..18b4f4d
--- /dev/null
@@ -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 (file)
index 0000000..c534e86
--- /dev/null
@@ -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 (file)
index 0000000..81e6af7
--- /dev/null
@@ -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 (file)
index 0000000..d012f35
--- /dev/null
@@ -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 (file)
index 0000000..e9d84fb
--- /dev/null
@@ -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 (file)
index 0000000..7058c71
--- /dev/null
@@ -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 (file)
index 0000000..f67a1cb
--- /dev/null
@@ -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 (file)
index 0000000..dda1feb
--- /dev/null
@@ -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
+       }
+
+}
index a66f166759f5d07b101d853fa0bc088421da9cfe..46e46e375529c1ab188fbdef259addf691474d95 100644 (file)
                                        // 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) {
                                }
                        })
 
-                       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)
index a90c94b6d7dc5b1c45ebc3fe18af647f06bd42d9..c712229b2ee81247e4de68967016901843458233 100644 (file)
@@ -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 (file)
index 0000000..b2e2e34
--- /dev/null
@@ -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 (file)
index 0000000..1c01886
--- /dev/null
@@ -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 (file)
index 0000000..c2ecbbb
--- /dev/null
@@ -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 (file)
index 0000000..5a9b24c
--- /dev/null
@@ -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>
index 3fc0628b20233ad805b08d6663c69407c69751f7..43a34559d415ec6774d6e45035d2097b5222f125 100644 (file)
@@ -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: {
index 98874d165bf35260927f3bbf1eff89085f8217c9..0daf7c9dc3ffcf8c3b8c84bb8f9207a6e5ba0ab1 100644 (file)
                                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 (file)
index 0000000..1c60864
--- /dev/null
@@ -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
+       }
+}
index 055ab6343a43e35f8d9e12dae477d987850f4720..306c72a0777e6eba8e335cc02911e074dc7790d9 100644 (file)
@@ -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'
index 29c657f5db3f62bfd2bf353dff6b54178c5df42c..3f0e82df95f711d91ef23d89e6f0e2bf7b07eed3 100644 (file)
@@ -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
index 08538ec2fe9a3d21a7f570275f54b63b47313094..c7958aac15b58a7864e3b13050dd8505b703b2fa 100644 (file)
@@ -27,7 +27,7 @@
                                <LoginForm
                                        :username.sync="user"
                                        :redirect-url="redirectUrl"
-                                       :directLogin="directLogin"
+                                       :direct-login="directLogin"
                                        :messages="messages"
                                        :errors="errors"
                                        :throttle-delay="throttleDelay"
index 70643e8c37f978d44316b5920c23e8fe683beaee..1529af0274e0e14bafd37bb46402240eceb27cc4 100644 (file)
         "@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",
       "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",
     "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",
         "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",
         }
       }
     },
+    "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",
index 2a8f7d9e95daa08bf2e2a716f2e5629e56031b8e..cd8be3d6d533ba4ec19cae5d04ba6d7c5831c64e 100644 (file)
@@ -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",
     "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",
   },
   "browserslist": [
     "extends @nextcloud/browserslist-config"
-  ]
+  ],
+  "engines": {
+    "node": ">=10.0.0"
+  }
 }
index 32645146066a1937961690e6b9a974bcff1ea99c..53c5d5e67698306a7cca54369d3a9761151a19f4 100644 (file)
@@ -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,