diff options
Diffstat (limited to 'apps/files_sharing/src/components/SharingInput.vue')
-rw-r--r-- | apps/files_sharing/src/components/SharingInput.vue | 427 |
1 files changed, 227 insertions, 200 deletions
diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue index 6ca2299b81c..6fb33aba6b2 100644 --- a/apps/files_sharing/src/components/SharingInput.vue +++ b/apps/files_sharing/src/components/SharingInput.vue @@ -1,71 +1,57 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <Multiselect ref="multiselect" - class="sharing-input" - :clear-on-select="false" - :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" - open-direction="below" - @search-change="asyncFind" - @select="addShare"> - <template #noOptions> - {{ t('files_sharing', 'No recommendations. Start typing.') }} - </template> - <template #noResult> - {{ noResultText }} - </template> - </Multiselect> + <div class="sharing-search"> + <label class="hidden-visually" :for="shareInputId"> + {{ isExternal ? t('files_sharing', 'Enter external recipients') + : t('files_sharing', 'Search for internal recipients') }} + </label> + <NcSelect ref="select" + v-model="value" + :input-id="shareInputId" + class="sharing-search__input" + :disabled="!canReshare" + :loading="loading" + :filterable="false" + :placeholder="inputPlaceholder" + :clear-search-on-blur="() => false" + :user-select="true" + :options="options" + :label-outside="true" + @search="asyncFind" + @option:selected="onSelected"> + <template #no-options="{ search }"> + {{ search ? noResultText : placeholder }} + </template> + </NcSelect> + </div> </template> <script> import { generateOcsUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' import axios from '@nextcloud/axios' import debounce from 'debounce' -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' +import NcSelect from '@nextcloud/vue/components/NcSelect' -import Config from '../services/ConfigService' -import Share from '../models/Share' -import ShareRequests from '../mixins/ShareRequests' -import ShareTypes from '../mixins/ShareTypes' +import Config from '../services/ConfigService.ts' +import Share from '../models/Share.ts' +import ShareRequests from '../mixins/ShareRequests.js' +import ShareDetails from '../mixins/ShareDetails.js' +import { ShareType } from '@nextcloud/sharing' export default { name: 'SharingInput', components: { - Multiselect, + NcSelect, }, - mixins: [ShareTypes, ShareRequests], + mixins: [ShareRequests, ShareDetails], props: { shares: { @@ -91,6 +77,20 @@ export default { type: Boolean, required: true, }, + isExternal: { + type: Boolean, + default: false, + }, + placeholder: { + type: String, + default: '', + }, + }, + + setup() { + return { + shareInputId: `share-input-${Math.random().toString(36).slice(2, 7)}`, + } }, data() { @@ -101,6 +101,7 @@ export default { recommendations: [], ShareSearch: OCA.Sharing.ShareSearch.state, suggestions: [], + value: null, } }, @@ -111,7 +112,7 @@ export default { * results into the autocomplete dropdown * Used for the guests app * - * @returns {Array} + * @return {Array} */ externalResults() { return this.ShareSearch.results @@ -122,6 +123,10 @@ export default { if (!this.canReshare) { return t('files_sharing', 'Resharing is not allowed') } + if (this.placeholder) { + return this.placeholder + } + // We can always search with email addresses for users too if (!allowRemoteSharing) { return t('files_sharing', 'Name or email …') @@ -150,11 +155,19 @@ export default { }, mounted() { - this.getRecommendations() + if (!this.isExternal) { + // We can only recommend users, groups etc for internal shares + this.getRecommendations() + } }, methods: { - async asyncFind(query, id) { + onSelected(option) { + this.value = null // Reset selected option + this.openSharingDetails(option) + }, + + async asyncFind(query) { // save current query to check if we display // recommendations or search results this.query = query.trim() @@ -170,53 +183,69 @@ export default { * Get suggestions * * @param {string} search the search query - * @param {boolean} [lookup=false] search on lookup server + * @param {boolean} [lookup] search on lookup server */ async getSuggestions(search, lookup = false) { this.loading = true - if (OC.getCapabilities().files_sharing.sharee.query_lookup_default === true) { + if (getCapabilities().files_sharing.sharee.query_lookup_default === true) { lookup = true } - const shareType = [ - this.SHARE_TYPES.SHARE_TYPE_USER, - this.SHARE_TYPES.SHARE_TYPE_GROUP, - this.SHARE_TYPES.SHARE_TYPE_REMOTE, - this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP, - this.SHARE_TYPES.SHARE_TYPE_CIRCLE, - this.SHARE_TYPES.SHARE_TYPE_ROOM, - this.SHARE_TYPES.SHARE_TYPE_GUEST, - this.SHARE_TYPES.SHARE_TYPE_DECK, - ] - - if (OC.getCapabilities().files_sharing.public.enabled === true) { - shareType.push(this.SHARE_TYPES.SHARE_TYPE_EMAIL) + const remoteTypes = [ShareType.Remote, ShareType.RemoteGroup] + const shareType = [] + + const showFederatedAsInternal = this.config.showFederatedSharesAsInternal + || this.config.showFederatedSharesToTrustedServersAsInternal + + // For internal users, add remote types if config says to show them as internal + const shouldAddRemoteTypes = (!this.isExternal && showFederatedAsInternal) + // For external users, add them if config *doesn't* say to show them as internal + || (this.isExternal && !showFederatedAsInternal) + // Edge case: federated-to-trusted is a separate "add" trigger for external users + || (this.isExternal && this.config.showFederatedSharesToTrustedServersAsInternal) + + if (this.isExternal) { + if (getCapabilities().files_sharing.public.enabled === true) { + shareType.push(ShareType.Email) + } + } else { + shareType.push( + ShareType.User, + ShareType.Group, + ShareType.Team, + ShareType.Room, + ShareType.Guest, + ShareType.Deck, + ShareType.ScienceMesh, + ) } - 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, - shareType, - }, - }) + if (shouldAddRemoteTypes) { + shareType.push(...remoteTypes) + } - if (request.data.ocs.meta.statuscode !== 100) { - console.error('Error fetching suggestions', request) + let request = null + try { + 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, + shareType, + }, + }) + } catch (error) { + console.error('Error fetching suggestions', error) return } - const data = request.data.ocs.data - const exact = request.data.ocs.data.exact - data.exact = [] // removing exact from general results - + const { exact, ...data } = request.data.ocs.data // 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), []) + const rawExactSuggestions = Object.values(exact).flat() + const rawSuggestions = Object.values(data).flat() // remove invalid data and format to user-select layout const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions) @@ -233,8 +262,9 @@ export default { const lookupEntry = [] if (data.lookupEnabled && !lookup) { lookupEntry.push({ + id: 'global-lookup', isNoUser: true, - displayName: t('files_sharing', 'Search globally'), + displayName: t('files_sharing', 'Search everywhere'), lookup: true, }) } @@ -244,7 +274,7 @@ export default { const allSuggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry) - // Count occurances of display names in order to provide a distinguishable description if needed + // Count occurrences of display names in order to provide a distinguishable description if needed const nameCounts = allSuggestions.reduce((nameCounts, result) => { if (!result.displayName) { return nameCounts @@ -283,24 +313,25 @@ export default { 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) + let request = null + try { + request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), { + params: { + format: 'json', + itemType: this.fileInfo.type, + }, + }) + } catch (error) { + console.error('Error fetching recommendations', error) return } + // Add external results from the OCA.Sharing.ShareSearch api const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this)) - const exact = request.data.ocs.data.exact - // flatten array of arrays - const rawRecommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), []) + const rawRecommendations = Object.values(request.data.ocs.data.exact) + .reduce((arr, elem) => arr.concat(elem), []) // remove invalid data and format to user-select layout this.recommendations = this.filterOutExistingShares(rawRecommendations) @@ -315,8 +346,8 @@ export default { * Filter out existing shares from * the provided shares search results * - * @param {Object[]} shares the array of shares object - * @returns {Object[]} + * @param {object[]} shares the array of shares object + * @return {object[]} */ filterOutExistingShares(shares) { return shares.reduce((arr, share) => { @@ -325,7 +356,7 @@ export default { return arr } try { - if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER) { + if (share.value.shareType === ShareType.User) { // filter out current user if (share.value.shareWith === getCurrentUser().uid) { return arr @@ -338,7 +369,12 @@ export default { } // filter out existing mail shares - if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + if (share.value.shareType === ShareType.Email) { + // When sharing internally, we don't want to suggest email addresses + // that the user previously created shares to + if (!this.isExternal) { + return arr + } const emails = this.linkShares.map(elem => elem.shareWith) if (emails.indexOf(share.value.shareWith.trim()) !== -1) { return arr @@ -370,110 +406,91 @@ export default { /** * Get the icon based on the share type + * * @param {number} type the share type - * @returns {string} the icon class + * @return {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 + case ShareType.Guest: + // default is a user, other icons are here to differentiate // 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' - case this.SHARE_TYPES.SHARE_TYPE_DECK: - return 'icon-deck' - + // case ShareType.Remote: + // case ShareType.User: + return { + icon: 'icon-user', + iconTitle: t('files_sharing', 'Guest'), + } + case ShareType.RemoteGroup: + case ShareType.Group: + return { + icon: 'icon-group', + iconTitle: t('files_sharing', 'Group'), + } + case ShareType.Email: + return { + icon: 'icon-mail', + iconTitle: t('files_sharing', 'Email'), + } + case ShareType.Team: + return { + icon: 'icon-teams', + iconTitle: t('files_sharing', 'Team'), + } + case ShareType.Room: + return { + icon: 'icon-room', + iconTitle: t('files_sharing', 'Talk conversation'), + } + case ShareType.Deck: + return { + icon: 'icon-deck', + iconTitle: t('files_sharing', 'Deck board'), + } + case ShareType.Sciencemesh: + return { + icon: 'icon-sciencemesh', + iconTitle: t('files_sharing', 'ScienceMesh'), + } default: - return '' + return {} } }, /** * Format shares for the multiselect options - * @param {Object} result select entry item - * @returns {Object} + * + * @param {object} result select entry item + * @return {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 + let subname + let displayName = result.name || result.label + + if (result.value.shareType === ShareType.User && this.config.shouldAlwaysShowUnique) { + subname = result.shareWithDisplayNameUnique ?? '' + } else if (result.value.shareType === ShareType.Email) { + subname = result.value.shareWith + } else if (result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup) { + if (this.config.showFederatedSharesAsInternal) { + subname = result.extra?.email?.value ?? '' + displayName = result.extra?.name?.value ?? displayName + } else if (result.value.server) { + subname = t('files_sharing', 'on {server}', { server: result.value.server }) + } } else { - desc = result.shareWithDescription ?? '' + subname = result.shareWithDescription ?? '' } return { shareWith: result.value.shareWith, shareType: result.value.shareType, user: result.uuid || result.value.shareWith, - isNoUser: result.value.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER, - displayName: result.name || result.label, - desc, + isNoUser: result.value.shareType !== ShareType.User, + displayName, + subname, shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '', - icon: this.shareTypeToIcon(result.value.shareType), - } - }, - - /** - * Process the new share request - * @param {Object} value the multiselect option - */ - async addShare(value) { - if (value.lookup) { - await this.getSuggestions(this.query, true) - - // focus the input again - this.$nextTick(() => { - this.$refs.multiselect.$el.querySelector('.multiselect__input').focus() - }) - return true - } - - // TODO: reset the search string when done - // https://github.com/shentao/vue-multiselect/issues/633 - - // 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, - permissions: this.fileInfo.sharePermissions & OC.getCapabilities().files_sharing.default_permissions, - }) - 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 + ...this.shareTypeToIcon(result.value.shareType), } }, }, @@ -481,21 +498,31 @@ export default { </script> <style lang="scss"> -.sharing-input { - width: 100%; - margin: 10px 0; +.sharing-search { + display: flex; + flex-direction: column; + margin-bottom: 4px; + + label[for="sharing-search-input"] { + margin-bottom: 2px; + } + &__input { + width: 100%; + margin: 10px 0; + } +} + +.vs__dropdown-menu { // 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; - } + span[lookup] { + .avatardiv { + background-image: var(--icon-search-white); + background-repeat: no-repeat; + background-position: center; + background-color: var(--color-text-maxcontrast) !important; + .avatardiv__initials-wrapper { + display: none; } } } |