Skip to content

Commit c10f63d

Browse files
authored
Merge pull request #239 from nextcloud-libraries/feature/vite-lib-build
feat(dialog): experimental sharing dialog for the unified sharing API
2 parents 820a100 + 47b9e8b commit c10f63d

38 files changed

Lines changed: 13262 additions & 3100 deletions

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,48 @@ There are three entry points provided:
2323
- The main entry point `@nextcloud/sharing` provides general utils for file sharing
2424
- The _public_ entry point `@nextcloud/sharing/public` provides utils for handling public file shares
2525
- The _ui_ entry point `@nextcloud/sharing/ui` provides API bindings to interact with the files sharing interface in the files app.
26+
- The _dialog_ entry point `@nextcloud/sharing/dialog` provides the sharing dialog for the unified sharing API (**experimental**, see below).
27+
28+
### Sharing dialog (experimental)
29+
30+
> [!WARNING]
31+
> This entry point is experimental. It requires the unified sharing API
32+
> (Nextcloud 35 or later) and its API may change in any minor release.
33+
> Some inline validation requires a not-yet-released `@nextcloud/vue` version
34+
> and degrades gracefully on older ones.
35+
36+
Open the dialog for a node:
37+
38+
```ts
39+
import { openSharingDialog, isSharingDialogAvailable } from '@nextcloud/sharing/dialog'
40+
41+
// Safe to call directly: shows an error toast if the API is unavailable
42+
await openSharingDialog(node)
43+
44+
// Or gate your entry point (e.g. a share button) on availability:
45+
if (isSharingDialogAvailable()) {
46+
// show the share action
47+
}
48+
```
49+
50+
For programmatic control, work with a `Share` instance directly. Instances are
51+
created by `createShare()` (a new draft) or `getShare()` (an existing share) —
52+
the `Share` class is exported as a type only and is never constructed manually.
53+
Every mutation round-trips to the backend and updates the reactive instance:
54+
55+
```ts
56+
import { createShare, getShare, searchRecipients } from '@nextcloud/sharing/dialog'
57+
import type { Share } from '@nextcloud/sharing/dialog'
58+
59+
const share = await createShare()
60+
await share.addNode(node)
61+
await share.selectPreset(presetClass)
62+
await share.setProperty(propertyClass, value)
63+
await share.showDialog(node) // open the dialog bound to this share
64+
65+
const existing: Share = await getShare(shareId)
66+
const recipients = await searchRecipients('alice')
67+
```
68+
69+
The entry point also exports the `SharingDialog` component for embedding and all
70+
of the sharing API's request/response types.

lib/assets.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
declare module '*?raw' {
7+
const content: string
8+
export default content
9+
}

lib/dialog/SharingDialog.vue

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
<template>
6+
<NcDialog
7+
class="sharing-dialog"
8+
name=""
9+
size="normal"
10+
@closing="emit('close')">
11+
<div class="sharing-dialog__header">
12+
<!-- Back button, shown when in settings -->
13+
<NcButton
14+
v-if="inSettings"
15+
class="sharing-dialog__settings-back-btn"
16+
variant="tertiary"
17+
:aria-label="t('Back to sharing options')"
18+
@click="inSettings = false">
19+
<template #icon>
20+
<NcIconSvgWrapper :svg="IconArrowLeft" directional />
21+
</template>
22+
</NcButton>
23+
24+
<!-- Dialog title and subtitle -->
25+
<span class="dialog__titles">
26+
<h2 class="sharing-dialog__title">
27+
{{ dialogTitle }}
28+
</h2>
29+
<h3 v-if="inSettings && nodeName" class="sharing-dialog__subtitle">
30+
{{ nodeName }}
31+
</h3>
32+
</span>
33+
</div>
34+
35+
<NcEmptyContent
36+
v-if="loading"
37+
class="sharing-dialog__loading"
38+
:name="t('Loading sharing options…')">
39+
<template #icon>
40+
<NcLoadingIcon :size="44" />
41+
</template>
42+
</NcEmptyContent>
43+
44+
<NcEmptyContent
45+
v-else-if="error"
46+
class="sharing-dialog__error"
47+
:name="t('Failed to create share')"
48+
:description="error" />
49+
50+
<!-- Confirmation shown after the share is submitted -->
51+
<ShareConfirmation
52+
v-else-if="submitted"
53+
:link="submitResult?.link ?? null"
54+
:isPublic="submitResult?.isPublic ?? false"
55+
@close="emit('close')" />
56+
57+
<template v-else-if="share">
58+
<SharePanel
59+
v-model:shareDialogTab="shareDialogTab"
60+
:inSettings="inSettings"
61+
:share="share"
62+
:folderName="folderName"
63+
@settingsWarning="settingsHasWarning = $event"
64+
@settingsAvailable="settingsAvailable = $event"
65+
@submitted="onSubmitted" />
66+
</template>
67+
68+
<!-- Settings toggle -->
69+
<NcButton
70+
v-if="!inSettings && !submitted && share && settingsAvailable"
71+
:aria-label="t('Additional sharing settings')"
72+
class="sharing-dialog__settings-toggle"
73+
:class="{ 'sharing-dialog__settings-toggle--warning': settingsHasWarning }"
74+
variant="tertiary"
75+
@click="inSettings = true">
76+
<template #icon>
77+
<NcIconSvgWrapper :svg="IconCogOutline" />
78+
</template>
79+
</NcButton>
80+
</NcDialog>
81+
</template>
82+
83+
<script setup lang="ts">
84+
import type { INode } from '@nextcloud/files'
85+
import type { Share } from './api/share.ts'
86+
import type { SharingCapabilities } from './types/api.ts'
87+
88+
import IconArrowLeft from '@mdi/svg/svg/arrow-left.svg?raw'
89+
import IconCogOutline from '@mdi/svg/svg/cog-outline.svg?raw'
90+
import { getCapabilities } from '@nextcloud/capabilities'
91+
import { FileType } from '@nextcloud/files'
92+
import { computed, onMounted, ref, shallowRef } from 'vue'
93+
import NcButton from '@nextcloud/vue/components/NcButton'
94+
import NcDialog from '@nextcloud/vue/components/NcDialog'
95+
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
96+
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
97+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
98+
import ShareConfirmation from './components/ShareConfirmation.vue'
99+
import SharePanel from './components/SharePanel.vue'
100+
import { createShare } from './api/share.ts'
101+
import { SOURCE_TYPE_NODE } from './constants.ts'
102+
import { ShareDialogTab } from './types/ui.ts'
103+
import { t } from './utils/l10n.ts'
104+
import { logger } from './utils/logger.ts'
105+
106+
const props = defineProps<{
107+
/** An existing share to edit. Provide this or {@link node}. */
108+
share?: Share
109+
/** The file or folder to share. A draft share is created for it. */
110+
node?: INode
111+
}>()
112+
113+
const emit = defineEmits<{
114+
(e: 'close'): void
115+
}>()
116+
117+
const { sharing: sharingCapabilities } = getCapabilities() as SharingCapabilities
118+
119+
const inSettings = ref(false)
120+
// Loading only while a draft is being created for a node; a passed-in share is ready.
121+
const loading = ref(!props.share)
122+
const error = ref<string | null>(null)
123+
const share = shallowRef<Share | null>(props.share ?? null)
124+
125+
// Display name for the title/subtitle, from the node if one was provided.
126+
const nodeName = computed(() => props.node?.displayname ?? null)
127+
128+
// Folder name when the shared node is a folder, used to hint that public
129+
// uploads land in it. Null for files or when opened without a node.
130+
const folderName = computed(() => props.node?.type === FileType.Folder ? props.node.displayname : null)
131+
132+
const dialogTitle = computed(() => {
133+
if (inSettings.value) {
134+
return t('Sharing settings')
135+
}
136+
return nodeName.value
137+
? t('Share "{name}"', { name: nodeName.value })
138+
: t('Share')
139+
})
140+
141+
const shareDialogTab = ref<ShareDialogTab>(ShareDialogTab.InvitedPeople)
142+
const settingsHasWarning = ref(false)
143+
const settingsAvailable = ref(false)
144+
145+
// Once the share is submitted we swap the form for the confirmation view.
146+
const submitted = ref(false)
147+
const submitResult = ref<{ link: string | null, isPublic: boolean } | null>(null)
148+
149+
/**
150+
* Switch to the confirmation view with the submitted share's link.
151+
*
152+
* @param payload The resolved link and whether it is a public link
153+
* @param payload.link
154+
* @param payload.isPublic
155+
*/
156+
function onSubmitted(payload: { link: string | null, isPublic: boolean }) {
157+
submitResult.value = payload
158+
submitted.value = true
159+
}
160+
161+
onMounted(async () => {
162+
// A ready share was passed in; nothing to create.
163+
if (share.value) {
164+
return
165+
}
166+
167+
try {
168+
if (!props.node) {
169+
throw new Error('Either a share or a node must be provided')
170+
}
171+
// Validate source type is registered
172+
if (!sharingCapabilities.source_types.some((t) => t.class === SOURCE_TYPE_NODE)) {
173+
throw new Error('File source type not available')
174+
}
175+
176+
// Create a draft share and attach the file as source
177+
const draft = await createShare()
178+
await draft.addNode(props.node)
179+
share.value = draft
180+
} catch (e) {
181+
const message = e instanceof Error ? e.message : 'Unknown error'
182+
error.value = message
183+
logger.error('Failed to initialize share', { error: e })
184+
} finally {
185+
loading.value = false
186+
}
187+
})
188+
189+
</script>
190+
191+
<style scoped lang="scss">
192+
.sharing-dialog {
193+
// Hide the default dialog title, we use our own in the content instead
194+
:deep(.dialog__name) {
195+
display: none;
196+
}
197+
198+
// Consistent vertical rhythm between the header and the form.
199+
:deep(.dialog__content) {
200+
display: flex;
201+
flex-direction: column;
202+
gap: calc(var(--default-grid-baseline) * 3);
203+
}
204+
205+
// Scroll the form (everything but the fixed header/close) once it grows
206+
// tall, so the scrollbar never overlaps the header. A max-height rather than
207+
// flex is used so the dialog still sizes to its content when it is short.
208+
:deep(.share-panel) {
209+
max-height: 50vh;
210+
overflow-y: auto;
211+
// Match the dialog's inline padding at the bottom (its content has none),
212+
// so the form does not sit flush against the edge.
213+
padding-block-end: calc(var(--default-grid-baseline) * 3);
214+
}
215+
216+
&__loading,
217+
&__error {
218+
display: flex;
219+
justify-content: center;
220+
align-items: center;
221+
padding: calc(var(--default-grid-baseline) * 12);
222+
}
223+
224+
.sharing-dialog__settings-toggle {
225+
z-index: 1;
226+
position: absolute !important;
227+
top: var(--default-grid-baseline);
228+
inset-inline-end: var(--default-grid-baseline);
229+
margin-inline-end: calc(var(--button-size) + var(--default-grid-baseline));
230+
231+
&--warning {
232+
&::after {
233+
content: '';
234+
position: absolute;
235+
top: 2px;
236+
inset-inline-end: 2px;
237+
width: 10px;
238+
height: 10px;
239+
border-radius: 50%;
240+
border: 2px solid var(--color-main-background);
241+
background-color: var(--color-warning);
242+
pointer-events: none;
243+
}
244+
}
245+
}
246+
.sharing-dialog__header {
247+
display: flex;
248+
align-items: center;
249+
gap: calc(var(--default-grid-baseline) * 2);
250+
height: calc(var(--default-clickable-area) * 2);
251+
padding-inline-end: calc(var(--default-clickable-area) * 2);
252+
}
253+
254+
.dialog__titles {
255+
display: flex;
256+
flex-direction: column;
257+
overflow: hidden;
258+
text-overflow: ellipsis;
259+
260+
h2.sharing-dialog__title,
261+
h3.sharing-dialog__subtitle {
262+
margin-top: 2px;
263+
margin-bottom: 2px;
264+
line-height: 1.1em;
265+
font-size: 21px;
266+
}
267+
268+
h2.sharing-dialog__title {
269+
word-break: break-all;
270+
}
271+
272+
h3.sharing-dialog__subtitle {
273+
color: var(--color-text-maxcontrast);
274+
font-size: 1em;
275+
font-weight: normal;
276+
overflow: hidden;
277+
text-overflow: ellipsis;
278+
white-space: nowrap;
279+
}
280+
}
281+
}
282+
</style>

0 commit comments

Comments
 (0)