|
|
<!--
|
|
|
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
|
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
-->
|
|
|
|
|
|
<template>
|
|
|
<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 { getCurrentUser } from '@nextcloud/auth'
|
|
|
import axios from '@nextcloud/axios'
|
|
|
import { getCapabilities } from '@nextcloud/capabilities'
|
|
|
import { generateOcsUrl } from '@nextcloud/router'
|
|
|
import { ShareType } from '@nextcloud/sharing'
|
|
|
import debounce from 'debounce'
|
|
|
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
|
|
import ShareDetails from '../mixins/ShareDetails.js'
|
|
|
import ShareRequests from '../mixins/ShareRequests.js'
|
|
|
import Share from '../models/Share.ts'
|
|
|
import Config from '../services/ConfigService.ts'
|
|
|
import logger from '../services/logger.ts'
|
|
|
|
|
|
export default {
|
|
|
name: 'SharingInput',
|
|
|
|
|
|
components: {
|
|
|
NcSelect,
|
|
|
},
|
|
|
|
|
|
mixins: [ShareRequests, ShareDetails],
|
|
|
|
|
|
props: {
|
|
|
shares: {
|
|
|
type: Array,
|
|
|
required: true,
|
|
|
},
|
|
|
|
|
|
linkShares: {
|
|
|
type: Array,
|
|
|
required: true,
|
|
|
},
|
|
|
|
|
|
fileInfo: {
|
|
|
type: Object,
|
|
|
required: true,
|
|
|
},
|
|
|
|
|
|
reshare: {
|
|
|
type: Share,
|
|
|
default: null,
|
|
|
},
|
|
|
|
|
|
canReshare: {
|
|
|
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() {
|
|
|
return {
|
|
|
config: new Config(),
|
|
|
loading: false,
|
|
|
query: '',
|
|
|
recommendations: [],
|
|
|
ShareSearch: OCA.Sharing.ShareSearch.state,
|
|
|
suggestions: [],
|
|
|
value: null,
|
|
|
}
|
|
|
},
|
|
|
|
|
|
computed: {
|
|
|
/**
|
|
|
* Implement ShareSearch
|
|
|
* allows external appas to inject new
|
|
|
* results into the autocomplete dropdown
|
|
|
* Used for the guests app
|
|
|
*
|
|
|
* @return {Array}
|
|
|
*/
|
|
|
externalResults() {
|
|
|
return this.ShareSearch.results
|
|
|
},
|
|
|
|
|
|
inputPlaceholder() {
|
|
|
const allowRemoteSharing = this.config.isRemoteShareAllowed
|
|
|
|
|
|
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 …')
|
|
|
}
|
|
|
|
|
|
return t('files_sharing', 'Name, email, or Federated Cloud ID …')
|
|
|
},
|
|
|
|
|
|
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() {
|
|
|
if (!this.isExternal) {
|
|
|
// We can only recommend users, groups etc for internal shares
|
|
|
this.getRecommendations()
|
|
|
}
|
|
|
},
|
|
|
|
|
|
methods: {
|
|
|
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()
|
|
|
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] search on lookup server
|
|
|
*/
|
|
|
async getSuggestions(search, lookup = false) {
|
|
|
this.loading = true
|
|
|
|
|
|
if (getCapabilities().files_sharing.sharee.query_lookup_default === true) {
|
|
|
lookup = true
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
)
|
|
|
}
|
|
|
|
|
|
if (shouldAddRemoteTypes) {
|
|
|
shareType.push(...remoteTypes)
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
logger.error('Error fetching suggestions', { error })
|
|
|
return
|
|
|
}
|
|
|
|
|
|
const { exact, ...data } = request.data.ocs.data
|
|
|
// flatten array of arrays
|
|
|
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)
|
|
|
.filter((result) => this.filterByTrustedServer(result))
|
|
|
.map((share) => this.formatForMultiselect(share))
|
|
|
// sort by type so we can get user&groups first...
|
|
|
.sort((a, b) => a.shareType - b.shareType)
|
|
|
const suggestions = this.filterOutExistingShares(rawSuggestions)
|
|
|
.filter((result) => this.filterByTrustedServer(result))
|
|
|
.map((share) => this.formatForMultiselect(share))
|
|
|
// sort by type so we can get user&groups first...
|
|
|
.sort((a, b) => a.shareType - b.shareType)
|
|
|
|
|
|
// lookup clickable entry
|
|
|
// show if enabled and not already requested
|
|
|
const lookupEntry = []
|
|
|
if (data.lookupEnabled && !lookup) {
|
|
|
lookupEntry.push({
|
|
|
id: 'global-lookup',
|
|
|
isNoUser: true,
|
|
|
displayName: t('files_sharing', 'Search everywhere'),
|
|
|
lookup: true,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// if there is a condition specified, filter it
|
|
|
const externalResults = this.externalResults.filter((result) => !result.condition || result.condition(this))
|
|
|
|
|
|
const allSuggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry)
|
|
|
|
|
|
// 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
|
|
|
}
|
|
|
if (!nameCounts[result.displayName]) {
|
|
|
nameCounts[result.displayName] = 0
|
|
|
}
|
|
|
nameCounts[result.displayName]++
|
|
|
return nameCounts
|
|
|
}, {})
|
|
|
|
|
|
this.suggestions = allSuggestions.map((item) => {
|
|
|
// Make sure that items with duplicate displayName get the shareWith applied as a description
|
|
|
if (nameCounts[item.displayName] > 1 && !item.desc) {
|
|
|
return { ...item, desc: item.shareWithDisplayNameUnique }
|
|
|
}
|
|
|
return item
|
|
|
})
|
|
|
|
|
|
this.loading = false
|
|
|
logger.debug('sharing suggestions', { 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
|
|
|
|
|
|
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) {
|
|
|
logger.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))
|
|
|
|
|
|
// flatten array of arrays
|
|
|
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)
|
|
|
.filter((result) => this.filterByTrustedServer(result))
|
|
|
.map((share) => this.formatForMultiselect(share))
|
|
|
.concat(externalResults)
|
|
|
|
|
|
this.loading = false
|
|
|
logger.debug('sharing recommendations', { recommendations: this.recommendations })
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
* Filter out existing shares from
|
|
|
* the provided shares search results
|
|
|
*
|
|
|
* @param {object[]} shares the array of shares object
|
|
|
* @return {object[]}
|
|
|
*/
|
|
|
filterOutExistingShares(shares) {
|
|
|
return shares.reduce((arr, share) => {
|
|
|
// only check proper objects
|
|
|
if (typeof share !== 'object') {
|
|
|
return arr
|
|
|
}
|
|
|
try {
|
|
|
if (share.value.shareType === ShareType.User) {
|
|
|
// 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 === 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
|
|
|
}
|
|
|
} 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
|
|
|
* @return {string} the icon class
|
|
|
*/
|
|
|
shareTypeToIcon(type) {
|
|
|
switch (type) {
|
|
|
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 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 {}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
* Filter suggestion results based on trusted server configuration
|
|
|
*
|
|
|
* @param {object} result The raw suggestion result from API
|
|
|
* @return {boolean} Whether to include this result in suggestions
|
|
|
*/
|
|
|
filterByTrustedServer(result) {
|
|
|
const isRemoteEntity = result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup
|
|
|
if (isRemoteEntity && this.config.showFederatedSharesToTrustedServersAsInternal && !this.isExternal) {
|
|
|
return result.value.isTrustedServer === true
|
|
|
}
|
|
|
return true
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
* Format shares for the multiselect options
|
|
|
*
|
|
|
* @param {object} result select entry item
|
|
|
* @return {object}
|
|
|
*/
|
|
|
formatForMultiselect(result) {
|
|
|
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 {
|
|
|
subname = result.shareWithDescription ?? ''
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
shareWith: result.value.shareWith,
|
|
|
shareType: result.value.shareType,
|
|
|
user: result.uuid || result.value.shareWith,
|
|
|
isNoUser: result.value.shareType !== ShareType.User,
|
|
|
displayName,
|
|
|
subname,
|
|
|
shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '',
|
|
|
...this.shareTypeToIcon(result.value.shareType),
|
|
|
}
|
|
|
},
|
|
|
},
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss">
|
|
|
.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
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</style>
|