Welcome to mirror list, hosted at ThFree Co, Russian Federation.

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