Skip to content

Commit 1160fc4

Browse files
committed
feat(files_sharing): Add file list filter to filter by owner / sharee
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent d0c9047 commit 1160fc4

5 files changed

Lines changed: 224 additions & 20 deletions

File tree

apps/files_sharing/src/actions/sharingStatusAction.ts

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,19 @@
44
*/
55
import { Node, View, registerFileAction, FileAction, Permission } from '@nextcloud/files'
66
import { translate as t } from '@nextcloud/l10n'
7-
import { Type } from '@nextcloud/sharing'
7+
import { ShareType } from '@nextcloud/sharing'
88

99
import AccountGroupSvg from '@mdi/svg/svg/account-group.svg?raw'
1010
import AccountPlusSvg from '@mdi/svg/svg/account-plus.svg?raw'
1111
import LinkSvg from '@mdi/svg/svg/link.svg?raw'
1212
import CircleSvg from '../../../../core/img/apps/circles.svg?raw'
1313

14-
import { action as sidebarAction } from '../../../files/src/actions/sidebarAction'
15-
import { generateUrl } from '@nextcloud/router'
1614
import { getCurrentUser } from '@nextcloud/auth'
15+
import { action as sidebarAction } from '../../../files/src/actions/sidebarAction'
16+
import { generateAvatarSvg } from '../utils/AccountIcon'
1717

1818
import './sharingStatusAction.scss'
1919

20-
const isDarkMode = window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true
21-
|| document.querySelector('[data-themes*=dark]') !== null
22-
23-
const generateAvatarSvg = (userId: string, isGuest = false) => {
24-
const url = isDarkMode ? '/avatar/{userId}/32/dark' : '/avatar/{userId}/32'
25-
const avatarUrl = generateUrl(isGuest ? url : url + '?guestFallback=true', { userId })
26-
return `<svg width="32" height="32" viewBox="0 0 32 32"
27-
xmlns="http://www.w3.org/2000/svg" class="sharing-status__avatar">
28-
<image href="${avatarUrl}" height="32" width="32" />
29-
</svg>`
30-
}
31-
3220
const isExternal = (node: Node) => {
3321
return node.attributes.remote_id !== undefined
3422
}
@@ -75,19 +63,19 @@ export const action = new FileAction({
7563
}
7664

7765
// Link shares
78-
if (shareTypes.includes(Type.SHARE_TYPE_LINK)
79-
|| shareTypes.includes(Type.SHARE_TYPE_EMAIL)) {
66+
if (shareTypes.includes(ShareType.Link)
67+
|| shareTypes.includes(ShareType.Email)) {
8068
return LinkSvg
8169
}
8270

8371
// Group shares
84-
if (shareTypes.includes(Type.SHARE_TYPE_GROUP)
85-
|| shareTypes.includes(Type.SHARE_TYPE_REMOTE_GROUP)) {
72+
if (shareTypes.includes(ShareType.Grup)
73+
|| shareTypes.includes(ShareType.RemoteGroup)) {
8674
return AccountGroupSvg
8775
}
8876

8977
// Circle shares
90-
if (shareTypes.includes(Type.SHARE_TYPE_CIRCLE)) {
78+
if (shareTypes.includes(ShareType.Team)) {
9179
return CircleSvg
9280
}
9381

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
<template>
6+
<NcSelect v-model="selectedAccounts"
7+
:aria-label-combobox="t('files_sharing', 'Accounts')"
8+
class="file-list-filter-accounts"
9+
multiple
10+
no-wrap
11+
:options="availableAccounts"
12+
:placeholder="t('files_sharing', 'Accounts')"
13+
user-select />
14+
</template>
15+
16+
<script setup lang="ts">
17+
import type { IAccountData } from '../filters/AccountFilter.ts'
18+
19+
import { translate as t } from '@nextcloud/l10n'
20+
import { useBrowserLocation } from '@vueuse/core'
21+
import { ref, watch, watchEffect } from 'vue'
22+
import { useNavigation } from '../../../files/src/composables/useNavigation.ts'
23+
24+
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
25+
26+
interface IUserSelectData {
27+
id: string
28+
user: string
29+
displayName: string
30+
}
31+
32+
const emit = defineEmits<{
33+
(event: 'update:accounts', value: IAccountData[]): void
34+
}>()
35+
36+
const { currentView } = useNavigation()
37+
const currentLocation = useBrowserLocation()
38+
const availableAccounts = ref<IUserSelectData[]>([])
39+
const selectedAccounts = ref<IUserSelectData[]>([])
40+
41+
// Watch selected account, on change we emit the new account data to the filter instance
42+
watch(selectedAccounts, () => {
43+
// Emit selected accounts as account data
44+
const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName }))
45+
emit('update:accounts', accounts)
46+
})
47+
48+
/**
49+
* Update the accounts owning nodes or have nodes shared to them
50+
* @param path The path inside the current view to load for accounts
51+
*/
52+
async function updateAvailableAccounts(path: string = '/') {
53+
availableAccounts.value = []
54+
if (!currentView.value) {
55+
return
56+
}
57+
58+
const { contents } = await currentView.value.getContents(path)
59+
const available = new Map<string, IUserSelectData>()
60+
for (const node of contents) {
61+
const owner = node.owner ?? node.attributes['owner-id']
62+
if (owner && !available.has(owner)) {
63+
available.set(owner, {
64+
id: owner,
65+
user: owner,
66+
displayName: node.attributes['owner-display-name'] ?? node.owner,
67+
})
68+
}
69+
70+
const sharees = node.attributes.sharees?.sharee
71+
if (sharees) {
72+
// ensure sharees is an array (if only one share then it is just an object)
73+
for (const sharee of [sharees].flat()) {
74+
// Skip link shares and other without user
75+
if (sharee.id === '') {
76+
continue
77+
}
78+
// Add if not already added
79+
if (!available.has(sharee.id)) {
80+
available.set(sharee.id, {
81+
id: sharee.id,
82+
user: sharee.id,
83+
displayName: sharee['display-name'],
84+
})
85+
}
86+
}
87+
}
88+
}
89+
availableAccounts.value = [...available.values()]
90+
}
91+
92+
/**
93+
* Reset this filter
94+
*/
95+
function resetFilter() {
96+
selectedAccounts.value = []
97+
}
98+
defineExpose({ resetFilter })
99+
100+
// When the current view changes or the current directory,
101+
// then we need to rebuild the available accounts
102+
watchEffect(() => {
103+
if (currentView.value) {
104+
// we have no access to the files router here...
105+
const path = (currentLocation.value.search ?? '?dir=/').match(/(?<=&|\?)dir=([^&#]+)/)?.[1]
106+
selectedAccounts.value = []
107+
updateAvailableAccounts(decodeURIComponent(path ?? '/'))
108+
}
109+
})
110+
</script>
111+
112+
<style scoped lang="scss">
113+
.file-list-filter-accounts {
114+
max-width: 300px;
115+
}
116+
</style>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import type { INode } from '@nextcloud/files'
6+
7+
import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
8+
import Vue from 'vue'
9+
import FileListFilterAccount from '../components/FileListFilterAccount.vue'
10+
11+
export interface IAccountData {
12+
uid: string
13+
displayName: string
14+
}
15+
16+
/**
17+
* File list filter to filter by owner / sharee
18+
*/
19+
class AccountFilter extends FileListFilter {
20+
21+
private currentInstance?: Vue
22+
private filterAccounts?: IAccountData[]
23+
24+
constructor() {
25+
super('files_sharing:account', 100)
26+
}
27+
28+
public mount(el: HTMLElement) {
29+
if (this.currentInstance) {
30+
this.currentInstance.$destroy()
31+
}
32+
33+
const View = Vue.extend(FileListFilterAccount as never)
34+
this.currentInstance = new View({
35+
el,
36+
})
37+
.$on('update:accounts', this.setAccounts.bind(this))
38+
.$mount()
39+
}
40+
41+
public filter(nodes: INode[]): INode[] {
42+
if (!this.filterAccounts || this.filterAccounts.length === 0) {
43+
return nodes
44+
}
45+
46+
const userIds = this.filterAccounts.map(({ uid }) => uid)
47+
// Filter if the owner of the node is in the list of filtered accounts
48+
return nodes.filter((node) => {
49+
const sharees = node.attributes.sharees?.sharee as { id: string }[] | undefined
50+
// If the node provides no information lets keep it
51+
if (!node.owner && !sharees) {
52+
return true
53+
}
54+
// if the owner matches
55+
if (node.owner && userIds.includes(node.owner)) {
56+
return true
57+
}
58+
// Or any of the sharees (if only one share this will be an object, otherwise an array. So using `.flat()` to make it always an array)
59+
if (sharees && [sharees].flat().some(({ id }) => userIds.includes(id))) {
60+
return true
61+
}
62+
// Not a valid node for the current filter
63+
return false
64+
})
65+
}
66+
67+
public setAccounts(accounts?: IAccountData[]) {
68+
this.filterAccounts = accounts
69+
this.filterUpdated()
70+
}
71+
72+
}
73+
74+
/**
75+
* Register the file list filter by owner or sharees
76+
*/
77+
export function registerAccountFilter() {
78+
registerFileListFilter(new AccountFilter())
79+
}

apps/files_sharing/src/init.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import './actions/openInFilesAction'
1111
import './actions/rejectShareAction'
1212
import './actions/restoreShareAction'
1313
import './actions/sharingStatusAction'
14+
import { registerAccountFilter } from './filters/AccountFilter'
1415

1516
registerSharingViews()
1617

1718
addNewFileMenuEntry(newFileRequest)
1819

20+
registerDavProperty('nc:sharees', { nc: 'http://nextcloud.org/ns' })
1921
registerDavProperty('nc:share-attributes', { nc: 'http://nextcloud.org/ns' })
2022
registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' })
2123
registerDavProperty('ocs:share-permissions', { ocs: 'http://open-collaboration-services.org/ns' })
24+
25+
registerAccountFilter()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import { generateUrl } from '@nextcloud/router'
6+
7+
const isDarkMode = window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true
8+
|| document.querySelector('[data-themes*=dark]') !== null
9+
10+
export const generateAvatarSvg = (userId: string, isGuest = false) => {
11+
const url = isDarkMode ? '/avatar/{userId}/32/dark' : '/avatar/{userId}/32'
12+
const avatarUrl = generateUrl(isGuest ? url : url + '?guestFallback=true', { userId })
13+
return `<svg width="32" height="32" viewBox="0 0 32 32"
14+
xmlns="http://www.w3.org/2000/svg" class="sharing-status__avatar">
15+
<image href="${avatarUrl}" height="32" width="32" />
16+
</svg>`
17+
}

0 commit comments

Comments
 (0)