Browse Source

Add OCA.Files.Sidebar

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
John Molakvoæ (skjnldsv) 4 years ago
parent
commit
fd90af50d9
46 changed files with 4607 additions and 109 deletions
  1. 4 1
      .babelrc.js
  2. 2 0
      Makefile
  3. 1 1
      apps/comments/src/filesplugin.js
  4. 2 1
      apps/files/css/files.scss
  5. 6 0
      apps/files/js/fileactions.js
  6. 27 42
      apps/files/js/filelist.js
  7. 24 23
      apps/files/js/merged-index.json
  8. 89 0
      apps/files/src/components/LegacyTab.vue
  9. 59 0
      apps/files/src/components/LegacyView.vue
  10. 59 0
      apps/files/src/models/Tab.js
  11. 67 0
      apps/files/src/services/FileInfo.js
  12. 109 0
      apps/files/src/services/Sidebar.js
  13. 59 0
      apps/files/src/sidebar.js
  14. 345 0
      apps/files/src/views/Sidebar.vue
  15. 13 0
      apps/files/webpack.js
  16. 1 0
      apps/files_sharing/appinfo/app.php
  17. 32 0
      apps/files_sharing/css/icons.scss
  18. 1 0
      apps/files_sharing/list.php
  19. 249 0
      apps/files_sharing/src/components/SharingEntry.vue
  20. 117 0
      apps/files_sharing/src/components/SharingEntryInternal.vue
  21. 769 0
      apps/files_sharing/src/components/SharingEntryLink.vue
  22. 97 0
      apps/files_sharing/src/components/SharingEntrySimple.vue
  23. 444 0
      apps/files_sharing/src/components/SharingInput.vue
  24. 39 0
      apps/files_sharing/src/files_sharing_tab.js
  25. 114 0
      apps/files_sharing/src/mixins/ShareRequests.js
  26. 39 0
      apps/files_sharing/src/mixins/ShareTypes.js
  27. 303 0
      apps/files_sharing/src/mixins/SharesMixin.js
  28. 444 0
      apps/files_sharing/src/models/Share.js
  29. 223 0
      apps/files_sharing/src/services/ConfigService.js
  30. 63 0
      apps/files_sharing/src/services/ExternalLinkActions.js
  31. 71 0
      apps/files_sharing/src/services/ShareSearch.js
  32. 29 29
      apps/files_sharing/src/share.js
  33. 1 1
      apps/files_sharing/src/sharebreadcrumbview.js
  34. 86 0
      apps/files_sharing/src/utils/SharedWithMe.js
  35. 141 0
      apps/files_sharing/src/views/SharingLinkList.vue
  36. 76 0
      apps/files_sharing/src/views/SharingList.vue
  37. 318 0
      apps/files_sharing/src/views/SharingTab.vue
  38. 1 0
      apps/files_sharing/webpack.js
  39. 7 0
      core/js/files/client.js
  40. 19 0
      core/src/Polyfill/closest.js
  41. 2 1
      core/src/Polyfill/index.js
  42. 2 2
      core/src/main.js
  43. 1 1
      core/src/views/Login.vue
  44. 38 2
      package-lock.json
  45. 9 1
      package.json
  46. 5 4
      webpack.common.js

+ 4 - 1
.babelrc.js

@@ -1,5 +1,8 @@
 module.exports = {
-	plugins: ['@babel/plugin-syntax-dynamic-import'],
+	plugins: [
+		'@babel/plugin-syntax-dynamic-import',
+		['@babel/plugin-proposal-class-properties', { loose: true }]
+	],
 	presets: [
 		[
 			'@babel/preset-env',

+ 2 - 0
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/

+ 1 - 1
apps/comments/src/filesplugin.js

@@ -104,7 +104,7 @@
 				actionHandler: function(fileName, context) {
 					context.$file.find('.action-comment').tooltip('hide')
 					// open sidebar in comments section
-					context.fileList.showDetailsView(fileName, 'commentsTabView')
+					context.fileList.showDetailsView(fileName, 'comments')
 				}
 			})
 

+ 2 - 1
apps/files/css/files.scss

@@ -85,8 +85,9 @@
 }
 
 /* fit app list view heights */
-.app-files #app-content>.viewcontainer {
+.app-files #app-content > .viewcontainer {
 	min-height: 0%;
+	width: 100%;
 }
 
 .app-files #app-content {

+ 6 - 0
apps/files/js/fileactions.js

@@ -704,6 +704,12 @@
 					}
 					context.fileList.do_delete(fileName, context.dir);
 					$('.tipsy').remove();
+
+					// close sidebar on delete
+					const path = context.dir + '/' + fileName
+					if (OCA.Files.Sidebar && OCA.Files.Sidebar.file === path) {
+						OCA.Files.Sidebar.file = undefined
+					}
 				}
 			});
 

+ 27 - 42
apps/files/js/filelist.js

@@ -610,11 +610,11 @@
 		 * @param {string} [tabId] optional tab id to select
 		 */
 		showDetailsView: function(fileName, tabId) {
+			console.warn('showDetailsView is deprecated! Use OCA.Files.Sidebar.activeTab. It will be removed in nextcloud 20.');
 			this._updateDetailsView(fileName);
 			if (tabId) {
-				this._detailsView.selectTab(tabId);
+				OCA.Files.Sidebar.activeTab = tabId;
 			}
-			OC.Apps.showAppSidebar(this._detailsView.$el);
 		},
 
 		/**
@@ -623,48 +623,23 @@
 		 * @param {string|OCA.Files.FileInfoModel} fileName file name from the current list or a FileInfoModel object
 		 * @param {boolean} [show=true] whether to open the sidebar if it was closed
 		 */
-		_updateDetailsView: function(fileName, show) {
-			if (!this._detailsView) {
+		_updateDetailsView: function(fileName) {
+			if (!(OCA.Files && OCA.Files.Sidebar)) {
+				console.error('No sidebar available');
 				return;
 			}
 
-			// show defaults to true
-			show = _.isUndefined(show) || !!show;
-			var oldFileInfo = this._detailsView.getFileInfo();
-			if (oldFileInfo) {
-				// TODO: use more efficient way, maybe track the highlight
-				this.$fileList.children().filterAttr('data-id', '' + oldFileInfo.get('id')).removeClass('highlighted');
-				oldFileInfo.off('change', this._onSelectedModelChanged, this);
-			}
-
 			if (!fileName) {
-				this._detailsView.setFileInfo(null);
-				if (this._currentFileModel) {
-					this._currentFileModel.off();
-				}
-				this._currentFileModel = null;
-				OC.Apps.hideAppSidebar(this._detailsView.$el);
-				return;
+				OCA.Files.Sidebar.file = null
+				return
+			} else if (typeof fileName !== 'string') {
+				fileName = ''
 			}
 
-			if (show && this._detailsView.$el.hasClass('disappear')) {
-				OC.Apps.showAppSidebar(this._detailsView.$el);
-			}
-
-			if (fileName instanceof OCA.Files.FileInfoModel) {
-				var model = fileName;
-			} else {
-				var $tr = this.findFileEl(fileName);
-				var model = this.getModelForFile($tr);
-				$tr.addClass('highlighted');
-			}
-
-			this._currentFileModel = model;
-
-			this._replaceDetailsViewElementIfNeeded();
-
-			this._detailsView.setFileInfo(model);
-			this._detailsView.$el.scrollTop(0);
+			// open sidebar and set file
+			const dir = `${this.dirInfo.path}/${this.dirInfo.name}`
+			const path = `${dir}/${fileName}`
+			OCA.Files.Sidebar.file = path.replace('//', '/')
 		},
 
 		/**
@@ -1404,6 +1379,13 @@
 					return OC.MimeType.getIconUrl('dir-external');
 				} else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') {
 					return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType);
+				} else if (fileInfo.shareTypes && (
+					fileInfo.shareTypes.indexOf(OC.Share.SHARE_TYPE_LINK) > -1
+					|| fileInfo.shareTypes.indexOf(OC.Share.SHARE_TYPE_EMAIL) > -1)
+				) {
+					return OC.MimeType.getIconUrl('dir-public')
+				} else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) {
+					return OC.MimeType.getIconUrl('dir-shared')
 				}
 				return OC.MimeType.getIconUrl('dir');
 			}
@@ -3654,8 +3636,10 @@
 		 * Register a tab view to be added to all views
 		 */
 		registerTabView: function(tabView) {
-			if (this._detailsView) {
-				this._detailsView.addTabView(tabView);
+			console.warn('registerTabView is deprecated! It will be removed in nextcloud 20.');
+			const name = tabView.getLabel()
+			if (name) {
+				OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab(name, tabView, true))
 			}
 		},
 
@@ -3663,8 +3647,9 @@
 		 * Register a detail view to be added to all views
 		 */
 		registerDetailView: function(detailView) {
-			if (this._detailsView) {
-				this._detailsView.addDetailView(detailView);
+			console.warn('registerDetailView is deprecated! It will be removed in nextcloud 20.');
+			if (detailView.el) {
+				OCA.Files.Sidebar.registerSecondaryView(detailView)
 			}
 		},
 

+ 24 - 23
apps/files/js/merged-index.json

@@ -1,33 +1,34 @@
 [
+  "dist/sidebar.js",
   "app.js",
-  "templates.js",
-  "file-upload.js",
-  "newfilemenu.js",
-  "jquery.fileupload.js",
-  "jquery-visibility.js",
-  "fileinfomodel.js",
-  "filesummary.js",
-  "filemultiselectmenu.js",
   "breadcrumb.js",
-  "filelist.js",
-  "search.js",
-  "favoritesfilelist.js",
-  "recentfilelist.js",
-  "tagsplugin.js",
-  "gotoplugin.js",
-  "favoritesplugin.js",
-  "recentplugin.js",
   "detailfileinfoview.js",
-  "sidebarpreviewmanager.js",
-  "sidebarpreviewtext.js",
-  "detailtabview.js",
-  "semaphore.js",
-  "mainfileinfodetailview.js",
-  "operationprogressbar.js",
   "detailsview.js",
+  "detailtabview.js",
+  "favoritesfilelist.js",
+  "favoritesplugin.js",
+  "file-upload.js",
   "fileactions.js",
   "fileactionsmenu.js",
+  "fileinfomodel.js",
+  "filelist.js",
+  "filemultiselectmenu.js",
   "files.js",
+  "filesummary.js",
+  "gotoplugin.js",
+  "jquery-visibility.js",
+  "jquery.fileupload.js",
   "keyboardshortcuts.js",
-  "navigation.js"
+  "mainfileinfodetailview.js",
+  "navigation.js",
+  "newfilemenu.js",
+  "operationprogressbar.js",
+  "recentfilelist.js",
+  "recentplugin.js",
+  "search.js",
+  "semaphore.js",
+  "sidebarpreviewmanager.js",
+  "sidebarpreviewtext.js",
+  "tagsplugin.js",
+  "templates.js"
 ]

+ 89 - 0
apps/files/src/components/LegacyTab.vue

@@ -0,0 +1,89 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<AppSidebarTab :icon="icon"
+		:name="name"
+		:active-tab="activeTab" />
+</template>
+<script>
+import AppSidebarTab from 'nextcloud-vue/dist/Components/AppSidebarTab'
+
+export default {
+	name: 'LegacyTab',
+	components: {
+		AppSidebarTab: AppSidebarTab
+	},
+	props: {
+		component: {
+			type: Object,
+			required: true
+		},
+		name: {
+			type: String,
+			default: '',
+			required: true
+		},
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		}
+	},
+	computed: {
+		icon() {
+			return this.component.getIcon()
+		},
+		id() {
+			// copied from AppSidebarTab
+			return this.name.toLowerCase().replace(/ /g, '-')
+		},
+		order() {
+			return this.component.order
+				? this.component.order
+				: 0
+		},
+		// needed because AppSidebarTab also uses $parent.activeTab
+		activeTab() {
+			return this.$parent.activeTab
+		}
+	},
+	watch: {
+		activeTab(activeTab) {
+			if (activeTab === this.id && this.fileInfo) {
+				this.setFileInfo(this.fileInfo)
+			}
+		}
+	},
+	mounted() {
+		// append the backbone element and set the FileInfo
+		this.component.$el.appendTo(this.$el)
+	},
+	methods: {
+		setFileInfo(fileInfo) {
+			this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo))
+		}
+	}
+}
+</script>
+<style>
+</style>

+ 59 - 0
apps/files/src/components/LegacyView.vue

@@ -0,0 +1,59 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<div />
+</template>
+<script>
+export default {
+	name: 'LegacyView',
+	props: {
+		component: {
+			type: Object,
+			required: true
+		},
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		}
+	},
+	watch: {
+		fileInfo(fileInfo) {
+			// update the backbone model FileInfo
+			this.setFileInfo(fileInfo)
+		}
+	},
+	mounted() {
+		// append the backbone element and set the FileInfo
+		this.component.$el.replaceAll(this.$el)
+		this.setFileInfo(this.fileInfo)
+	},
+	methods: {
+		setFileInfo(fileInfo) {
+			this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo))
+		}
+	}
+}
+</script>
+<style>
+</style>

+ 59 - 0
apps/files/src/models/Tab.js

@@ -0,0 +1,59 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export default class Tab {
+
+	#component;
+	#legacy;
+	#name;
+
+	/**
+	 * Create a new tab instance
+	 *
+	 * @param {string} name the name of this tab
+	 * @param {Object} component the vue component
+	 * @param {boolean} [legacy] is this a legacy tab
+	 */
+	constructor(name, component, legacy) {
+		this.#name = name
+		this.#component = component
+		this.#legacy = legacy === true
+
+		if (this.#legacy) {
+			console.warn('Legacy tabs are deprecated! They will be removed in nextcloud 20.')
+		}
+
+	}
+
+	get name() {
+		return this.#name
+	}
+
+	get component() {
+		return this.#component
+	}
+
+	get isLegacyTab() {
+		return this.#legacy === true
+	}
+
+}

+ 67 - 0
apps/files/src/services/FileInfo.js

@@ -0,0 +1,67 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import axios from '@nextcloud/axios'
+
+export default async function(url) {
+	const response = await axios({
+		method: 'PROPFIND',
+		url,
+		data: `<?xml version="1.0"?>
+			<d:propfind  xmlns:d="DAV:"
+				xmlns:oc="http://owncloud.org/ns"
+				xmlns:nc="http://nextcloud.org/ns"
+				xmlns:ocs="http://open-collaboration-services.org/ns">
+			<d:prop>
+				<d:getlastmodified />
+				<d:getetag />
+				<d:getcontenttype />
+				<d:resourcetype />
+				<oc:fileid />
+				<oc:permissions />
+				<oc:size />
+				<d:getcontentlength />
+				<nc:has-preview />
+				<nc:mount-type />
+				<nc:is-encrypted />
+				<ocs:share-permissions />
+				<oc:tags />
+				<oc:favorite />
+				<oc:comments-unread />
+				<oc:owner-id />
+				<oc:owner-display-name />
+				<oc:share-types />
+			</d:prop>
+			</d:propfind>`
+	})
+
+	// TODO: create new parser or use cdav-lib when available
+	const file = OCA.Files.App.fileList.filesClient._client.parseMultiStatus(response.data)
+	// TODO: create new parser or use cdav-lib when available
+	const fileInfo = OCA.Files.App.fileList.filesClient._parseFileInfo(file[0])
+
+	// TODO remove when no more legacy backbone is used
+	fileInfo.get = (key) => fileInfo[key]
+	fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory'
+
+	return fileInfo
+}

+ 109 - 0
apps/files/src/services/Sidebar.js

@@ -0,0 +1,109 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export default class Sidebar {
+
+	#state;
+	#view;
+
+	constructor() {
+		// init empty state
+		this.#state = {}
+
+		// init default values
+		this.#state.tabs = []
+		this.#state.views = []
+		this.#state.file = ''
+		this.#state.activeTab = ''
+		console.debug('OCA.Files.Sidebar initialized')
+	}
+
+	/**
+	 * Get the sidebar state
+	 *
+	 * @readonly
+	 * @memberof Sidebar
+	 * @returns {Object} the data state
+	 */
+	get state() {
+		return this.#state
+	}
+
+	/**
+	 * @memberof Sidebar
+	 * Register a new tab view
+	 *
+	 * @param {Object} tab a new unregistered tab
+	 * @memberof Sidebar
+	 * @returns {Boolean}
+	 */
+	registerTab(tab) {
+		const hasDuplicate = this.#state.tabs.findIndex(check => check.name === tab.name) > -1
+		if (!hasDuplicate) {
+			this.#state.tabs.push(tab)
+			return true
+		}
+		console.error(`An tab with the same name ${tab.name} already exists`, tab)
+		return false
+	}
+
+	registerSecondaryView(view) {
+		const hasDuplicate = this.#state.views.findIndex(check => check.cid === view.cid) > -1
+		if (!hasDuplicate) {
+			this.#state.views.push(view)
+			return true
+		}
+		console.error(`A similar view already exists`, view)
+		return false
+	}
+
+	/**
+	 * Set the current sidebar file data
+	 *
+	 * @param {string} path the file path to load
+	 * @memberof Sidebar
+	 */
+	set file(path) {
+		this.#state.file = path
+	}
+
+	/**
+	 * Set the current sidebar file data
+	 *
+	 * @returns {String} the current opened file
+	 * @memberof Sidebar
+	 */
+	get file() {
+		return this.#state.file
+	}
+
+	/**
+	 * Set the current sidebar tab
+	 *
+	 * @param {string} id the tab unique id
+	 * @memberof Sidebar
+	 */
+	set activeTab(id) {
+		this.#state.activeTab = id
+	}
+
+}

+ 59 - 0
apps/files/src/sidebar.js

@@ -0,0 +1,59 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import Vue from 'vue'
+import SidebarView from './views/Sidebar.vue'
+import Sidebar from './services/Sidebar'
+import Tab from './models/Tab'
+import VueClipboard from 'vue-clipboard2'
+
+Vue.use(VueClipboard)
+
+Vue.prototype.t = t
+
+window.addEventListener('DOMContentLoaded', () => {
+	// Init Sidebar Service
+	if (window.OCA && window.OCA.Files) {
+		Object.assign(window.OCA.Files, { Sidebar: new Sidebar() })
+		Object.assign(window.OCA.Files.Sidebar, { Tab })
+	}
+
+	// Make sure we have a proper layout
+	if (document.getElementById('content')) {
+
+		// Make sure we have a mountpoint
+		if (!document.getElementById('app-sidebar')) {
+			var contentElement = document.getElementById('content')
+			var sidebarElement = document.createElement('div')
+			sidebarElement.id = 'app-sidebar'
+			contentElement.appendChild(sidebarElement)
+		}
+	}
+
+	// Init vue app
+	const AppSidebar = new Vue({
+		// eslint-disable-next-line vue/match-component-file-name
+		name: 'SidebarRoot',
+		render: h => h(SidebarView)
+	})
+	AppSidebar.$mount('#app-sidebar')
+})

+ 345 - 0
apps/files/src/views/Sidebar.vue

@@ -0,0 +1,345 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<AppSidebar
+		v-if="file"
+		ref="sidebar"
+		v-bind="appSidebar"
+		@close="onClose"
+		@update:starred="toggleStarred"
+		@[defaultActionListener].stop.prevent="onDefaultAction">
+		<!-- TODO: create a standard to allow multiple elements here? -->
+		<template v-if="fileInfo" #primary-actions>
+			<LegacyView v-for="view in views"
+				:key="view.cid"
+				:component="view"
+				:file-info="fileInfo" />
+		</template>
+
+		<!-- Error display -->
+		<div v-if="error" class="emptycontent">
+			<div class="icon-error" />
+			<h2>{{ error }}</h2>
+		</div>
+
+		<!-- If fileInfo fetch is complete, display tabs -->
+		<template v-for="tab in tabs" v-else-if="fileInfo">
+			<component
+				:is="tabComponent(tab).is"
+				v-if="canDisplay(tab)"
+				:key="tab.id"
+				:component="tabComponent(tab).component"
+				:name="tab.name"
+				:file-info="fileInfo" />
+		</template>
+	</AppSidebar>
+</template>
+<script>
+import $ from 'jquery'
+import axios from '@nextcloud/axios'
+import AppSidebar from 'nextcloud-vue/dist/Components/AppSidebar'
+import FileInfo from '../services/FileInfo'
+import LegacyTab from '../components/LegacyTab'
+import LegacyView from '../components/LegacyView'
+
+export default {
+	name: 'Sidebar',
+
+	components: {
+		AppSidebar,
+		LegacyView
+	},
+
+	data() {
+		return {
+			// reactive state
+			Sidebar: OCA.Files.Sidebar.state,
+			error: null,
+			fileInfo: null,
+			starLoading: false
+		}
+	},
+
+	computed: {
+		/**
+		 * Current filename
+		 * This is bound to the Sidebar service and
+		 * is used to load a new file
+		 * @returns {string}
+		 */
+		file() {
+			return this.Sidebar.file
+		},
+
+		/**
+		 * List of all the registered tabs
+		 * @returns {Array}
+		 */
+		tabs() {
+			return this.Sidebar.tabs
+		},
+
+		/**
+		 * List of all the registered views
+		 * @returns {Array}
+		 */
+		views() {
+			return this.Sidebar.views
+		},
+
+		/**
+		 * Current user dav root path
+		 * @returns {string}
+		 */
+		davPath() {
+			const user = OC.getCurrentUser().uid
+			return OC.linkToRemote(`dav/files/${user}${encodeURIComponent(this.file)}`)
+		},
+
+		/**
+		 * Current active tab handler
+		 * @param {string} id the tab id to set as active
+		 * @returns {string} the current active tab
+		 */
+		activeTab: {
+			get: function() {
+				return this.Sidebar.activeTab
+			},
+			set: function(id) {
+				OCA.Files.Sidebar.activeTab = id
+			}
+		},
+
+		/**
+		 * Sidebar subtitle
+		 * @returns {string}
+		 */
+		subtitle() {
+			return `${this.size}, ${this.time}`
+		},
+
+		/**
+		 * File last modified formatted string
+		 * @returns {string}
+		 */
+		time() {
+			return OC.Util.relativeModifiedDate(this.fileInfo.mtime)
+		},
+
+		/**
+		 * File size formatted string
+		 * @returns {string}
+		 */
+		size() {
+			return OC.Util.humanFileSize(this.fileInfo.size)
+		},
+
+		/**
+		 * File background/figure to illustrate the sidebar header
+		 * @returns {string}
+		 */
+		background() {
+			return this.getPreviewIfAny(this.fileInfo)
+		},
+
+		/**
+		 * App sidebar v-binding object
+		 *
+		 * @returns {Object}
+		 */
+		appSidebar() {
+			if (this.fileInfo) {
+				return {
+					background: this.background,
+					active: this.activeTab,
+					class: { 'has-preview': this.fileInfo.hasPreview },
+					compact: !this.fileInfo.hasPreview,
+					'star-loading': this.starLoading,
+					starred: this.fileInfo.isFavourited,
+					subtitle: this.subtitle,
+					title: this.fileInfo.name
+				}
+			} else if (this.error) {
+				return {
+					key: 'error', // force key to re-render
+					subtitle: '',
+					title: ''
+				}
+			} else {
+				return {
+					class: 'icon-loading',
+					subtitle: '',
+					title: ''
+				}
+			}
+		},
+
+		/**
+		 * Default action object for the current file
+		 *
+		 * @returns {Object}
+		 */
+		defaultAction() {
+			return this.fileInfo
+				&& OCA.Files && OCA.Files.App && OCA.Files.App.fileList
+				&& OCA.Files.App.fileList
+					.fileActions.getDefaultFileAction(this.fileInfo.mimetype, this.fileInfo.type, OC.PERMISSION_READ)
+
+		},
+
+		/**
+		 * Dynamic header click listener to ensure
+		 * nothing is listening for a click if there
+		 * is no default action
+		 *
+		 * @returns {string|null}
+		 */
+		defaultActionListener() {
+			return this.defaultAction ? 'figure-click' : null
+		}
+	},
+
+	watch: {
+		// update the sidebar data
+		async file(curr, prev) {
+			this.resetData()
+			if (curr && curr.trim() !== '') {
+				try {
+					this.fileInfo = await FileInfo(this.davPath)
+					// adding this as fallback because other apps expect it
+					this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/')
+
+					// DEPRECATED legacy views
+					// TODO: remove
+					this.views.forEach(view => {
+						view.setFileInfo(this.fileInfo)
+					})
+
+					this.$nextTick(() => {
+						if (this.$refs.sidebar) {
+							this.$refs.sidebar.updateTabs()
+						}
+					})
+				} catch (error) {
+					this.error = t('files', 'Error while loading the file data')
+					console.error('Error while loading the file data')
+				}
+			}
+		}
+	},
+
+	methods: {
+		/**
+		 * Can this tab be displayed ?
+		 *
+		 * @param {Object} tab a registered tab
+		 * @returns {boolean}
+		 */
+		canDisplay(tab) {
+			if (tab.isLegacyTab) {
+				return this.fileInfo && tab.component.canDisplay && tab.component.canDisplay(this.fileInfo)
+			}
+			// if the tab does not have an enabled method, we assume it's always available
+			return tab.enabled ? tab.enabled(this.fileInfo) : true
+		},
+		onClose() {
+			this.resetData()
+			OCA.Files.Sidebar.file = ''
+		},
+		resetData() {
+			this.error = null
+			this.fileInfo = null
+			this.$nextTick(() => {
+				if (this.$refs.sidebar) {
+					this.$refs.sidebar.updateTabs()
+				}
+			})
+		},
+		getPreviewIfAny(fileInfo) {
+			if (fileInfo.hasPreview) {
+				return OC.generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`)
+			}
+			return OCA.Files.App.fileList._getIconUrl(fileInfo)
+		},
+
+		tabComponent(tab) {
+			if (tab.isLegacyTab) {
+				return {
+					is: LegacyTab,
+					component: tab.component
+				}
+			}
+			return {
+				is: tab.component
+			}
+		},
+
+		/**
+		 * Toggle favourite state
+		 * TODO: better implementation
+		 *
+		 * @param {Boolean} state favourited or not
+		 */
+		async toggleStarred(state) {
+			try {
+				this.starLoading = true
+				await axios({
+					method: 'PROPPATCH',
+					url: this.davPath,
+					data: `<?xml version="1.0"?>
+						<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
+						${state ? '<d:set>' : '<d:remove>'}
+							<d:prop>
+								<oc:favorite>1</oc:favorite>
+							</d:prop>
+						${state ? '</d:set>' : '</d:remove>'}
+						</d:propertyupdate>`
+				})
+			} catch (error) {
+				OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file'))
+				console.error('Unable to change favourite state', error)
+			}
+			this.starLoading = false
+		},
+
+		onDefaultAction() {
+			if (this.defaultAction) {
+				// generate fake context
+				this.defaultAction.action(this.fileInfo.name, {
+					fileInfo: this.fileInfo,
+					dir: this.fileInfo.dir,
+					fileList: OCA.Files.App.fileList,
+					$file: $('body')
+				})
+			}
+		}
+	}
+}
+</script>
+<style lang="scss" scoped>
+#app-sidebar {
+	&.has-preview::v-deep .app-sidebar-header__figure {
+		background-size: cover;
+	}
+}
+</style>

+ 13 - 0
apps/files/webpack.js

@@ -0,0 +1,13 @@
+const path = require('path');
+
+module.exports = {
+	entry: {
+		'sidebar': path.join(__dirname, 'src', 'sidebar.js'),
+	},
+	output: {
+		path: path.resolve(__dirname, './js/dist/'),
+		publicPath: '/js/',
+		filename: '[name].js',
+		chunkFilename: 'files.[id].js'
+	}
+}

+ 1 - 0
apps/files_sharing/appinfo/app.php

@@ -43,6 +43,7 @@ $eventDispatcher->addListener(
 	'OCA\Files::loadAdditionalScripts',
 	function() {
 		\OCP\Util::addScript('files_sharing', 'dist/additionalScripts');
+		\OCP\Util::addStyle('files_sharing', 'icons');
 	}
 );
 \OC::$server->getEventDispatcher()->addListener('\OCP\Collaboration\Resources::loadAdditionalScripts', function () {

+ 32 - 0
apps/files_sharing/css/icons.scss

@@ -0,0 +1,32 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// This is the icons used in the sharing ui (multiselect)
+.icon-room {
+	@include icon-color('app', 'spreed', $color-black);
+}
+.icon-circle {
+	@include icon-color('circles', 'circles', $color-black, 3, false);
+}
+.icon-guests {
+	@include icon-color('app', 'guests', $color-black);
+}

+ 1 - 0
apps/files_sharing/list.php

@@ -33,6 +33,7 @@ $tmpl = new OCP\Template('files_sharing', 'list', '');
 $tmpl->assign('showgridview', $showgridview && !$isIE);
 
 OCP\Util::addScript('files_sharing', 'dist/files_sharing');
+OCP\Util::addScript('files_sharing', 'dist/files_sharing_tab');
 \OC::$server->getEventDispatcher()->dispatch('\OCP\Collaboration\Resources::loadAdditionalScripts');
 
 $tmpl->printPage();

+ 249 - 0
apps/files_sharing/src/components/SharingEntry.vue

@@ -0,0 +1,249 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<li class="sharing-entry">
+		<Avatar class="sharing-entry__avatar"
+			:user="share.shareWith"
+			:display-name="share.shareWithDisplayName"
+			:url="share.shareWithAvatar" />
+		<div v-tooltip.auto="tooltip" class="sharing-entry__desc">
+			<h5>{{ title }}</h5>
+		</div>
+		<Actions menu-align="right" class="sharing-entry__actions">
+			<!-- edit permission -->
+			<ActionCheckbox
+				ref="canEdit"
+				:checked.sync="canEdit"
+				:value="permissionsEdit"
+				:disabled="saving">
+				{{ t('files_sharing', 'Allow editing') }}
+			</ActionCheckbox>
+
+			<!-- reshare permission -->
+			<ActionCheckbox
+				ref="canReshare"
+				:checked.sync="canReshare"
+				:value="permissionsShare"
+				:disabled="saving">
+				{{ t('files_sharing', 'Can reshare') }}
+			</ActionCheckbox>
+
+			<!-- expiration date -->
+			<ActionCheckbox :checked.sync="hasExpirationDate"
+				:disabled="config.isDefaultExpireDateEnforced || saving"
+				@uncheck="onExpirationDisable">
+				{{ config.isDefaultExpireDateEnforced
+					? t('files_sharing', 'Expiration date enforced')
+					: t('files_sharing', 'Set expiration date') }}
+			</ActionCheckbox>
+			<ActionInput v-if="hasExpirationDate"
+				ref="expireDate"
+				v-tooltip.auto="{
+					content: errors.expireDate,
+					show: errors.expireDate,
+					trigger: 'manual'
+				}"
+				:class="{ error: errors.expireDate}"
+				:disabled="saving"
+				:first-day-of-week="firstDay"
+				:lang="lang"
+				:value="share.expireDate"
+				icon="icon-calendar-dark"
+				type="date"
+				:not-before="dateTomorrow"
+				:not-after="dateMaxEnforced"
+				@update:value="onExpirationChange">
+				{{ t('files_sharing', 'Enter a date') }}
+			</ActionInput>
+
+			<!-- note -->
+			<template v-if="canHaveNote">
+				<ActionCheckbox
+					:checked.sync="hasNote"
+					:disabled="saving"
+					@uncheck="queueUpdate('note')">
+					{{ t('files_sharing', 'Note to recipient') }}
+				</ActionCheckbox>
+				<ActionTextEditable v-if="hasNote"
+					ref="note"
+					v-tooltip.auto="{
+						content: errors.note,
+						show: errors.note,
+						trigger: 'manual'
+					}"
+					:class="{ error: errors.note}"
+					:disabled="saving"
+					:value.sync="share.note"
+					icon="icon-edit"
+					@update:value="debounceQueueUpdate('note')" />
+			</template>
+
+			<ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete">
+				{{ t('files_sharing', 'Unshare') }}
+			</ActionButton>
+		</Actions>
+	</li>
+</template>
+
+<script>
+import Avatar from 'nextcloud-vue/dist/Components/Avatar'
+import Actions from 'nextcloud-vue/dist/Components/Actions'
+import ActionButton from 'nextcloud-vue/dist/Components/ActionButton'
+import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox'
+import ActionInput from 'nextcloud-vue/dist/Components/ActionInput'
+import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable'
+import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip'
+
+// eslint-disable-next-line no-unused-vars
+import Share from '../models/Share'
+import SharesMixin from '../mixins/SharesMixin'
+
+export default {
+	name: 'SharingEntry',
+
+	components: {
+		Actions,
+		ActionButton,
+		ActionCheckbox,
+		ActionInput,
+		ActionTextEditable,
+		Avatar
+	},
+
+	directives: {
+		Tooltip
+	},
+
+	mixins: [SharesMixin],
+
+	data() {
+		return {
+			permissionsEdit: OC.PERMISSION_UPDATE,
+			permissionsRead: OC.PERMISSION_READ,
+			permissionsShare: OC.PERMISSION_SHARE
+		}
+	},
+
+	computed: {
+		title() {
+			let title = this.share.shareWithDisplayName
+			if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
+				title += ` (${t('files_sharing', 'group')})`
+			} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
+				title += ` (${t('files_sharing', 'conversation')})`
+			} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) {
+				title += ` (${t('files_sharing', 'remote')})`
+			} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) {
+				title += ` (${t('files_sharing', 'remote group')})`
+			} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) {
+				title += ` (${t('files_sharing', 'guest')})`
+			}
+			return title
+		},
+
+		tooltip() {
+			if (this.share.owner !== this.share.uidFileOwner) {
+				const data = {
+					// todo: strong or italic?
+					// but the t function escape any html from the data :/
+					user: this.share.shareWithDisplayName,
+					owner: this.share.owner
+				}
+
+				if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
+					return t('files_sharing', 'Shared with the group {user} by {owner}', data)
+				} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
+					return t('files_sharing', 'Shared with the conversation {user} by {owner}', data)
+				}
+
+				return t('files_sharing', 'Shared with {user} by {owner}', data)
+			}
+			return null
+		},
+
+		canHaveNote() {
+			return this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE
+				&& this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP
+		},
+
+		/**
+		 * Can the sharee edit the shared file ?
+		 */
+		canEdit: {
+			get: function() {
+				return this.share.hasUpdatePermission
+			},
+			set: function(checked) {
+				this.updatePermissions(checked, this.canReshare)
+			}
+		},
+
+		/**
+		 * Can the sharee reshare the file ?
+		 */
+		canReshare: {
+			get: function() {
+				return this.share.hasSharePermission
+			},
+			set: function(checked) {
+				this.updatePermissions(this.canEdit, checked)
+			}
+		}
+
+	},
+
+	methods: {
+		updatePermissions(isEditChecked, isReshareChecked) {
+			// calc permissions if checked
+			const permissions = this.permissionsRead
+				| (isEditChecked ? this.permissionsEdit : 0)
+				| (isReshareChecked ? this.permissionsShare : 0)
+
+			this.share.permissions = permissions
+			this.queueUpdate('permissions')
+		}
+	}
+
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+	display: flex;
+	align-items: center;
+	height: 44px;
+	&__desc {
+		display: flex;
+		flex-direction: column;
+		justify-content: space-between;
+		padding: 8px;
+		line-height: 1.2em;
+		p {
+			color: var(--color-text-maxcontrast);
+		}
+	}
+	&__actions {
+		margin-left: auto;
+	}
+}
+</style>

+ 117 - 0
apps/files_sharing/src/components/SharingEntryInternal.vue

@@ -0,0 +1,117 @@
+
+<template>
+	<SharingEntrySimple
+		class="sharing-entry__internal"
+		:title="t('files_sharing', 'Internal link')"
+		:subtitle="internalLinkSubtitle">
+		<template #avatar>
+			<div class="avatar-external icon-external-white" />
+		</template>
+
+		<ActionLink ref="copyButton"
+			:href="internalLink"
+			target="_blank"
+			:icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'"
+			@click.prevent="copyLink">
+			{{ clipboardTooltip }}
+		</ActionLink>
+	</SharingEntrySimple>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import ActionLink from 'nextcloud-vue/dist/Components/ActionLink'
+import SharingEntrySimple from './SharingEntrySimple'
+
+export default {
+	name: 'SharingEntryInternal',
+
+	components: {
+		ActionLink,
+		SharingEntrySimple
+	},
+
+	props: {
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			copied: false,
+			copySuccess: false
+		}
+	},
+
+	computed: {
+		/**
+		 * Get the internal link to this file id
+		 * @returns {string}
+		 */
+		internalLink() {
+			return window.location.protocol + '//' + window.location.host + generateUrl('/f/') + this.fileInfo.id
+		},
+
+		/**
+		 * Clipboard v-tooltip message
+		 * @returns {string}
+		 */
+		clipboardTooltip() {
+			if (this.copied) {
+				return this.copySuccess
+					? t('files_sharing', 'Link copied')
+					: t('files_sharing', 'Cannot copy, please copy the link manually')
+			}
+			return t('files_sharing', 'Copy to clipboard')
+		},
+
+		internalLinkSubtitle() {
+			if (this.fileInfo.type === 'dir') {
+				return t('files_sharing', 'Only works for users with access to this folder')
+			}
+			return t('files_sharing', 'Only works for users with access to this file')
+		}
+	},
+
+	methods: {
+		async copyLink() {
+			try {
+				await this.$copyText(this.internalLink)
+				// focus and show the tooltip
+				this.$refs.copyButton.$el.focus()
+				this.copySuccess = true
+				this.copied = true
+			} catch (error) {
+				this.copySuccess = false
+				this.copied = true
+				console.error(error)
+			} finally {
+				setTimeout(() => {
+					this.copySuccess = false
+					this.copied = false
+				}, 4000)
+			}
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry__internal {
+	.avatar-external {
+		width: 32px;
+		height: 32px;
+		line-height: 32px;
+		font-size: 18px;
+		background-color: var(--color-text-maxcontrast);
+		border-radius: 50%;
+		flex-shrink: 0;
+	}
+	.icon-checkmark-color {
+		opacity: 1;
+	}
+}
+</style>

+ 769 - 0
apps/files_sharing/src/components/SharingEntryLink.vue

@@ -0,0 +1,769 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<li :class="{'sharing-entry--share': share}" class="sharing-entry sharing-entry__link">
+		<Avatar :is-no-user="true"
+			:class="isEmailShareType ? 'icon-mail-white' : 'icon-public-white'"
+			class="sharing-entry__avatar" />
+		<div class="sharing-entry__desc">
+			<h5>{{ title }}</h5>
+		</div>
+
+		<!-- clipboard -->
+		<Actions v-if="share && !isEmailShareType && share.token"
+			ref="copyButton"
+			class="sharing-entry__copy">
+			<ActionLink :href="shareLink"
+				target="_blank"
+				:icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'"
+				@click.stop.prevent="copyLink">
+				{{ clipboardTooltip }}
+			</ActionLink>
+		</Actions>
+
+		<!-- pending actions -->
+		<Actions v-if="!loading && (pendingPassword || pendingExpirationDate)"
+			class="sharing-entry__actions"
+			menu-align="right"
+			:open.sync="open"
+			@close="onNewLinkShare">
+			<!-- pending data menu -->
+			<ActionText v-if="errors.pending"
+				icon="icon-error"
+				:class="{ error: errors.pending}">
+				{{ errors.pending }}
+			</ActionText>
+			<ActionText v-else icon="icon-info">
+				{{ t('files_sharing', 'Please enter the following required information before creating the share') }}
+			</ActionText>
+
+			<!-- password -->
+			<ActionText v-if="pendingPassword" icon="icon-password">
+				{{ t('files_sharing', 'Password protection (enforced)') }}
+			</ActionText>
+			<ActionCheckbox v-else-if="config.enableLinkPasswordByDefault"
+				:checked.sync="isPasswordProtected"
+				:disabled="config.enforcePasswordForPublicLink || saving"
+				class="share-link-password-checkbox"
+				@uncheck="onPasswordDisable">
+				{{ t('files_sharing', 'Password protection') }}
+			</ActionCheckbox>
+			<ActionInput v-if="pendingPassword || share.password"
+				v-tooltip.auto="{
+					content: errors.password,
+					show: errors.password,
+					trigger: 'manual',
+					defaultContainer: '#app-sidebar'
+				}"
+				class="share-link-password"
+				:value.sync="share.password"
+				:disabled="saving"
+				:required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink"
+				:minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength"
+				icon=""
+				autocomplete="new-password"
+				@submit="onNewLinkShare">
+				{{ t('files_sharing', 'Enter a password') }}
+			</ActionInput>
+
+			<!-- expiration date -->
+			<ActionText v-if="pendingExpirationDate" icon="icon-calendar-dark">
+				{{ t('files_sharing', 'Expiration date (enforced)') }}
+			</ActionText>
+			<ActionInput v-if="pendingExpirationDate"
+				v-model="share.expireDate"
+				v-tooltip.auto="{
+					content: errors.expireDate,
+					show: errors.expireDate,
+					trigger: 'manual',
+					defaultContainer: '#app-sidebar'
+				}"
+				class="share-link-expire-date"
+				:disabled="saving"
+				:first-day-of-week="firstDay"
+				:lang="lang"
+				icon=""
+				type="date"
+				:not-before="dateTomorrow"
+				:not-after="dateMaxEnforced">
+				<!-- let's not submit when picked, the user
+					might want to still edit or copy the password -->
+				{{ t('files_sharing', 'Enter a date') }}
+			</ActionInput>
+
+			<ActionButton icon="icon-close" @click.prevent.stop="onCancel">
+				{{ t('files_sharing', 'Cancel') }}
+			</ActionButton>
+		</Actions>
+
+		<!-- actions -->
+		<Actions v-else-if="!loading"
+			class="sharing-entry__actions"
+			menu-align="right"
+			:open.sync="open"
+			@close="onPasswordSubmit">
+			<template v-if="share">
+				<template v-if="isShareOwner">
+					<!-- folder -->
+					<template v-if="isFolder && fileHasCreatePermission && config.isPublicUploadEnabled">
+						<ActionRadio :checked="share.permissions === publicUploadRValue"
+							:value="publicUploadRValue"
+							:name="randomId"
+							:disabled="saving"
+							@change="togglePermissions">
+							{{ t('files_sharing', 'Read only') }}
+						</ActionRadio>
+						<ActionRadio :checked="share.permissions === publicUploadRWValue"
+							:value="publicUploadRWValue"
+							:disabled="saving"
+							:name="randomId"
+							@change="togglePermissions">
+							{{ t('files_sharing', 'Allow upload and editing') }}
+						</ActionRadio>
+						<ActionRadio :checked="share.permissions === publicUploadWValue"
+							:value="publicUploadWValue"
+							:disabled="saving"
+							:name="randomId"
+							class="sharing-entry__action--public-upload"
+							@change="togglePermissions">
+							{{ t('files_sharing', 'File drop (upload only)') }}
+						</ActionRadio>
+					</template>
+
+					<!-- file -->
+					<ActionCheckbox v-else
+						:checked.sync="canUpdate"
+						:disabled="saving"
+						@change="queueUpdate('permissions')">
+						{{ t('files_sharing', 'Allow editing') }}
+					</ActionCheckbox>
+
+					<ActionCheckbox
+						:checked.sync="share.hideDownload"
+						:disabled="saving"
+						@change="queueUpdate('hideDownload')">
+						{{ t('files_sharing', 'Hide download') }}
+					</ActionCheckbox>
+
+					<!-- password -->
+					<ActionCheckbox :checked.sync="isPasswordProtected"
+						:disabled="config.enforcePasswordForPublicLink || saving"
+						class="share-link-password-checkbox"
+						@uncheck="onPasswordDisable">
+						{{ config.enforcePasswordForPublicLink
+							? t('files_sharing', 'Password protection (enforced)')
+							: t('files_sharing', 'Password protect') }}
+					</ActionCheckbox>
+					<ActionInput v-if="isPasswordProtected"
+						ref="password"
+						v-tooltip.auto="{
+							content: errors.password,
+							show: errors.password,
+							trigger: 'manual',
+							defaultContainer: '#app-sidebar'
+						}"
+						class="share-link-password"
+						:class="{ error: errors.password}"
+						:disabled="saving"
+						:required="config.enforcePasswordForPublicLink"
+						:value="hasUnsavedPassword ? share.newPassword : '***************'"
+						icon="icon-password"
+						autocomplete="new-password"
+						:type="hasUnsavedPassword ? 'text': 'password'"
+						@update:value="onPasswordChange"
+						@submit="onPasswordSubmit">
+						{{ t('files_sharing', 'Enter a password') }}
+					</ActionInput>
+
+					<!-- expiration date -->
+					<ActionCheckbox :checked.sync="hasExpirationDate"
+						:disabled="config.isDefaultExpireDateEnforced || saving"
+						class="share-link-expire-date-checkbox"
+						@uncheck="onExpirationDisable">
+						{{ config.isDefaultExpireDateEnforced
+							? t('files_sharing', 'Expiration date (enforced)')
+							: t('files_sharing', 'Set expiration date') }}
+					</ActionCheckbox>
+					<ActionInput v-if="hasExpirationDate"
+						ref="expireDate"
+						v-tooltip.auto="{
+							content: errors.expireDate,
+							show: errors.expireDate,
+							trigger: 'manual',
+							defaultContainer: '#app-sidebar'
+						}"
+						class="share-link-expire-date"
+						:class="{ error: errors.expireDate}"
+						:disabled="saving"
+						:first-day-of-week="firstDay"
+						:lang="lang"
+						:value="share.expireDate"
+						icon="icon-calendar-dark"
+						type="date"
+						:not-before="dateTomorrow"
+						:not-after="dateMaxEnforced"
+						@update:value="onExpirationChange">
+						{{ t('files_sharing', 'Enter a date') }}
+					</ActionInput>
+
+					<!-- note -->
+					<ActionCheckbox :checked.sync="hasNote"
+						:disabled="saving"
+						@uncheck="queueUpdate('note')">
+						{{ t('files_sharing', 'Note to recipient') }}
+					</ActionCheckbox>
+					<ActionTextEditable v-if="hasNote"
+						ref="note"
+						v-tooltip.auto="{
+							content: errors.note,
+							show: errors.note,
+							trigger: 'manual',
+							defaultContainer: '#app-sidebar'
+						}"
+						:class="{ error: errors.note}"
+						:disabled="saving"
+						:value.sync="share.note"
+						icon="icon-edit"
+						@update:value="debounceQueueUpdate('note')" />
+				</template>
+
+				<components :is="action" v-for="(action, index) in externalActions" :key="index" />
+
+				<ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete">
+					{{ t('files_sharing', 'Delete share') }}
+				</ActionButton>
+				<ActionButton v-if="!isEmailShareType && canReshare"
+					class="new-share-link"
+					icon="icon-add"
+					@click.prevent.stop="onNewLinkShare">
+					{{ t('files_sharing', 'Add another link') }}
+				</ActionButton>
+			</template>
+
+			<!-- Create new share -->
+			<ActionButton v-else-if="canReshare"
+				class="new-share-link"
+				icon="icon-add"
+				@click.prevent.stop="onNewLinkShare">
+				{{ t('files_sharing', 'Create a new share link') }}
+			</ActionButton>
+		</Actions>
+
+		<!-- loading indicator to replace the menu -->
+		<div v-else class="icon-loading-small sharing-entry__loading" />
+	</li>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
+
+import ActionButton from 'nextcloud-vue/dist/Components/ActionButton'
+import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox'
+import ActionRadio from 'nextcloud-vue/dist/Components/ActionRadio'
+import ActionInput from 'nextcloud-vue/dist/Components/ActionInput'
+import ActionText from 'nextcloud-vue/dist/Components/ActionText'
+import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable'
+import ActionLink from 'nextcloud-vue/dist/Components/ActionLink'
+import Actions from 'nextcloud-vue/dist/Components/Actions'
+import Avatar from 'nextcloud-vue/dist/Components/Avatar'
+import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip'
+
+import Share from '../models/Share'
+import SharesMixin from '../mixins/SharesMixin'
+
+const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
+
+export default {
+	name: 'SharingEntryLink',
+
+	components: {
+		Actions,
+		ActionButton,
+		ActionCheckbox,
+		ActionRadio,
+		ActionInput,
+		ActionLink,
+		ActionText,
+		ActionTextEditable,
+		Avatar
+	},
+
+	directives: {
+		Tooltip
+	},
+
+	mixins: [SharesMixin],
+
+	props: {
+		canReshare: {
+			type: Boolean,
+			default: true
+		}
+	},
+
+	data() {
+		return {
+			copySuccess: true,
+			copied: false,
+
+			publicUploadRWValue: OC.PERMISSION_UPDATE | OC.PERMISSION_CREATE | OC.PERMISSION_READ | OC.PERMISSION_DELETE,
+			publicUploadRValue: OC.PERMISSION_READ,
+			publicUploadWValue: OC.PERMISSION_CREATE,
+
+			ExternalLinkActions: OCA.Sharing.ExternalLinkActions.state
+		}
+	},
+
+	computed: {
+		/**
+		 * Generate a unique random id for this SharingEntryLink only
+		 * This allows ActionRadios to have the same name prop
+		 * but not to impact others SharingEntryLink
+		 * @returns {string}
+		 */
+		randomId() {
+			return Math.random().toString(27).substr(2)
+		},
+
+		/**
+		 * Link share label
+		 * TODO: allow editing
+		 * @returns {string}
+		 */
+		title() {
+			// if we have a valid existing share (not pending)
+			if (this.share && this.share.id) {
+				if (!this.isShareOwner && this.share.ownerDisplayName) {
+					return t('files_sharing', 'Shared via link by {initiator}', {
+						initiator: this.share.ownerDisplayName
+					})
+				}
+				if (this.share.label && this.share.label.trim() !== '') {
+					return this.share.label
+				}
+				if (this.isEmailShareType) {
+					return this.share.shareWith
+				}
+			}
+			return t('files_sharing', 'Share link')
+		},
+
+		/**
+		 * Is the current share password protected ?
+		 * @returns {boolean}
+		 */
+		isPasswordProtected: {
+			get: function() {
+				return this.config.enforcePasswordForPublicLink
+					|| !!this.share.password
+			},
+			set: async function(enabled) {
+				// TODO: directly save after generation to make sure the share is always protected
+				this.share.password = enabled ? await this.generatePassword() : ''
+				this.share.newPassword = this.share.password
+			}
+		},
+
+		/**
+		 * Is the current share an email share ?
+		 * @returns {boolean}
+		 */
+		isEmailShareType() {
+			return this.share
+				? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
+				: false
+		},
+
+		/**
+		 * Pending data.
+		 * If the share still doesn't have an id, it is not synced
+		 * Therefore this is still not valid and requires user input
+		 * @returns {boolean}
+		 */
+		pendingPassword() {
+			return this.config.enforcePasswordForPublicLink && this.share && !this.share.id
+		},
+		pendingExpirationDate() {
+			return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id
+		},
+
+		/**
+		 * Can the recipient edit the file ?
+		 * @returns {boolean}
+		 */
+		canUpdate: {
+			get: function() {
+				return this.share.hasUpdatePermission
+			},
+			set: function(enabled) {
+				this.share.permissions = enabled
+					? OC.PERMISSION_READ | OC.PERMISSION_UPDATE
+					: OC.PERMISSION_READ
+			}
+		},
+
+		// if newPassword exists, but is empty, it means
+		// the user deleted the original password
+		hasUnsavedPassword() {
+			return this.share.newPassword !== undefined
+		},
+
+		/**
+		 * Is the current share a folder ?
+		 * TODO: move to a proper FileInfo model?
+		 * @returns {boolean}
+		 */
+		isFolder() {
+			return this.fileInfo.type === 'dir'
+		},
+
+		/**
+		 * Does the current file/folder have create permissions
+		 * TODO: move to a proper FileInfo model?
+		 * @returns {boolean}
+		 */
+		fileHasCreatePermission() {
+			return !!(this.fileInfo.permissions & OC.PERMISSION_CREATE)
+		},
+
+		/**
+		 * Return the public share link
+		 * @returns {string}
+		 */
+		shareLink() {
+			return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token
+		},
+
+		/**
+		 * Clipboard v-tooltip message
+		 * @returns {string}
+		 */
+		clipboardTooltip() {
+			if (this.copied) {
+				return this.copySuccess
+					? t('files_sharing', 'Link copied')
+					: t('files_sharing', 'Cannot copy, please copy the link manually')
+			}
+			return t('files_sharing', 'Copy to clipboard')
+		},
+
+		/**
+		 * External aditionnal actions for the menu
+		 * @returns {Array}
+		 */
+		externalActions() {
+			return this.ExternalLinkActions.actions
+		},
+
+		isPasswordPolicyEnabled() {
+			return typeof this.config.passwordPolicy === 'object'
+		}
+	},
+
+	methods: {
+		/**
+		 * Create a new share link and append it to the list
+		 */
+		async onNewLinkShare() {
+			const shareDefaults = {
+				share_type: OC.Share.SHARE_TYPE_LINK
+			}
+			if (this.config.isDefaultExpireDateEnforced) {
+				// default is empty string if not set
+				// expiration is the share object key, not expireDate
+				shareDefaults.expiration = this.config.defaultExpirationDateString
+			}
+			if (this.config.enableLinkPasswordByDefault) {
+				shareDefaults.password = await this.generatePassword()
+			}
+
+			// do not push yet if we need a password or an expiration date
+			if (this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced) {
+				this.loading = true
+				// if a share already exists, pushing it
+				if (this.share && !this.share.id) {
+					if (this.checkShare(this.share)) {
+						await this.pushNewLinkShare(this.share, true)
+						return true
+					} else {
+						this.open = true
+						OC.Notification.showTemporary(t('files_sharing', 'Error, please enter proper password and/or expiration date'))
+						return false
+					}
+				}
+
+				// ELSE, show the pending popovermenu
+				// if password enforced, pre-fill with random one
+				if (this.config.enforcePasswordForPublicLink) {
+					shareDefaults.password = await this.generatePassword()
+				}
+
+				// create share & close menu
+				const share = new Share(shareDefaults)
+				const component = await new Promise(resolve => {
+					this.$emit('add:share', share, resolve)
+				})
+
+				// open the menu on the
+				// freshly created share component
+				this.open = false
+				this.loading = false
+				component.open = true
+
+			// Nothing enforced, creating share directly
+			} else {
+				const share = new Share(shareDefaults)
+				await this.pushNewLinkShare(share)
+			}
+		},
+
+		/**
+		 * Push a new link share to the server
+		 * And update or append to the list
+		 * accordingly
+		 *
+		 * @param {Share} share the new share
+		 * @param {boolean} [update=false] do we update the current share ?
+		 */
+		async pushNewLinkShare(share, update) {
+			try {
+				this.loading = true
+				this.errors = {}
+
+				const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+				const newShare = await this.createShare({
+					path,
+					shareType: OC.Share.SHARE_TYPE_LINK,
+					password: share.password,
+					expireDate: share.expireDate
+					// we do not allow setting the publicUpload
+					// before the share creation.
+					// Todo: We also need to fix the createShare method in
+					// lib/Controller/ShareAPIController.php to allow file drop
+					// (currently not supported on create, only update)
+				})
+
+				this.open = false
+
+				console.debug('Link share created', newShare)
+
+				// if share already exists, copy link directly on next tick
+				let component
+				if (update) {
+					component = await new Promise(resolve => {
+						this.$emit('update:share', newShare, resolve)
+					})
+				} else {
+					// adding new share to the array and copying link to clipboard
+					// using promise so that we can copy link in the same click function
+					// and avoid firefox copy permissions issue
+					component = await new Promise(resolve => {
+						this.$emit('add:share', newShare, resolve)
+					})
+				}
+
+				// Execute the copy link method
+				// freshly created share component
+				// ! somehow does not works on firefox !
+				component.copyLink()
+
+			} catch ({ response }) {
+				const message = response.data.ocs.meta.message
+				if (message.match(/password/i)) {
+					this.onSyncError('password', message)
+				} else if (message.match(/date/i)) {
+					this.onSyncError('expireDate', message)
+				} else {
+					this.onSyncError('pending', message)
+				}
+			} finally {
+				this.loading = false
+			}
+		},
+
+		/**
+		 * On permissions change
+		 * @param {Event} event js event
+		 */
+		togglePermissions(event) {
+			const permissions = parseInt(event.target.value, 10)
+			this.share.permissions = permissions
+			this.queueUpdate('permissions')
+		},
+
+		/**
+		 * Generate a valid policy password or
+		 * request a valid password if password_policy
+		 * is enabled
+		 *
+		 * @returns {string} a valid password
+		 */
+		async generatePassword() {
+			// password policy is enabled, let's request a pass
+			if (this.config.passwordPolicy.api && this.config.passwordPolicy.api.generate) {
+				try {
+					const request = await axios.get(this.config.passwordPolicy.api.generate)
+					if (request.data.ocs.data.password) {
+						return request.data.ocs.data.password
+					}
+				} catch (error) {
+					console.info('Error generating password from password_policy', error)
+				}
+			}
+
+			// generate password of 10 length based on passwordSet
+			return Array(10).fill(0)
+				.reduce((prev, curr) => {
+					prev += passwordSet.charAt(Math.floor(Math.random() * passwordSet.length))
+					return prev
+				}, '')
+		},
+
+		async copyLink() {
+			try {
+				await this.$copyText(this.shareLink)
+				// focus and show the tooltip
+				this.$refs.copyButton.$el.focus()
+				this.copySuccess = true
+				this.copied = true
+			} catch (error) {
+				this.copySuccess = false
+				this.copied = true
+				console.error(error)
+			} finally {
+				setTimeout(() => {
+					this.copySuccess = false
+					this.copied = false
+				}, 4000)
+			}
+		},
+
+		/**
+		 * Update newPassword values
+		 * of share. If password is set but not newPassword
+		 * then the user did not changed the password
+		 * If both co-exists, the password have changed and
+		 * we show it in plain text.
+		 * Then on submit (or menu close), we sync it.
+		 * @param {string} password the changed password
+		 */
+		onPasswordChange(password) {
+			this.$set(this.share, 'newPassword', password)
+		},
+
+		/**
+		 * Uncheck password protection
+		 * We need this method because @update:checked
+		 * is ran simultaneously as @uncheck, so
+		 * so we cannot ensure data is up-to-date
+		 */
+		onPasswordDisable() {
+			this.share.password = ''
+
+			// reset password state after sync
+			this.$delete(this.share, 'newPassword')
+
+			// only update if valid share.
+			if (this.share.id) {
+				this.queueUpdate('password')
+			}
+		},
+
+		/**
+		 * Menu have been closed or password has been submited.
+		 * The only property that does not get
+		 * synced automatically is the password
+		 * So let's check if we have an unsaved
+		 * password.
+		 * expireDate is saved on datepicker pick
+		 * or close.
+		 */
+		onPasswordSubmit() {
+			if (this.hasUnsavedPassword) {
+				this.share.password = this.share.newPassword
+				this.queueUpdate('password')
+			}
+		},
+
+		/**
+		 * Cancel the share creation
+		 * Used in the pending popover
+		 */
+		onCancel() {
+			// this.share already exists at this point,
+			// but is incomplete as not pushed to server
+			// YET. We can safely delete the share :)
+			this.$emit('remove:share', this.share)
+		}
+	}
+
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+	display: flex;
+	align-items: center;
+	height: 44px;
+	&__desc {
+		display: flex;
+		flex-direction: column;
+		justify-content: space-between;
+		padding: 8px;
+		line-height: 1.2em;
+	}
+
+	&:not(.sharing-entry--share) &__actions {
+		.new-share-link {
+			border-top: 1px solid var(--color-border);
+		}
+	}
+
+	.sharing-entry__action--public-upload {
+		border-bottom: 1px solid var(--color-border);
+	}
+
+	&__loading {
+		width: 44px;
+		height: 44px;
+		margin: 0;
+		padding: 14px;
+		margin-left: auto;
+	}
+
+	// put menus to the left
+	// but only the first one
+	.action-item {
+		margin-left: auto;
+		~ .action-item,
+		~ .sharing-entry__loading {
+			margin-left: 0;
+		}
+	}
+
+	.icon-checkmark-color {
+		opacity: 1;
+	}
+}
+</style>

+ 97 - 0
apps/files_sharing/src/components/SharingEntrySimple.vue

@@ -0,0 +1,97 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<li class="sharing-entry">
+		<slot name="avatar" />
+		<div v-tooltip="tooltip" class="sharing-entry__desc">
+			<h5>{{ title }}</h5>
+			<p v-if="subtitle">
+				{{ subtitle }}
+			</p>
+		</div>
+		<Actions v-if="$slots['default']" menu-align="right" class="sharing-entry__actions">
+			<slot />
+		</Actions>
+	</li>
+</template>
+
+<script>
+import Actions from 'nextcloud-vue/dist/Components/Actions'
+import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip'
+
+export default {
+	name: 'SharingEntrySimple',
+
+	components: {
+		Actions
+	},
+
+	directives: {
+		Tooltip
+	},
+
+	props: {
+		title: {
+			type: String,
+			default: '',
+			required: true
+		},
+		tooltip: {
+			type: String,
+			default: ''
+		},
+		subtitle: {
+			type: String,
+			default: ''
+		}
+	}
+
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+	display: flex;
+	align-items: center;
+	height: 44px;
+	&__desc {
+		padding: 8px;
+		line-height: 1.2em;
+		position: relative;
+		flex: 1 1;
+		min-width: 0;
+		h5 {
+			white-space: nowrap;
+			text-overflow: ellipsis;
+			overflow: hidden;
+			max-width: inherit;
+		}
+		p {
+			color: var(--color-text-maxcontrast);
+		}
+	}
+	&__actions {
+		margin-left: auto !important;
+	}
+}
+</style>

+ 444 - 0
apps/files_sharing/src/components/SharingInput.vue

@@ -0,0 +1,444 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<Multiselect ref="multiselect"
+		class="sharing-input"
+		:disabled="!canReshare"
+		:hide-selected="true"
+		:internal-search="false"
+		:loading="loading"
+		:options="options"
+		:placeholder="inputPlaceholder"
+		:preselect-first="true"
+		:preserve-search="true"
+		:searchable="true"
+		:user-select="true"
+		@search-change="asyncFind"
+		@select="addShare">
+		<template #noOptions>
+			{{ t('files_sharing', 'No recommendations. Start typing.') }}
+		</template>
+		<template #noResult>
+			{{ noResultText }}
+		</template>
+	</Multiselect>
+</template>
+
+<script>
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import axios from '@nextcloud/axios'
+import debounce from 'debounce'
+import Multiselect from 'nextcloud-vue/dist/Components/Multiselect'
+
+import Config from '../services/ConfigService'
+import Share from '../models/Share'
+import ShareRequests from '../mixins/ShareRequests'
+import ShareTypes from '../mixins/ShareTypes'
+
+export default {
+	name: 'SharingInput',
+
+	components: {
+		Multiselect
+	},
+
+	mixins: [ShareTypes, ShareRequests],
+
+	props: {
+		shares: {
+			type: Array,
+			default: () => [],
+			required: true
+		},
+		linkShares: {
+			type: Array,
+			default: () => [],
+			required: true
+		},
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		},
+		reshare: {
+			type: Share,
+			default: null
+		},
+		canReshare: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			config: new Config(),
+			loading: false,
+			query: '',
+			recommendations: [],
+			ShareSearch: OCA.Sharing.ShareSearch.state,
+			suggestions: []
+		}
+	},
+
+	computed: {
+		/**
+		 * Implement ShareSearch
+		 * allows external appas to inject new
+		 * results into the autocomplete dropdown
+		 * Used for the guests app
+		 *
+		 * @returns {Array}
+		 */
+		externalResults() {
+			return this.ShareSearch.results
+		},
+		inputPlaceholder() {
+			const allowRemoteSharing = this.config.isRemoteShareAllowed
+			const allowMailSharing = this.config.isMailShareAllowed
+
+			if (!this.canReshare) {
+				return t('files_sharing', 'Resharing is not allowed')
+			}
+			if (!allowRemoteSharing && allowMailSharing) {
+				return t('files_sharing', 'Name or email address...')
+			}
+			if (allowRemoteSharing && !allowMailSharing) {
+				return t('files_sharing', 'Name or federated cloud ID...')
+			}
+			if (allowRemoteSharing && allowMailSharing) {
+				return t('files_sharing', 'Name, federated cloud ID or email address...')
+			}
+
+			return 	t('files_sharing', 'Name...')
+		},
+
+		isValidQuery() {
+			return this.query && this.query.trim() !== '' && this.query.length > this.config.minSearchStringLength
+		},
+
+		options() {
+			if (this.isValidQuery) {
+				return this.suggestions
+			}
+			return this.recommendations
+		},
+
+		noResultText() {
+			if (this.loading) {
+				return t('files_sharing', 'Searching...')
+			}
+			return t('files_sharing', 'No elements found.')
+		}
+	},
+
+	mounted() {
+		this.getRecommendations()
+	},
+
+	methods: {
+		async asyncFind(query, id) {
+			// save current query to check if we display
+			// recommendations or search results
+			this.query = query.trim()
+			if (this.isValidQuery) {
+				// start loading now to have proper ux feedback
+				// during the debounce
+				this.loading = true
+				await this.debounceGetSuggestions(query)
+			}
+		},
+
+		/**
+		 * Get suggestions
+		 *
+		 * @param {string} search the search query
+		 * @param {boolean} [lookup=false] search on lookup server
+		 */
+		async getSuggestions(search, lookup) {
+			this.loading = true
+			lookup = lookup || false
+			console.info(search, lookup)
+
+			const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees', {
+				params: {
+					format: 'json',
+					itemType: this.fileInfo.type === 'dir' ? 'folder' : 'file',
+					search,
+					lookup,
+					perPage: this.config.maxAutocompleteResults
+				}
+			})
+
+			if (request.data.ocs.meta.statuscode !== 100) {
+				console.error('Error fetching suggestions', request)
+				return
+			}
+
+			const data = request.data.ocs.data
+			const exact = request.data.ocs.data.exact
+			data.exact = [] // removing exact from general results
+
+			// flatten array of arrays
+			const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
+			const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), [])
+
+			// remove invalid data and format to user-select layout
+			const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions)
+				.map(share => this.formatForMultiselect(share))
+			const suggestions = this.filterOutExistingShares(rawSuggestions)
+				.map(share => this.formatForMultiselect(share))
+
+			// lookup clickable entry
+			const lookupEntry = []
+			if (data.lookupEnabled) {
+				lookupEntry.push({
+					isNoUser: true,
+					displayName: t('files_sharing', 'Search globally'),
+					lookup: true
+				})
+			}
+
+			// if there is a condition specified, filter it
+			const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this))
+
+			this.suggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry)
+
+			this.loading = false
+			console.info('suggestions', this.suggestions)
+		},
+
+		/**
+		 * Debounce getSuggestions
+		 *
+		 * @param {...*} args the arguments
+		 */
+		debounceGetSuggestions: debounce(function(...args) {
+			this.getSuggestions(...args)
+		}, 300),
+
+		/**
+		 * Get the sharing recommendations
+		 */
+		async getRecommendations() {
+			this.loading = true
+
+			const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees_recommended', {
+				params: {
+					format: 'json',
+					itemType: this.fileInfo.type
+				}
+			})
+
+			if (request.data.ocs.meta.statuscode !== 100) {
+				console.error('Error fetching recommendations', request)
+				return
+			}
+
+			const exact = request.data.ocs.data.exact
+
+			// flatten array of arrays
+			const rawRecommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
+
+			// remove invalid data and format to user-select layout
+			this.recommendations = this.filterOutExistingShares(rawRecommendations)
+				.map(share => this.formatForMultiselect(share))
+
+			this.loading = false
+			console.info('recommendations', this.recommendations)
+		},
+
+		/**
+		 * Filter out existing shares from
+		 * the provided shares search results
+		 *
+		 * @param {Object[]} shares the array of shares object
+		 * @returns {Object[]}
+		 */
+		filterOutExistingShares(shares) {
+			return shares.reduce((arr, share) => {
+				// only check proper objects
+				if (typeof share !== 'object') {
+					return arr
+				}
+				try {
+					// filter out current user
+					if (share.value.shareWith === getCurrentUser().uid) {
+						return arr
+					}
+
+					// filter out the owner of the share
+					if (this.reshare && share.value.shareWith === this.reshare.owner) {
+						return arr
+					}
+
+					// filter out existing mail shares
+					if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+						const emails = this.linkShares.map(elem => elem.shareWith)
+						if (emails.indexOf(share.value.shareWith.trim()) !== -1) {
+							return arr
+						}
+					} else { // filter out existing shares
+						// creating an object of uid => type
+						const sharesObj = this.shares.reduce((obj, elem) => {
+							obj[elem.shareWith] = elem.type
+							return obj
+						}, {})
+
+						// if shareWith is the same and the share type too, ignore it
+						const key = share.value.shareWith.trim()
+						if (key in sharesObj
+							&& sharesObj[key] === share.value.shareType) {
+							return arr
+						}
+					}
+
+					// ALL GOOD
+					// let's add the suggestion
+					arr.push(share)
+				} catch {
+					return arr
+				}
+				return arr
+			}, [])
+		},
+
+		/**
+		 * Get the icon based on the share type
+		 * @param {number} type the share type
+		 * @returns {string} the icon class
+		 */
+		shareTypeToIcon(type) {
+			switch (type) {
+			case this.SHARE_TYPES.SHARE_TYPE_GUEST:
+				// default is a user, other icons are here to differenciate
+				// themselves from it, so let's not display the user icon
+				// case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
+				// case this.SHARE_TYPES.SHARE_TYPE_USER:
+				return 'icon-user'
+			case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
+			case this.SHARE_TYPES.SHARE_TYPE_GROUP:
+				return 'icon-group'
+			case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
+				return 'icon-mail'
+			case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
+				return 'icon-circle'
+			case this.SHARE_TYPES.SHARE_TYPE_ROOM:
+				return 'icon-room'
+
+			default:
+				return ''
+			}
+		},
+
+		/**
+		 * Format shares for the multiselect options
+		 * @param {Object} result select entry item
+		 * @returns {Object}
+		 */
+		formatForMultiselect(result) {
+			let desc
+			if ((result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE
+					|| result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP
+			) && result.value.server) {
+				desc = t('files_sharing', 'on {server}', { server: result.value.server })
+			} else if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+				desc = result.value.shareWith
+			}
+
+			return {
+				shareWith: result.value.shareWith,
+				shareType: result.value.shareType,
+				user: result.uuid || result.value.shareWith,
+				isNoUser: !result.uuid,
+				displayName: result.name || result.label,
+				desc,
+				icon: this.shareTypeToIcon(result.value.shareType)
+			}
+		},
+
+		/**
+		 * Process the new share request
+		 * @param {Object} value the multiselect option
+		 */
+		async addShare(value) {
+			if (value.lookup) {
+				return this.getSuggestions(this.query, true)
+			}
+
+			// handle externalResults from OCA.Sharing.ShareSearch
+			if (value.handler) {
+				const share = await value.handler(this)
+				this.$emit('add:share', new Share(share))
+				return true
+			}
+
+			this.loading = true
+			try {
+				const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+				const share = await this.createShare({
+					path,
+					shareType: value.shareType,
+					shareWith: value.shareWith
+				})
+				this.$emit('add:share', share)
+
+				this.getRecommendations()
+
+			} catch (response) {
+				// focus back if any error
+				const input = this.$refs.multiselect.$el.querySelector('input')
+				if (input) {
+					input.focus()
+				}
+				this.query = value.shareWith
+			} finally {
+				this.loading = false
+			}
+		}
+	}
+}
+</script>
+
+<style lang="scss">
+.sharing-input {
+	width: 100%;
+	margin: 10px 0;
+
+	// properly style the lookup entry
+	.multiselect__option {
+		span[lookup] {
+			.avatardiv {
+				background-image: var(--icon-search-fff);
+				background-repeat: no-repeat;
+				background-position: center;
+				background-color: var(--color-text-maxcontrast) !important;
+				div {
+					display: none;
+				}
+			}
+		}
+	}
+}
+</style>

+ 39 - 0
apps/files_sharing/src/files_sharing_tab.js

@@ -0,0 +1,39 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import SharingTab from './views/SharingTab'
+import ShareSearch from './services/ShareSearch'
+import ExternalLinkActions from './services/ExternalLinkActions'
+
+if (window.OCA && window.OCA.Sharing) {
+	Object.assign(window.OCA.Sharing, { ShareSearch: new ShareSearch() })
+}
+
+if (window.OCA && window.OCA.Sharing) {
+	Object.assign(window.OCA.Sharing, { ExternalLinkActions: new ExternalLinkActions() })
+}
+
+window.addEventListener('DOMContentLoaded', () => {
+	if (OCA.Files && OCA.Files.Sidebar) {
+		OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab('sharing', SharingTab))
+	}
+})

+ 114 - 0
apps/files_sharing/src/mixins/ShareRequests.js

@@ -0,0 +1,114 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// TODO: remove when ie not supported
+import 'url-search-params-polyfill'
+
+import { generateOcsUrl } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
+import Share from '../models/Share'
+
+const shareUrl = generateOcsUrl('apps/files_sharing/api/v1', 2) + 'shares'
+const headers = {
+	'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
+}
+
+export default {
+	methods: {
+		/**
+		 * Create a new share
+		 *
+		 * @param {Object} data destructuring object
+		 * @param {string} data.path  path to the file/folder which should be shared
+		 * @param {number} data.shareType  0 = user; 1 = group; 3 = public link; 6 = federated cloud share
+		 * @param {string} data.shareWith  user/group id with which the file should be shared (optional for shareType > 1)
+		 * @param {boolean} [data.publicUpload=false]  allow public upload to a public shared folder
+		 * @param {string} [data.password]  password to protect public link Share with
+		 * @param {number} [data.permissions=31]  1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1)
+		 * @param {boolean} [data.sendPasswordByTalk=false] send the password via a talk conversation
+		 * @param {string} [data.expireDate=''] expire the shareautomatically after
+		 * @param {string} [data.label=''] custom label
+		 * @returns {Share} the new share
+		 * @throws {Error}
+		 */
+		async createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label }) {
+			try {
+				const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label })
+				if (!('ocs' in request.data)) {
+					throw request
+				}
+				return new Share(request.data.ocs.data)
+			} catch (error) {
+				console.error('Error while creating share', error)
+				OC.Notification.showTemporary(t('files_sharing', 'Error creating the share'), { type: 'error' })
+				throw error
+			}
+		},
+
+		/**
+		 * Delete a share
+		 *
+		 * @param {number} id share id
+		 * @throws {Error}
+		 */
+		async deleteShare(id) {
+			try {
+				const request = await axios.delete(shareUrl + `/${id}`)
+				if (!('ocs' in request.data)) {
+					throw request
+				}
+				return true
+			} catch (error) {
+				console.error('Error while deleting share', error)
+				OC.Notification.showTemporary(t('files_sharing', 'Error deleting the share'), { type: 'error' })
+				throw error
+			}
+		},
+
+		/**
+		 * Update a share
+		 *
+		 * @param {number} id share id
+		 * @param {Object} data destructuring object
+		 * @param {string} data.property property to update
+		 * @param {any} data.value value to set
+		 */
+		async updateShare(id, { property, value }) {
+			try {
+				// ocs api requires x-www-form-urlencoded
+				const data = new URLSearchParams()
+				data.append(property, value)
+
+				const request = await axios.put(shareUrl + `/${id}`, { [property]: value }, headers)
+				if (!('ocs' in request.data)) {
+					throw request
+				}
+				return true
+			} catch (error) {
+				console.error('Error while updating share', error)
+				OC.Notification.showTemporary(t('files_sharing', 'Error updating the share'), { type: 'error' })
+				const message = error.response.data.ocs.meta.message
+				throw new Error(`${property}, ${message}`)
+			}
+		}
+	}
+}

+ 39 - 0
apps/files_sharing/src/mixins/ShareTypes.js

@@ -0,0 +1,39 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export default {
+	data() {
+		return {
+			SHARE_TYPES: {
+				SHARE_TYPE_USER: OC.Share.SHARE_TYPE_USER,
+				SHARE_TYPE_GROUP: OC.Share.SHARE_TYPE_GROUP,
+				SHARE_TYPE_LINK: OC.Share.SHARE_TYPE_LINK,
+				SHARE_TYPE_EMAIL: OC.Share.SHARE_TYPE_EMAIL,
+				SHARE_TYPE_REMOTE: OC.Share.SHARE_TYPE_REMOTE,
+				SHARE_TYPE_CIRCLE: OC.Share.SHARE_TYPE_CIRCLE,
+				SHARE_TYPE_GUEST: OC.Share.SHARE_TYPE_GUEST,
+				SHARE_TYPE_REMOTE_GROUP: OC.Share.SHARE_TYPE_REMOTE_GROUP,
+				SHARE_TYPE_ROOM: OC.Share.SHARE_TYPE_ROOM
+			}
+		}
+	}
+}

+ 303 - 0
apps/files_sharing/src/mixins/SharesMixin.js

@@ -0,0 +1,303 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import PQueue from 'p-queue'
+import debounce from 'debounce'
+
+import Share from '../models/Share'
+import SharesRequests from './ShareRequests'
+import ShareTypes from './ShareTypes'
+import Config from '../services/ConfigService'
+import { getCurrentUser } from '@nextcloud/auth'
+
+export default {
+	mixins: [SharesRequests, ShareTypes],
+
+	props: {
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		},
+		share: {
+			type: Share,
+			default: null
+		}
+	},
+
+	data() {
+		return {
+			config: new Config(),
+
+			// errors helpers
+			errors: {},
+
+			// component status toggles
+			loading: false,
+			saving: false,
+			open: false,
+
+			// concurrency management queue
+			// we want one queue per share
+			updateQueue: new PQueue({ concurrency: 1 }),
+
+			/**
+			 * ! This allow vue to make the Share class state reactive
+			 * ! do not remove it ot you'll lose all reactivity here
+			 */
+			reactiveState: this.share && this.share.state,
+
+			SHARE_TYPES: {
+				SHARE_TYPE_USER: OC.Share.SHARE_TYPE_USER,
+				SHARE_TYPE_GROUP: OC.Share.SHARE_TYPE_GROUP,
+				SHARE_TYPE_LINK: OC.Share.SHARE_TYPE_LINK,
+				SHARE_TYPE_EMAIL: OC.Share.SHARE_TYPE_EMAIL,
+				SHARE_TYPE_REMOTE: OC.Share.SHARE_TYPE_REMOTE,
+				SHARE_TYPE_CIRCLE: OC.Share.SHARE_TYPE_CIRCLE,
+				SHARE_TYPE_GUEST: OC.Share.SHARE_TYPE_GUEST,
+				SHARE_TYPE_REMOTE_GROUP: OC.Share.SHARE_TYPE_REMOTE_GROUP,
+				SHARE_TYPE_ROOM: OC.Share.SHARE_TYPE_ROOM
+			}
+		}
+	},
+
+	computed: {
+
+		/**
+		 * Does the current share have an expiration date
+		 * @returns {boolean}
+		 */
+		hasExpirationDate: {
+			get: function() {
+				return this.config.isDefaultExpireDateEnforced || !!this.share.expireDate
+			},
+			set: function(enabled) {
+				this.share.expireDate = enabled
+					? this.config.defaultExpirationDateString !== ''
+						? this.config.defaultExpirationDateString
+						: moment().format('YYYY-MM-DD')
+					: ''
+			}
+		},
+
+		/**
+		 * Does the current share have a note
+		 * @returns {boolean}
+		 */
+		hasNote: {
+			get: function() {
+				return !!this.share.note
+			},
+			set: function(enabled) {
+				this.share.note = enabled
+					? t('files_sharing', 'Enter a note for the share recipient')
+					: ''
+			}
+		},
+
+		dateTomorrow() {
+			return moment().add(1, 'days')
+		},
+
+		dateMaxEnforced() {
+			return this.config.isDefaultExpireDateEnforced
+				&& moment().add(1 + this.config.defaultExpireDate, 'days')
+		},
+
+		/**
+		 * Datepicker lang values
+		 * https://github.com/nextcloud/nextcloud-vue/pull/146
+		 * TODO: have this in vue-components
+		 *
+		 * @returns {int}
+		 */
+		firstDay() {
+			return window.firstDay
+				? window.firstDay
+				: 0 // sunday as default
+		},
+		lang() {
+			// fallback to default in case of unavailable data
+			return {
+				days: window.dayNamesShort
+					? window.dayNamesShort			// provided by nextcloud
+					: ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.'],
+				months: window.monthNamesShort
+					? window.monthNamesShort		// provided by nextcloud
+					: ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'],
+				placeholder: {
+					date: 'Select Date' // TODO: Translate
+				}
+			}
+		},
+
+		isShareOwner() {
+			return this.share && this.share.owner === getCurrentUser().uid
+		}
+
+	},
+
+	methods: {
+		/**
+		 * Check if a share is valid before
+		 * firing the request
+		 *
+		 * @param {Share} share the share to check
+		 * @returns {Boolean}
+		 */
+		checkShare(share) {
+			if (share.password) {
+				if (typeof share.password !== 'string' || share.password.trim() === '') {
+					return false
+				}
+			}
+			if (share.expirationDate) {
+				const date = moment(share.expirationDate)
+				if (!date.isValid()) {
+					return false
+				}
+			}
+			return true
+		},
+
+		/**
+		 * ActionInput can be a little tricky to work with.
+		 * Since we expect a string and not a Date,
+		 * we need to process the value here
+		 *
+		 * @param {Date} date js date to be parsed by moment.js
+		 */
+		onExpirationChange(date) {
+			// format to YYYY-MM-DD
+			const value = moment(date).format('YYYY-MM-DD')
+			this.share.expireDate = value
+			this.queueUpdate('expireDate')
+		},
+
+		/**
+		 * Uncheck expire date
+		 * We need this method because @update:checked
+		 * is ran simultaneously as @uncheck, so
+		 * so we cannot ensure data is up-to-date
+		 */
+		onExpirationDisable() {
+			this.share.expireDate = ''
+			this.queueUpdate('expireDate')
+		},
+
+		/**
+		 * Delete share button handler
+		 */
+		async onDelete() {
+			try {
+				this.loading = true
+				this.open = false
+				await this.deleteShare(this.share.id)
+				console.debug('Share deleted', this.share.id)
+				this.$emit('remove:share', this.share)
+			} catch (error) {
+				// re-open menu if error
+				this.open = true
+			} finally {
+				this.loading = false
+			}
+		},
+
+		/**
+		 * Send an update of the share to the queue
+		 *
+		 * @param {string} property the property to sync
+		 */
+		queueUpdate(property) {
+			if (this.share.id) {
+				// force value to string because that is what our
+				// share api controller accepts
+				const value = this.share[property].toString()
+
+				this.updateQueue.add(async() => {
+					this.saving = true
+					this.errors = {}
+					try {
+						await this.updateShare(this.share.id, {
+							property,
+							value
+						})
+
+						// clear any previous errors
+						this.$delete(this.errors, property)
+
+						// reset password state after sync
+						this.$delete(this.share, 'newPassword')
+					} catch ({ property, message }) {
+						this.onSyncError(property, message)
+					} finally {
+						this.saving = false
+					}
+				})
+			} else {
+				console.error('Cannot update share.', this.share, 'No valid id')
+			}
+		},
+
+		/**
+		 * Manage sync errors
+		 * @param {string} property the errored property, e.g. 'password'
+		 * @param {string} message the error message
+		 */
+		onSyncError(property, message) {
+			// re-open menu if closed
+			this.open = true
+			switch (property) {
+			case 'password':
+			case 'pending':
+			case 'expireDate':
+			case 'note': {
+				// show error
+				this.$set(this.errors, property, message)
+
+				let propertyEl = this.$refs[property]
+				if (propertyEl) {
+					if (propertyEl.$el) {
+						propertyEl = propertyEl.$el
+					}
+					// focus if there is a focusable action element
+					const focusable = propertyEl.querySelector('.focusable')
+					if (focusable) {
+						focusable.focus()
+					}
+				}
+				break
+			}
+			}
+		},
+
+		/**
+		 * Debounce queueUpdate to avoid requests spamming
+		 * more importantly for text data
+		 *
+		 * @param {string} property the property to sync
+		 */
+		debounceQueueUpdate: debounce(function(property) {
+			this.queueUpdate(property)
+		}, 500)
+	}
+}

+ 444 - 0
apps/files_sharing/src/models/Share.js

@@ -0,0 +1,444 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export default class Share {
+
+	#share;
+
+	/**
+	 * Create the share object
+	 *
+	 * @param {Object} ocsData ocs request response
+	 */
+	constructor(ocsData) {
+		if (ocsData.ocs && ocsData.ocs.data && ocsData.ocs.data[0]) {
+			ocsData = ocsData.ocs.data[0]
+		}
+
+		// convert int into boolean
+		ocsData.hide_download = !!ocsData.hide_download
+		ocsData.mail_send = !!ocsData.mail_send
+
+		// store state
+		this.#share = ocsData
+	}
+
+	/**
+	 * Get the share state
+	 * ! used for reactivity purpose
+	 * Do not remove. It allow vuejs to
+	 * inject its watchers into the #share
+	 * state and make the whole class reactive
+	 *
+	 * @returns {Object} the share raw state
+	 * @readonly
+	 * @memberof Sidebar
+	 */
+	get state() {
+		return this.#share
+	}
+
+	/**
+	 * get the share id
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get id() {
+		return this.#share.id
+	}
+
+	/**
+	 * Get the share type
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get type() {
+		return this.#share.share_type
+	}
+
+	/**
+	 * Get the share permissions
+	 * See OC.PERMISSION_* variables
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get permissions() {
+		return this.#share.permissions
+	}
+
+	/**
+	 * Set the share permissions
+	 * See OC.PERMISSION_* variables
+	 *
+	 * @param {int} permissions valid permission, See OC.PERMISSION_* variables
+	 * @memberof Share
+	 */
+	set permissions(permissions) {
+		this.#share.permissions = permissions
+	}
+
+	// SHARE OWNER --------------------------------------------------
+	/**
+	 * Get the share owner uid
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get owner() {
+		return this.#share.uid_owner
+	}
+
+	/**
+	 * Get the share owner's display name
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get ownerDisplayName() {
+		return this.#share.displayname_owner
+	}
+
+	// SHARED WITH --------------------------------------------------
+	/**
+	 * Get the share with entity uid
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get shareWith() {
+		return this.#share.share_with
+	}
+
+	/**
+	 * Get the share with entity display name
+	 * fallback to its uid if none
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get shareWithDisplayName() {
+		return this.#share.share_with_displayname
+			|| this.#share.share_with
+	}
+
+	/**
+	 * Get the share with avatar if any
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get shareWithAvatar() {
+		return this.#share.share_with_avatar
+	}
+
+	// SHARED FILE OR FOLDER OWNER ----------------------------------
+	/**
+	 * Get the shared item owner uid
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get uidFileOwner() {
+		return this.#share.uid_file_owner
+	}
+
+	/**
+	 * Get the shared item display name
+	 * fallback to its uid if none
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get displaynameFileOwner() {
+		return this.#share.displayname_file_owner
+			|| this.#share.uid_file_owner
+	}
+
+	// TIME DATA ----------------------------------------------------
+	/**
+	 * Get the share creation timestamp
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get createdTime() {
+		return this.#share.stime
+	}
+
+	/**
+	 * Get the expiration date as a string format
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get expireDate() {
+		return this.#share.expiration
+	}
+
+	/**
+	 * Set the expiration date as a string format
+	 * e.g. YYYY-MM-DD
+	 *
+	 * @param {string} date the share expiration date
+	 * @memberof Share
+	 */
+	set expireDate(date) {
+		this.#share.expiration = date
+	}
+
+	// EXTRA DATA ---------------------------------------------------
+	/**
+	 * Get the public share token
+	 *
+	 * @returns {string} the token
+	 * @readonly
+	 * @memberof Share
+	 */
+	get token() {
+		return this.#share.token
+	}
+
+	/**
+	 * Get the share note if any
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get note() {
+		return this.#share.note
+	}
+
+	/**
+	 * Set the share note if any
+	 *
+	 * @param {string} note the note
+	 * @memberof Share
+	 */
+	set note(note) {
+		this.#share.note = note.trim()
+	}
+
+	/**
+	 * Have a mail been sent
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get mailSend() {
+		return this.#share.mail_send === true
+	}
+
+	/**
+	 * Hide the download button on public page
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get hideDownload() {
+		return this.#share.hide_download === true
+	}
+
+	/**
+	 * Hide the download button on public page
+	 *
+	 * @param {boolean} state hide the button ?
+	 * @memberof Share
+	 */
+	set hideDownload(state) {
+		this.#share.hide_download = state === true
+	}
+
+	/**
+	 * Password protection of the share
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get password() {
+		return this.#share.password
+	}
+
+	/**
+	 * Password protection of the share
+	 *
+	 * @param {string} password the share password
+	 * @memberof Share
+	 */
+	set password(password) {
+		this.#share.password = password.trim()
+	}
+
+	// SHARED ITEM DATA ---------------------------------------------
+	/**
+	 * Get the shared item absolute full path
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get path() {
+		return this.#share.path
+	}
+
+	/**
+	 * Return the item type: file or folder
+	 *
+	 * @returns {string} 'folder' or 'file'
+	 * @readonly
+	 * @memberof Share
+	 */
+	get itemType() {
+		return this.#share.item_type
+	}
+
+	/**
+	 * Get the shared item mimetype
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get mimetype() {
+		return this.#share.mimetype
+	}
+
+	/**
+	 * Get the shared item id
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get fileSource() {
+		return this.#share.file_source
+	}
+
+	/**
+	 * Get the target path on the receiving end
+	 * e.g the file /xxx/aaa will be shared in
+	 * the receiving root as /aaa, the fileTarget is /aaa
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get fileTarget() {
+		return this.#share.file_target
+	}
+
+	/**
+	 * Get the parent folder id if any
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get fileParent() {
+		return this.#share.file_parent
+	}
+
+	// PERMISSIONS Shortcuts
+	/**
+	 * Does this share have CREATE permissions
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get hasCreatePermission() {
+		return !!((this.permissions & OC.PERMISSION_CREATE))
+	}
+
+	/**
+	 * Does this share have DELETE permissions
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get hasDeletePermission() {
+		return !!((this.permissions & OC.PERMISSION_DELETE))
+	}
+
+	/**
+	 * Does this share have UPDATE permissions
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get hasUpdatePermission() {
+		return !!((this.permissions & OC.PERMISSION_UPDATE))
+	}
+
+	/**
+	 * Does this share have SHARE permissions
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Share
+	 */
+	get hasSharePermission() {
+		return !!((this.permissions & OC.PERMISSION_SHARE))
+	}
+
+	// TODO: SORT THOSE PROPERTIES
+	get label() {
+		return this.#share.label
+	}
+
+	get parent() {
+		return this.#share.parent
+	}
+
+	get storageId() {
+		return this.#share.storage_id
+	}
+
+	get storage() {
+		return this.#share.storage
+	}
+
+	get itemSource() {
+		return this.#share.item_source
+	}
+
+}

+ 223 - 0
apps/files_sharing/src/services/ConfigService.js

@@ -0,0 +1,223 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export default class Config {
+
+	/**
+	 * Is public upload allowed on link shares ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isPublicUploadEnabled() {
+		return document.getElementById('filestable')
+			&& document.getElementById('filestable').dataset.allowPublicUpload === 'yes'
+	}
+
+	/**
+	 * Are link share allowed ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isShareWithLinkAllowed() {
+		return document.getElementById('allowShareWithLink')
+			&& document.getElementById('allowShareWithLink').value === 'yes'
+	}
+
+	/**
+	 * Get the federated sharing documentation link
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get federatedShareDocLink() {
+		return OC.appConfig.core.federatedCloudShareDoc
+	}
+
+	/**
+	 * Get the default expiration date as string
+	 *
+	 * @returns {string}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get defaultExpirationDateString() {
+		let expireDateString = ''
+		if (this.isDefaultExpireDateEnabled) {
+			const date = window.moment.utc()
+			const expireAfterDays = this.defaultExpireDate
+			date.add(expireAfterDays, 'days')
+			expireDateString = date.format('YYYY-MM-DD')
+		}
+		return expireDateString
+	}
+
+	/**
+	 * Are link shares password-enforced ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get enforcePasswordForPublicLink() {
+		return OC.appConfig.core.enforcePasswordForPublicLink === true
+	}
+
+	/**
+	 * Is password asked by default on link shares ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get enableLinkPasswordByDefault() {
+		return OC.appConfig.core.enableLinkPasswordByDefault === true
+	}
+
+	/**
+	 * Is link shares expiration enforced ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isDefaultExpireDateEnforced() {
+		return OC.appConfig.core.defaultExpireDateEnforced === true
+	}
+
+	/**
+	 * Is there a default expiration date for new link shares ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isDefaultExpireDateEnabled() {
+		return OC.appConfig.core.defaultExpireDateEnabled === true
+	}
+
+	/**
+	 * Are users on this server allowed to send shares to other servers ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isRemoteShareAllowed() {
+		return OC.appConfig.core.remoteShareAllowed === true
+	}
+
+	/**
+	 * Is sharing my mail (link share) enabled ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isMailShareAllowed() {
+		return OC.appConfig.shareByMailEnabled !== undefined
+	}
+
+	/**
+	 * Get the default days to expiration
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get defaultExpireDate() {
+		return OC.appConfig.core.defaultExpireDate
+	}
+
+	/**
+	 * Is resharing allowed ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isResharingAllowed() {
+		return OC.appConfig.core.resharingAllowed === true
+	}
+
+	/**
+	 * Is password enforced for mail shares ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get isPasswordForMailSharesRequired() {
+		return (OC.appConfig.shareByMail === undefined) ? false : OC.appConfig.shareByMail.enforcePasswordProtection === true
+	}
+
+	/**
+	 * Is sharing with groups allowed ?
+	 *
+	 * @returns {boolean}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get allowGroupSharing() {
+		return OC.appConfig.core.allowGroupSharing === true
+	}
+
+	/**
+	 * Get the maximum results of a share search
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get maxAutocompleteResults() {
+		return parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200
+	}
+
+	/**
+	 * Get the minimal string length
+	 * to initiate a share search
+	 *
+	 * @returns {int}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get minSearchStringLength() {
+		return parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0
+	}
+
+	/**
+	 * Get the password policy config
+	 *
+	 * @returns {Object}
+	 * @readonly
+	 * @memberof Config
+	 */
+	get passwordPolicy() {
+		const capabilities = OC.getCapabilities()
+		return capabilities.password_policy ? capabilities.password_policy : {}
+	}
+
+}

+ 63 - 0
apps/files_sharing/src/services/ExternalLinkActions.js

@@ -0,0 +1,63 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export default class ExternalLinkActions {
+
+	#state;
+
+	constructor() {
+		// init empty state
+		this.#state = {}
+
+		// init default values
+		this.#state.actions = []
+		console.debug('OCA.Sharing.ExternalLinkActions initialized')
+	}
+
+	/**
+	 * Get the state
+	 *
+	 * @readonly
+	 * @memberof ExternalLinkActions
+	 * @returns {Object} the data state
+	 */
+	get state() {
+		return this.#state
+	}
+
+	/**
+	 * Register a new action for the link share
+	 * Mostly used by the social sharing app.
+	 *
+	 * @param {Object} action new action component to register
+	 * @returns {boolean}
+	 */
+	registerAction(action) {
+		if (typeof action === 'object' && action.render && action.components) {
+			this.#state.actions.push(action)
+			return true
+		}
+		console.error(`Invalid action component provided`, action)
+		return false
+	}
+
+}

+ 71 - 0
apps/files_sharing/src/services/ShareSearch.js

@@ -0,0 +1,71 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export default class ShareSearch {
+
+	#state;
+
+	constructor() {
+		// init empty state
+		this.#state = {}
+
+		// init default values
+		this.#state.results = []
+		console.debug('OCA.Sharing.ShareSearch initialized')
+	}
+
+	/**
+	 * Get the state
+	 *
+	 * @readonly
+	 * @memberof ShareSearch
+	 * @returns {Object} the data state
+	 */
+	get state() {
+		return this.#state
+	}
+
+	/**
+	 * Register a new result
+	 * Mostly used by the guests app.
+	 * We should consider deprecation and add results via php ?
+	 *
+	 * @param {Object} result entry to append
+	 * @param {string} [result.user] entry user
+	 * @param {string} result.displayName entry first line
+	 * @param {string} [result.desc] entry second line
+	 * @param {string} [result.icon] entry icon
+	 * @param {function} result.handler function to run on entry selection
+	 * @param {function} [result.condition] condition to add entry or not
+	 * @returns {boolean}
+	 */
+	addNewResult(result) {
+		if (result.displayName.trim() !== ''
+			&& typeof result.handler === 'function') {
+			this.#state.results.push(result)
+			return true
+		}
+		console.error(`Invalid search result provided`, result)
+		return false
+	}
+
+}

+ 29 - 29
apps/files_sharing/src/share.js

@@ -195,7 +195,7 @@
 					// do not open sidebar if permission is set and equal to 0
 					var permissions = parseInt(context.$file.data('share-permissions'), 10)
 					if (isNaN(permissions) || permissions > 0) {
-						fileList.showDetailsView(fileName, 'shareTabView')
+						fileList.showDetailsView(fileName, 'sharing')
 					}
 				},
 				render: function(actionSpec, isDefault, context) {
@@ -209,37 +209,37 @@
 				}
 			})
 
-			var shareTab = new OCA.Sharing.ShareTabView('shareTabView', { order: -20 })
-			// detect changes and change the matching list entry
-			shareTab.on('sharesChanged', function(shareModel) {
-				var fileInfoModel = shareModel.fileInfoModel
-				var $tr = fileList.findFileEl(fileInfoModel.get('name'))
+			var shareTab = new OCA.Sharing.ShareTabView('sharing', {order: -20})
+			// // detect changes and change the matching list entry
+			// shareTab.on('sharesChanged', function(shareModel) {
+			// 	var fileInfoModel = shareModel.fileInfoModel
+			// 	var $tr = fileList.findFileEl(fileInfoModel.get('name'))
 
-				// We count email shares as link share
-				var hasLinkShares = shareModel.hasLinkShares()
-				shareModel.get('shares').forEach(function(share) {
-					if (share.share_type === OC.Share.SHARE_TYPE_EMAIL) {
-						hasLinkShares = true
-					}
-				})
+			// 	// We count email shares as link share
+			// 	var hasLinkShares = shareModel.hasLinkShares();
+			// 	shareModel.get('shares').forEach(function (share) {
+			// 		if (share.share_type === OC.Share.SHARE_TYPE_EMAIL) {
+			// 			hasLinkShares = true;
+			// 		}
+			// 	})
 
-				OCA.Sharing.Util._updateFileListDataAttributes(fileList, $tr, shareModel)
-				if (!OCA.Sharing.Util._updateFileActionIcon($tr, shareModel.hasUserShares(), hasLinkShares)) {
-					// remove icon, if applicable
-					OC.Share.markFileAsShared($tr, false, false)
-				}
+			// 	OCA.Sharing.Util._updateFileListDataAttributes(fileList, $tr, shareModel);
+			// 	if (!OCA.Sharing.Util._updateFileActionIcon($tr, shareModel.hasUserShares(), hasLinkShares)) {
+			// 		// remove icon, if applicable
+			// 		OC.Share.markFileAsShared($tr, false, false)
+			// 	}
 
-				// FIXME: this is too convoluted. We need to get rid of the above updates
-				// and only ever update the model and let the events take care of rerendering
-				fileInfoModel.set({
-					shareTypes: shareModel.getShareTypes(),
-					// in case markFileAsShared decided to change the icon,
-					// we need to modify the model
-					// (FIXME: yes, this is hacky)
-					icon: $tr.attr('data-icon')
-				})
-			})
-			fileList.registerTabView(shareTab)
+			// 	// FIXME: this is too convoluted. We need to get rid of the above updates
+			// 	// and only ever update the model and let the events take care of rerendering
+			// 	fileInfoModel.set({
+			// 		shareTypes: shareModel.getShareTypes(),
+			// 		// in case markFileAsShared decided to change the icon,
+			// 		// we need to modify the model
+			// 		// (FIXME: yes, this is hacky)
+			// 		icon: $tr.attr('data-icon')
+			// 	})
+			// })
+			// fileList.registerTabView(shareTab)
 
 			var breadCrumbSharingDetailView = new OCA.Sharing.ShareBreadCrumbView({ shareTab: shareTab })
 			fileList.registerBreadCrumbDetailView(breadCrumbSharingDetailView)

+ 1 - 1
apps/files_sharing/src/sharebreadcrumbview.js

@@ -93,7 +93,7 @@
 					dirInfo: self._dirInfo
 				})
 			})
-			OCA.Files.App.fileList.showDetailsView(fileInfoModel, 'shareTabView')
+			OCA.Files.App.fileList.showDetailsView(fileInfoModel, 'sharing')
 		}
 	})
 

+ 86 - 0
apps/files_sharing/src/utils/SharedWithMe.js

@@ -0,0 +1,86 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+/**
+ * Get the shared with me title
+ *
+ * @param {Share} share current share
+ * @returns {string} the title
+ */
+const shareWithTitle = function(share) {
+	if (share.type === OC.Share.type_GROUP) {
+		return t(
+			'files_sharing',
+			'Shared with you and the group {group} by {owner}',
+			{
+				group: share.shareWithDisplayName,
+				owner: share.ownerDisplayName
+			},
+			undefined,
+			{ escape: false }
+		)
+	} else if (share.type === OC.Share.type_CIRCLE) {
+		return t(
+			'files_sharing',
+			'Shared with you and {circle} by {owner}',
+			{
+				circle: share.shareWithDisplayName,
+				owner: share.ownerDisplayName
+			},
+			undefined,
+			{ escape: false }
+		)
+	} else if (share.type === OC.Share.type_ROOM) {
+		if (this.model.get('reshare').share_with_displayname) {
+			return t(
+				'files_sharing',
+				'Shared with you and the conversation {conversation} by {owner}',
+				{
+					conversation: share.shareWithDisplayName,
+					owner: share.ownerDisplayName
+				},
+				undefined,
+				{ escape: false }
+			)
+		} else {
+			return t(
+				'files_sharing',
+				'Shared with you in a conversation by {owner}',
+				{
+					owner: share.ownerDisplayName
+				},
+				undefined,
+				{ escape: false }
+			)
+		}
+	} else {
+		return t(
+			'files_sharing',
+			'Shared with you by {owner}',
+			{ owner: share.ownerDisplayName },
+			undefined,
+			{ escape: false }
+		)
+	}
+}
+
+export { shareWithTitle }

+ 141 - 0
apps/files_sharing/src/views/SharingLinkList.vue

@@ -0,0 +1,141 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<ul class="sharing-link-list">
+		<!-- If no link shares, show the add link default entry -->
+		<SharingEntryLink v-if="!hasLinkShares && canReshare"
+			:can-reshare="canReshare"
+			:file-info="fileInfo"
+			@add:share="addShare" />
+
+		<!-- Else we display the list -->
+		<template v-if="hasShares">
+			<!-- using shares[index] to work with .sync -->
+			<SharingEntryLink v-for="(share, index) in shares"
+				:key="share.id"
+				:can-reshare="canReshare"
+				:share.sync="shares[index]"
+				:file-info="fileInfo"
+				@add:share="addShare(...arguments)"
+				@update:share="awaitForShare(...arguments)"
+				@remove:share="removeShare" />
+		</template>
+	</ul>
+</template>
+
+<script>
+// eslint-disable-next-line no-unused-vars
+import Share from '../models/Share'
+import ShareTypes from '../mixins/ShareTypes'
+import SharingEntryLink from '../components/SharingEntryLink'
+
+export default {
+	name: 'SharingLinkList',
+
+	components: {
+		SharingEntryLink
+	},
+
+	mixins: [ShareTypes],
+
+	props: {
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		},
+		shares: {
+			type: Array,
+			default: () => [],
+			required: true
+		},
+		canReshare: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	computed: {
+		/**
+		 * Do we have link shares?
+		 * Using this to still show the `new link share`
+		 * button regardless of mail shares
+		 *
+		 * @returns {Array}
+		 */
+		hasLinkShares() {
+			return this.shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK).length > 0
+		},
+
+		/**
+		 * Do we have any link or email shares?
+		 *
+		 * @returns {boolean}
+		 */
+		hasShares() {
+			return this.shares.length > 0
+		}
+	},
+
+	methods: {
+		/**
+		 * Add a new share into the link shares list
+		 * and return the newly created share component
+		 *
+		 * @param {Share} share the share to add to the array
+		 * @param {Function} resolve a function to run after the share is added and its component initialized
+		 */
+		addShare(share, resolve) {
+			this.shares.unshift(share)
+			this.awaitForShare(share, resolve)
+		},
+
+		/**
+		 * Await for next tick and render after the list updated
+		 * Then resolve with the matched vue component of the
+		 * provided share object
+		 *
+		 * @param {Share} share newly created share
+		 * @param {Function} resolve a function to execute after
+		 */
+		awaitForShare(share, resolve) {
+			this.$nextTick(() => {
+				const newShare = this.$children.find(component => component.share === share)
+				if (newShare) {
+					resolve(newShare)
+				}
+			})
+		},
+
+		/**
+		 * Remove a share from the shares list
+		 *
+		 * @param {Share} share the share to remove
+		 */
+		removeShare(share) {
+			const index = this.shares.findIndex(item => item === share)
+			this.shares.splice(index, 1)
+		}
+	}
+}
+</script>

+ 76 - 0
apps/files_sharing/src/views/SharingList.vue

@@ -0,0 +1,76 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<ul class="sharing-sharee-list">
+		<SharingEntry v-for="share in shares"
+			:key="share.id"
+			:file-info="fileInfo"
+			:share="share"
+			@remove:share="removeShare" />
+	</ul>
+</template>
+
+<script>
+// eslint-disable-next-line no-unused-vars
+import Share from '../models/Share'
+import SharingEntry from '../components/SharingEntry'
+
+export default {
+	name: 'SharingList',
+
+	components: {
+		SharingEntry
+	},
+
+	props: {
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		},
+		shares: {
+			type: Array,
+			default: () => [],
+			required: true
+		}
+	},
+
+	computed: {
+		hasShares() {
+			return this.shares.length === 0
+		}
+	},
+
+	methods: {
+		/**
+		 * Remove a share from the shares list
+		 *
+		 * @param {Share} share the share to remove
+		 */
+		removeShare(share) {
+			const index = this.shares.findIndex(item => item === share)
+			this.shares.splice(index, 1)
+		}
+	}
+}
+</script>

+ 318 - 0
apps/files_sharing/src/views/SharingTab.vue

@@ -0,0 +1,318 @@
+<!--
+  - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<Tab :icon="icon" :name="name" :class="{ 'icon-loading': loading }">
+		<!-- error message -->
+		<div v-if="error" class="emptycontent">
+			<div class="icon icon-error" />
+			<h2>{{ error }}</h2>
+		</div>
+
+		<!-- shares content -->
+		<template v-else>
+			<!-- shared with me information -->
+			<SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare">
+				<template #avatar>
+					<Avatar #avatar
+						:user="sharedWithMe.user"
+						:display-name="sharedWithMe.displayName"
+						class="sharing-entry__avatar"
+						tooltip-message="" />
+				</template>
+			</SharingEntrySimple>
+
+			<!-- add new share input -->
+			<SharingInput v-if="!loading"
+				:can-reshare="canReshare"
+				:file-info="fileInfo"
+				:link-shares="linkShares"
+				:reshare="reshare"
+				:shares="shares"
+				@add:share="addShare" />
+
+			<!-- link shares list -->
+			<SharingLinkList v-if="!loading"
+				:can-reshare="canReshare"
+				:file-info="fileInfo"
+				:shares="linkShares" />
+
+			<!-- other shares list -->
+			<SharingList v-if="!loading"
+				:shares="shares"
+				:file-info="fileInfo" />
+
+			<!-- internal link copy -->
+			<SharingEntryInternal :file-info="fileInfo" />
+		</template>
+	</Tab>
+</template>
+
+<script>
+import { generateOcsUrl } from '@nextcloud/router'
+import Tab from 'nextcloud-vue/dist/Components/AppSidebarTab'
+import Avatar from 'nextcloud-vue/dist/Components/Avatar'
+import axios from '@nextcloud/axios'
+
+import { shareWithTitle } from '../utils/SharedWithMe'
+import Share from '../models/Share'
+import ShareTypes from '../mixins/ShareTypes'
+import SharingEntryInternal from '../components/SharingEntryInternal'
+import SharingEntrySimple from '../components/SharingEntrySimple'
+import SharingInput from '../components/SharingInput'
+
+import SharingLinkList from './SharingLinkList'
+import SharingList from './SharingList'
+
+export default {
+	name: 'SharingTab',
+
+	components: {
+		Avatar,
+		SharingEntryInternal,
+		SharingEntrySimple,
+		SharingInput,
+		SharingLinkList,
+		SharingList,
+		Tab
+	},
+
+	mixins: [ShareTypes],
+
+	props: {
+		fileInfo: {
+			type: Object,
+			default: () => {},
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			error: '',
+			expirationInterval: null,
+			icon: 'icon-share',
+			loading: true,
+			name: t('files_sharing', 'Sharing'),
+			// reshare Share object
+			reshare: null,
+			sharedWithMe: {},
+			shares: [],
+			linkShares: [],
+			sections: OCA.Sharing.ShareTabSections.getSections()
+		}
+	},
+
+	computed: {
+		/**
+		 * Needed to differenciate the tabs
+		 * pulled from the AppSidebarTab component
+		 *
+		 * @returns {string}
+		 */
+		id() {
+			return this.name.toLowerCase().replace(/ /g, '-')
+		},
+
+		/**
+		 * Returns the current active tab
+		 * needed because AppSidebarTab also uses $parent.activeTab
+		 *
+		 * @returns {string}
+		 */
+		activeTab() {
+			return this.$parent.activeTab
+		},
+
+		/**
+		 * Is this share shared with me?
+		 *
+		 * @returns {boolean}
+		 */
+		isSharedWithMe() {
+			return Object.keys(this.sharedWithMe).length > 0
+		},
+
+		canReshare() {
+			return !!(this.fileInfo.permissions & OC.PERMISSION_SHARE)
+				|| !!(this.reshare && this.reshare.hasSharePermission)
+		}
+	},
+
+	watch: {
+		fileInfo() {
+			this.resetState()
+			this.getShares()
+		}
+	},
+
+	beforeMount() {
+		this.getShares()
+	},
+
+	methods: {
+		/**
+		 * Get the existing shares infos
+		 */
+		async getShares() {
+			try {
+				this.loading = true
+
+				// init params
+				const shareUrl = generateOcsUrl('apps/files_sharing/api/v1', 2) + 'shares'
+				const format = 'json'
+				// TODO: replace with proper getFUllpath implementation of our own FileInfo model
+				const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+
+				// fetch shares
+				const fetchShares = axios.get(shareUrl, {
+					params: {
+						format,
+						path,
+						reshares: true
+					}
+				})
+				const fetchSharedWithMe = axios.get(shareUrl, {
+					params: {
+						format,
+						path,
+						shared_with_me: true
+					}
+				})
+
+				// wait for data
+				const [shares, sharedWithMe] = await Promise.all([fetchShares, fetchSharedWithMe])
+				this.loading = false
+
+				// process results
+				this.processSharedWithMe(sharedWithMe)
+				this.processShares(shares)
+			} catch (error) {
+				this.error = t('files_sharing', 'Unable to load the shares list')
+				this.loading = false
+				console.error('Error loading the shares list', error)
+			}
+		},
+
+		/**
+		 * Reset the current view to its default state
+		 */
+		resetState() {
+			clearInterval(this.expirationInterval)
+			this.loading = true
+			this.error = ''
+			this.sharedWithMe = {}
+			this.shares = []
+		},
+
+		/**
+		 * Update sharedWithMe.subtitle with the appropriate
+		 * expiration time left
+		 *
+		 * @param {Share} share the sharedWith Share object
+		 */
+		updateExpirationSubtitle(share) {
+			const expiration = moment(share.expireDate).unix()
+			this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', {
+				relativetime: OC.Util.relativeModifiedDate(expiration * 1000)
+			}))
+
+			// share have expired
+			if (moment().unix() > expiration) {
+				clearInterval(this.expirationInterval)
+				// TODO: clear ui if share is expired
+				this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'this share just expired.'))
+			}
+		},
+
+		/**
+		 * Process the current shares data
+		 * and init shares[]
+		 *
+		 * @param {Object} share the share ocs api request data
+		 * @param {Object} share.data the request data
+		 */
+		processShares({ data }) {
+			if (data.ocs && data.ocs.data && data.ocs.data.length > 0) {
+				// create Share objects and sort by newest
+				const shares = data.ocs.data
+					.map(share => new Share(share))
+					.sort((a, b) => b.createdTime - a.createdTime)
+
+				this.linkShares = shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL)
+				this.shares = shares.filter(share => share.type !== this.SHARE_TYPES.SHARE_TYPE_LINK && share.type !== this.SHARE_TYPES.SHARE_TYPE_EMAIL)
+			}
+		},
+
+		/**
+		 * Process the sharedWithMe share data
+		 * and init sharedWithMe
+		 *
+		 * @param {Object} share the share ocs api request data
+		 * @param {Object} share.data the request data
+		 */
+		processSharedWithMe({ data }) {
+			if (data.ocs && data.ocs.data && data.ocs.data[0]) {
+				const share = new Share(data)
+				const title = shareWithTitle(share)
+				const displayName = share.ownerDisplayName
+				const user = share.owner
+
+				this.sharedWithMe = {
+					displayName,
+					title,
+					user
+				}
+				this.reshare = share
+
+				// If we have an expiration date, use it as subtitle
+				// Refresh the status every 10s and clear if expired
+				if (share.expireDate && moment(share.expireDate).unix() > moment().unix()) {
+					// first update
+					this.updateExpirationSubtitle(share)
+					// interval update
+					this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share)
+				}
+			}
+		},
+
+		/**
+		 * Insert share at top of arrays
+		 *
+		 * @param {Share} share the share to insert
+		 */
+		addShare(share) {
+			// only catching share type MAIL as link shares are added differently
+			// meaning: not from the ShareInput
+			if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+				this.linkShares.unshift(share)
+			} else {
+				this.shares.unshift(share)
+			}
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+
+</style>

+ 1 - 0
apps/files_sharing/webpack.js

@@ -4,6 +4,7 @@ module.exports = {
 	entry: {
 		'additionalScripts': path.join(__dirname, 'src', 'additionalScripts.js'),
 		'files_sharing': path.join(__dirname, 'src', 'files_sharing.js'),
+		'files_sharing_tab': path.join(__dirname, 'src', 'files_sharing_tab.js'),
 		'collaboration': path.join(__dirname, 'src', 'collaborationresourceshandler.js'),
 	},
 	output: {

+ 7 - 0
core/js/files/client.js

@@ -323,6 +323,13 @@
 				data.isEncrypted = false;
 			}
 
+			var isFavouritedProp = props['{' + Client.NS_OWNCLOUD + '}favorite'];
+			if (!_.isUndefined(isFavouritedProp)) {
+				data.isFavourited = isFavouritedProp === '1';
+			} else {
+				data.isFavourited = false;
+			}
+
 			var contentType = props[Client.PROPERTY_GETCONTENTTYPE];
 			if (!_.isUndefined(contentType)) {
 				data.mimetype = contentType;

+ 19 - 0
core/src/Polyfill/closest.js

@@ -0,0 +1,19 @@
+// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
+
+if (!Element.prototype.matches) {
+	Element.prototype.matches
+		= Element.prototype.msMatchesSelector
+		|| Element.prototype.webkitMatchesSelector
+}
+
+if (!Element.prototype.closest) {
+	Element.prototype.closest = function(s) {
+		var el = this
+
+		do {
+			if (el.matches(s)) return el
+			el = el.parentElement || el.parentNode
+		} while (el !== null && el.nodeType === 1)
+		return null
+	}
+}

+ 2 - 1
core/src/Polyfill/index.js

@@ -1,4 +1,4 @@
-/*
+/**
  * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
  *
  * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
@@ -20,4 +20,5 @@
  */
 
 import './console'
+import './closest'
 import './windows-phone'

+ 2 - 2
core/src/main.js

@@ -1,4 +1,4 @@
-/*
+/**
  * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
  *
  * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
@@ -20,8 +20,8 @@
  */
 
 import $ from 'jquery'
-import '@babel/polyfill'
 import './Polyfill/index'
+import '@babel/polyfill'
 
 // If you remove the line below, tests won't pass
 // eslint-disable-next-line no-unused-vars

+ 1 - 1
core/src/views/Login.vue

@@ -27,7 +27,7 @@
 				<LoginForm
 					:username.sync="user"
 					:redirect-url="redirectUrl"
-					:directLogin="directLogin"
+					:direct-login="directLogin"
 					:messages="messages"
 					:errors="errors"
 					:throttle-delay="throttleDelay"

+ 38 - 2
package-lock.json

@@ -359,6 +359,16 @@
         "@babel/plugin-syntax-async-generators": "^7.2.0"
       }
     },
+    "@babel/plugin-proposal-class-properties": {
+      "version": "7.5.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz",
+      "integrity": "sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-create-class-features-plugin": "^7.5.5",
+        "@babel/helper-plugin-utils": "^7.0.0"
+      }
+    },
     "@babel/plugin-proposal-dynamic-import": {
       "version": "7.5.0",
       "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz",
@@ -2384,6 +2394,11 @@
       "integrity": "sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=",
       "dev": true
     },
+    "debounce": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz",
+      "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg=="
+    },
     "debug": {
       "version": "2.6.9",
       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -6085,8 +6100,7 @@
     "p-finally": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
-      "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
-      "dev": true
+      "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
     },
     "p-is-promise": {
       "version": "2.1.0",
@@ -6110,6 +6124,23 @@
         "p-limit": "^2.0.0"
       }
     },
+    "p-queue": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.2.0.tgz",
+      "integrity": "sha512-B2LXNONcyn/G6uz2UBFsGjmSa0e/br3jznlzhEyCXg56c7VhEpiT2pZxGOfv32Q3FSyugAdys9KGpsv3kV+Sbg==",
+      "requires": {
+        "eventemitter3": "^4.0.0",
+        "p-timeout": "^3.1.0"
+      }
+    },
+    "p-timeout": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+      "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+      "requires": {
+        "p-finally": "^1.0.0"
+      }
+    },
     "p-try": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -8184,6 +8215,11 @@
         }
       }
     },
+    "url-search-params-polyfill": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-7.0.0.tgz",
+      "integrity": "sha512-0SEH3s+wCNbxEE/rWUalN004ICNi23Q74Ksc0gS2kG8EXnbayxGOrV97JdwnIVPKZ75Xk0hvKXvtIC4xReLMgg=="
+    },
     "use": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

+ 9 - 1
package.json

@@ -25,6 +25,7 @@
   "dependencies": {
     "@babel/polyfill": "^7.6.0",
     "@chenfengyuan/vue-qrcode": "^1.0.1",
+    "@nextcloud/auth": "^0.3.1",
     "@nextcloud/axios": "^0.5.0",
     "@nextcloud/event-bus": "^0.2.1",
     "@nextcloud/initial-state": "^0.2.0",
@@ -37,6 +38,7 @@
     "clipboard": "^2.0.4",
     "css-vars-ponyfill": "^2.1.2",
     "davclient.js": "git+https://github.com/owncloud/davclient.js.git#0.2.1",
+    "debounce": "^1.2.0",
     "dompurify": "^2.0.7",
     "escape-html": "^1.0.3",
     "handlebars": "^4.4.5",
@@ -53,12 +55,14 @@
     "nextcloud-router": "0.0.9",
     "nextcloud-vue": "^0.12.7",
     "nextcloud-vue-collections": "^0.6.0",
+    "p-queue": "^6.1.0",
     "query-string": "^5.1.1",
     "select2": "3.5.1",
     "snap.js": "^2.0.9",
     "strengthify": "git+https://github.com/MorrisJobke/strengthify.git#0.5.8",
     "toastify-js": "^1.6.1",
     "underscore": "^1.9.1",
+    "url-search-params-polyfill": "^7.0.0",
     "v-tooltip": "^2.0.2",
     "vue": "^2.6.10",
     "vue-click-outside": "^1.0.7",
@@ -72,6 +76,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.6.4",
+    "@babel/plugin-proposal-class-properties": "^7.5.5",
     "@babel/plugin-syntax-dynamic-import": "^7.2.0",
     "@babel/preset-env": "^7.6.3",
     "@nextcloud/browserslist-config": "^1.0.0",
@@ -104,5 +109,8 @@
   },
   "browserslist": [
     "extends @nextcloud/browserslist-config"
-  ]
+  ],
+  "engines": {
+    "node": ">=10.0.0"
+  }
 }

+ 5 - 4
webpack.common.js

@@ -3,10 +3,10 @@ const path = require('path')
 const merge = require('webpack-merge')
 const { VueLoaderPlugin } = require('vue-loader')
 
-const core = require('./core/webpack')
-
 const accessibility = require('./apps/accessibility/webpack')
 const comments = require('./apps/comments/webpack')
+const core = require('./core/webpack')
+const files = require('./apps/files/webpack')
 const files_sharing = require('./apps/files_sharing/webpack')
 const files_trashbin = require('./apps/files_trashbin/webpack')
 const files_versions = require('./apps/files_versions/webpack')
@@ -18,14 +18,15 @@ const updatenotifications = require('./apps/updatenotification/webpack')
 const workflowengine = require('./apps/workflowengine/webpack')
 
 const modules = {
-	core,
-	settings,
 	accessibility,
 	comments,
+	core,
+	files,
 	files_sharing,
 	files_trashbin,
 	files_versions,
 	oauth2,
+	settings,
 	systemtags,
 	twofactor_backupscodes,
 	updatenotifications,