Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/components/FileCard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ describe('FileCard', () => {
const withSubname = mount(FileCard, { slots: { subname: 'a subname' } })
expect(withSubname.find('.file-card__subname').text()).toBe('a subname')
})

it('only renders the overlay slot when provided', () => {
const without = mount(FileCard)
expect(without.find('.file-card__overlay').exists()).toBe(false)

const withOverlay = mount(FileCard, { slots: { overlay: 'a badge' } })
expect(withOverlay.find('.file-card__overlay').text()).toBe('a badge')
})
})
20 changes: 20 additions & 0 deletions src/components/FileCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ defineEmits<{ click: [event: MouseEvent] }>()
<button type="button" class="file-card" @click="$emit('click', $event)">
<div class="file-card__preview">
<slot name="preview" />
<span v-if="$slots.overlay" class="file-card__overlay">
<slot name="overlay" />
</span>
</div>
<div class="file-card__content">
<span v-if="$slots.icon" class="file-card__icon">
Expand Down Expand Up @@ -50,6 +53,7 @@ defineEmits<{ click: [event: MouseEvent] }>()
}

.file-card__preview {
position: relative;
flex: 1;
min-height: 0;
display: flex;
Expand All @@ -58,6 +62,22 @@ defineEmits<{ click: [event: MouseEvent] }>()
margin-bottom: calc(var(--default-grid-baseline) * 1);
}

/* Badge area over the thumbnail (e.g. the share indicator). The chip keeps the
icon legible over any preview; the shadow colour is a theme token so it works
in dark mode. */
.file-card__overlay {
position: absolute;
inset-block-start: calc(var(--default-grid-baseline) * 1);
inset-inline-end: calc(var(--default-grid-baseline) * 1);
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border-radius: var(--border-radius);
background-color: var(--color-main-background);
box-shadow: 0 1px 2px rgba(var(--color-box-shadow-rgb), 0.4);
}

.file-card__content {
flex-shrink: 0;
display: flex;
Expand Down
51 changes: 51 additions & 0 deletions src/components/ShareIndicator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { makeNode } from '../test-utils/fixtures.ts'
import ShareIndicator from './ShareIndicator.vue'

describe('ShareIndicator', () => {
it('renders nothing for an unshared file the current user owns', () => {
const wrapper = mount(ShareIndicator, {
props: { file: makeNode({ owner: 'alice' }), currentUid: 'alice' },
})

expect(wrapper.find('.share-indicator').exists()).toBe(false)
})

it('labels an outgoing share "Shared"', () => {
const wrapper = mount(ShareIndicator, {
props: { file: makeNode({ owner: 'alice', shareTypes: [0] }), currentUid: 'alice' },
})

const indicator = wrapper.find('.share-indicator')
expect(indicator.exists()).toBe(true)
expect(indicator.attributes('aria-label')).toBe('Shared')
// The label is also the accessible name and the tooltip.
expect(indicator.attributes('title')).toBe('Shared')
expect(indicator.attributes('role')).toBe('img')
})

it('names the owner for an incoming share', () => {
const file = makeNode({ owner: 'bob' })
file.attributes!['owner-display-name'] = 'Bob'

const wrapper = mount(ShareIndicator, {
props: { file, currentUid: 'alice' },
})

expect(wrapper.find('.share-indicator').attributes('aria-label')).toBe('Shared by Bob')
})

it('falls back to "Shared" for an incoming share with no owner display name', () => {
const wrapper = mount(ShareIndicator, {
props: { file: makeNode({ owner: 'bob' }), currentUid: 'alice' },
})

expect(wrapper.find('.share-indicator').attributes('aria-label')).toBe('Shared')
})
})
53 changes: 53 additions & 0 deletions src/components/ShareIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import { computed } from 'vue'
import { translate as t } from '@nextcloud/l10n'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { mdiShareVariant } from '@mdi/js'
import type { Node } from '@nextcloud/files'
import { isIncomingShare, isShared } from '../utils/fileSharing.ts'

const props = withDefaults(defineProps<{
file: Node
currentUid: string | null
size?: number
}>(), {
size: 16,
})

const shared = computed(() => isShared(props.file, props.currentUid))

// Incoming shares name the owner; outgoing ones just read "Shared". The label is
// both the tooltip and the accessible name — the icon alone must never carry the
// meaning (accessibility) or the colour (it is only a hint on top of the icon).
const label = computed(() => {
if (!isIncomingShare(props.file, props.currentUid)) {
return t('office', 'Shared')
}
const owner = props.file.attributes?.['owner-display-name'] as string | undefined
return owner
? t('office', 'Shared by {owner}', { owner })
: t('office', 'Shared')
})
</script>

<template>
<span v-if="shared"
class="share-indicator"
role="img"
:aria-label="label"
:title="label">
<NcIconSvgWrapper :path="mdiShareVariant" :size="size" />
</span>
</template>

<style scoped>
.share-indicator {
display: inline-flex;
color: var(--color-primary-element);
}
</style>
9 changes: 9 additions & 0 deletions src/services/officeFiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@ import { makeNode } from '../test-utils/fixtures.ts'

const searchMock = vi.fn()

const registerDavPropertyMock = vi.fn()

vi.mock('@nextcloud/files/dav', () => ({
getClient: vi.fn(() => ({ search: searchMock })),
getDavNameSpaces: vi.fn(() => 'xmlns:d="DAV:"'),
getDavProperties: vi.fn(() => '<d:getcontenttype/>'),
getRootPath: vi.fn(() => '/remote.php/dav/files/alice'),
registerDavProperty: registerDavPropertyMock,
resultToNode: vi.fn((item: unknown) => item),
}))

const { getAllOfficeFiles, invalidateOfficeFilesCache, filterByMimes, SEARCH_RESULT_LIMIT } = await import('./officeFiles.ts')

describe('module load', () => {
it('registers the oc:share-types DAV property so sharing state is fetched', () => {
expect(registerDavPropertyMock).toHaveBeenCalledWith('oc:share-types', { oc: 'http://owncloud.org/ns' })
})
})

describe('filterByMimes', () => {
it('keeps files whose mime is in the list and drops the rest', () => {
const doc = makeNode({ mime: 'application/vnd.oasis.opendocument.text' })
Expand Down
9 changes: 8 additions & 1 deletion src/services/officeFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
*/

import type { Node } from '@nextcloud/files'
import { getClient, getDavNameSpaces, getDavProperties, getRootPath, resultToNode } from '@nextcloud/files/dav'
import { getClient, getDavNameSpaces, getDavProperties, getRootPath, registerDavProperty, resultToNode } from '@nextcloud/files/dav'

// `oc:share-types` is not in the default DAV property set, so the search would
// not return sharing state without this. Registering it (as the Files app does
// in files_sharing) adds it to the <d:prop> list getDavProperties() builds, so
// the overview can flag which files are shared. Runs once, at module load,
// before the first search.
registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' })

// Upper bound on files rendered per category, after client-side category and
// ownership filtering.
Expand Down
6 changes: 6 additions & 0 deletions src/test-utils/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface MakeNodeOptions {
favorite?: boolean
mtime?: Date
basename?: string
/** Outgoing share-type numbers; stored in the nested shape DAV returns. */
shareTypes?: number[]
}

// Owner/mount-type are the two axes that decide whether a file counts as
Expand All @@ -27,6 +29,7 @@ export function makeNode({
favorite = false,
mtime = new Date('2024-01-01T00:00:00Z'),
basename = `file-${id}.odt`,
shareTypes,
}: MakeNodeOptions = {}): Node {
const ownerSegment = owner ?? 'nobody'
return new File({
Expand All @@ -39,6 +42,9 @@ export function makeNode({
attributes: {
...(mountType !== undefined ? { 'nc:mount-type': mountType } : {}),
...(favorite ? { favorite: 1 } : {}),
// The DAV `oc:share-types` property nests the numbers under a
// `share-type` key — reproduce that so consumers exercise the real shape.
...(shareTypes ? { 'share-types': { 'share-type': shareTypes } } : {}),
},
})
}
Expand Down
50 changes: 50 additions & 0 deletions src/utils/fileSharing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { describe, expect, it } from 'vitest'
import { makeNode } from '../test-utils/fixtures.ts'
import { isIncomingShare, isShared, outgoingShareTypes } from './fileSharing.ts'

describe('outgoingShareTypes', () => {
it('returns [] for a file with no share types', () => {
expect(outgoingShareTypes(makeNode())).toEqual([])
})

it('flattens the nested DAV shape for a single share type', () => {
expect(outgoingShareTypes(makeNode({ shareTypes: [3] }))).toEqual([3])
})

it('flattens the nested DAV shape for several share types', () => {
expect(outgoingShareTypes(makeNode({ shareTypes: [0, 3] }))).toEqual([0, 3])
})
Comment on lines +15 to +21
})

describe('isIncomingShare', () => {
it('is true when the file is owned by someone other than the current user', () => {
expect(isIncomingShare(makeNode({ owner: 'bob' }), 'alice')).toBe(true)
})

it('is false when the current user owns the file', () => {
expect(isIncomingShare(makeNode({ owner: 'alice' }), 'alice')).toBe(false)
})

it('is false when the current user is unknown', () => {
expect(isIncomingShare(makeNode({ owner: 'bob' }), null)).toBe(false)
})
})

describe('isShared', () => {
it('is true for a file the current user has shared out', () => {
expect(isShared(makeNode({ owner: 'alice', shareTypes: [0] }), 'alice')).toBe(true)
})

it('is true for a file shared with the current user', () => {
expect(isShared(makeNode({ owner: 'bob' }), 'alice')).toBe(true)
})

it('is false for an unshared file the current user owns', () => {
expect(isShared(makeNode({ owner: 'alice' }), 'alice')).toBe(false)
})
})
44 changes: 44 additions & 0 deletions src/utils/fileSharing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Node } from '@nextcloud/files'

/**
* Share-type numbers the current user has attached to a node (outgoing shares),
* normalised to a flat array. The DAV `oc:share-types` property comes back
* nested — `{ 'share-type': 3 }` for one share, `{ 'share-type': [3, 1] }` for
* several — so flatten its values the way the Files app does. Absent/empty ⇒ [].
*
* @param file the node to inspect
*/
export function outgoingShareTypes(file: Node): number[] {
const raw = file.attributes?.['share-types'] as Record<string, unknown> | unknown[] | undefined
return Object.values(raw ?? {}).flat() as number[]
}

/**
* Whether a file is shared *with* the current user by someone else (incoming),
* rather than shared out by them. Owner differs from the current user ⇒ it was
* mounted into their tree by a share. Drives the indicator's wording.
*
* @param file the node to inspect
* @param currentUid uid of the logged-in user, or null when unknown
*/
export function isIncomingShare(file: Node, currentUid: string | null): boolean {
return Boolean(currentUid && file.owner && file.owner !== currentUid)
}

/**
* Whether a file should carry a "shared" indicator: either the current user has
* shared it with someone (outgoing share types present) or it is an incoming
* share owned by someone else. Mirrors the Files app's sharing-status definition
* so the indicator means the same thing here as everywhere else in Nextcloud.
*
* @param file the node to inspect
* @param currentUid uid of the logged-in user, or null when unknown
*/
export function isShared(file: Node, currentUid: string | null): boolean {
return outgoingShareTypes(file).length > 0 || isIncomingShare(file, currentUid)
}
22 changes: 18 additions & 4 deletions src/views/OfficeOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from '@mdi/js'
import FileCard from '../components/FileCard.vue'
import FilePreview from '../components/FilePreview.vue'
import ShareIndicator from '../components/ShareIndicator.vue'
import TemplateSection from '../components/TemplateSection.vue'
import { getAllOfficeFiles, invalidateOfficeFilesCache, MAX_DISPLAY_FILES } from '../services/officeFiles.ts'
import { getTemplates, createFromTemplate } from '../services/templates.ts'
Expand Down Expand Up @@ -313,6 +314,10 @@ fetchAll()
<FilePreview :file="file" :alt="file.basename" />
</template>

<template #overlay>
<ShareIndicator :file="file" :current-uid="currentUid" />
</template>

<template #icon>
<NcIconSvgWrapper :svg="activeCreator.iconSvgInline ?? ''" :size="20" />
</template>
Expand Down Expand Up @@ -341,10 +346,13 @@ fetchAll()
class="office-overview__list-thumb" />
</template>
<template #indicator>
<NcIconSvgWrapper v-if="file.attributes?.favorite === 1"
:path="mdiStar"
:size="16"
class="office-overview__favourite-icon" />
<span class="office-overview__indicators">
<NcIconSvgWrapper v-if="file.attributes?.favorite === 1"
:path="mdiStar"
:size="16"
class="office-overview__favourite-icon" />
<ShareIndicator :file="file" :current-uid="currentUid" :size="16" />
</span>
</template>
<template #subname>
<NcDateTime :timestamp="file.mtime" />
Expand Down Expand Up @@ -461,6 +469,12 @@ fetchAll()
flex-shrink: 0;
}

.office-overview__indicators {
display: inline-flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 1);
}

.office-overview__favourite-icon {
color: var(--color-warning);
}
Expand Down
Loading