-
Notifications
You must be signed in to change notification settings - Fork 4
feat(dialog): experimental sharing dialog for the unified sharing API #239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
e2314a5
build: set up the library build and test tooling
skjnldsv b424ea6
feat(dialog): experimental sharing dialog for the unified sharing API
skjnldsv cef9439
test(dialog): unit-test the sharing dialog
skjnldsv aead4c4
feat(dialog): confirmation screen after submitting a share
skjnldsv faa8b82
refactor(dialog): split SharePanel into composables and utils
skjnldsv aaff2c6
fix(dialog): copy the link reliably in non-secure contexts
skjnldsv afe74ed
fix(dialog): require a recipient to submit and clear the error on tab…
skjnldsv f3fad42
fix(dialog): confirmation QR optional, dark-mode safe, bigger success…
skjnldsv 336f705
fix(dialog): inline copy button and native helperText hints
skjnldsv 577ce1a
build: depend on published @nextcloud/vue and refresh tooling
skjnldsv 47b9e8b
build: skip type checking during doc generation
skjnldsv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /*! | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: GPL-3.0-or-later | ||
| */ | ||
|
|
||
| declare module '*?raw' { | ||
| const content: string | ||
| export default content | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,282 @@ | ||
| <!-- | ||
| SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| SPDX-License-Identifier: GPL-3.0-or-later | ||
| --> | ||
| <template> | ||
| <NcDialog | ||
| class="sharing-dialog" | ||
| name="" | ||
| size="normal" | ||
| @closing="emit('close')"> | ||
| <div class="sharing-dialog__header"> | ||
| <!-- Back button, shown when in settings --> | ||
| <NcButton | ||
| v-if="inSettings" | ||
| class="sharing-dialog__settings-back-btn" | ||
| variant="tertiary" | ||
| :aria-label="t('Back to sharing options')" | ||
| @click="inSettings = false"> | ||
| <template #icon> | ||
| <NcIconSvgWrapper :svg="IconArrowLeft" directional /> | ||
| </template> | ||
| </NcButton> | ||
|
|
||
| <!-- Dialog title and subtitle --> | ||
| <span class="dialog__titles"> | ||
| <h2 class="sharing-dialog__title"> | ||
| {{ dialogTitle }} | ||
| </h2> | ||
| <h3 v-if="inSettings && nodeName" class="sharing-dialog__subtitle"> | ||
| {{ nodeName }} | ||
| </h3> | ||
| </span> | ||
| </div> | ||
|
|
||
| <NcEmptyContent | ||
| v-if="loading" | ||
| class="sharing-dialog__loading" | ||
| :name="t('Loading sharing options…')"> | ||
| <template #icon> | ||
| <NcLoadingIcon :size="44" /> | ||
| </template> | ||
| </NcEmptyContent> | ||
|
|
||
| <NcEmptyContent | ||
| v-else-if="error" | ||
| class="sharing-dialog__error" | ||
| :name="t('Failed to create share')" | ||
| :description="error" /> | ||
|
|
||
| <!-- Confirmation shown after the share is submitted --> | ||
| <ShareConfirmation | ||
| v-else-if="submitted" | ||
| :link="submitResult?.link ?? null" | ||
| :isPublic="submitResult?.isPublic ?? false" | ||
| @close="emit('close')" /> | ||
|
|
||
| <template v-else-if="share"> | ||
| <SharePanel | ||
| v-model:shareDialogTab="shareDialogTab" | ||
| :inSettings="inSettings" | ||
| :share="share" | ||
| :folderName="folderName" | ||
| @settingsWarning="settingsHasWarning = $event" | ||
| @settingsAvailable="settingsAvailable = $event" | ||
| @submitted="onSubmitted" /> | ||
| </template> | ||
|
|
||
| <!-- Settings toggle --> | ||
| <NcButton | ||
| v-if="!inSettings && !submitted && share && settingsAvailable" | ||
| :aria-label="t('Additional sharing settings')" | ||
| class="sharing-dialog__settings-toggle" | ||
| :class="{ 'sharing-dialog__settings-toggle--warning': settingsHasWarning }" | ||
| variant="tertiary" | ||
| @click="inSettings = true"> | ||
| <template #icon> | ||
| <NcIconSvgWrapper :svg="IconCogOutline" /> | ||
| </template> | ||
| </NcButton> | ||
| </NcDialog> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import type { INode } from '@nextcloud/files' | ||
| import type { Share } from './api/share.ts' | ||
| import type { SharingCapabilities } from './types/api.ts' | ||
|
|
||
| import IconArrowLeft from '@mdi/svg/svg/arrow-left.svg?raw' | ||
| import IconCogOutline from '@mdi/svg/svg/cog-outline.svg?raw' | ||
| import { getCapabilities } from '@nextcloud/capabilities' | ||
| import { FileType } from '@nextcloud/files' | ||
| import { computed, onMounted, ref, shallowRef } from 'vue' | ||
| import NcButton from '@nextcloud/vue/components/NcButton' | ||
| import NcDialog from '@nextcloud/vue/components/NcDialog' | ||
| import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' | ||
| import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' | ||
| import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' | ||
| import ShareConfirmation from './components/ShareConfirmation.vue' | ||
| import SharePanel from './components/SharePanel.vue' | ||
| import { createShare } from './api/share.ts' | ||
| import { SOURCE_TYPE_NODE } from './constants.ts' | ||
| import { ShareDialogTab } from './types/ui.ts' | ||
| import { t } from './utils/l10n.ts' | ||
| import { logger } from './utils/logger.ts' | ||
|
|
||
| const props = defineProps<{ | ||
| /** An existing share to edit. Provide this or {@link node}. */ | ||
| share?: Share | ||
| /** The file or folder to share. A draft share is created for it. */ | ||
| node?: INode | ||
| }>() | ||
|
|
||
| const emit = defineEmits<{ | ||
| (e: 'close'): void | ||
| }>() | ||
|
|
||
| const { sharing: sharingCapabilities } = getCapabilities() as SharingCapabilities | ||
|
|
||
| const inSettings = ref(false) | ||
| // Loading only while a draft is being created for a node; a passed-in share is ready. | ||
| const loading = ref(!props.share) | ||
| const error = ref<string | null>(null) | ||
| const share = shallowRef<Share | null>(props.share ?? null) | ||
|
|
||
| // Display name for the title/subtitle, from the node if one was provided. | ||
| const nodeName = computed(() => props.node?.displayname ?? null) | ||
|
|
||
| // Folder name when the shared node is a folder, used to hint that public | ||
| // uploads land in it. Null for files or when opened without a node. | ||
| const folderName = computed(() => props.node?.type === FileType.Folder ? props.node.displayname : null) | ||
|
|
||
| const dialogTitle = computed(() => { | ||
| if (inSettings.value) { | ||
| return t('Sharing settings') | ||
| } | ||
| return nodeName.value | ||
| ? t('Share "{name}"', { name: nodeName.value }) | ||
| : t('Share') | ||
| }) | ||
|
|
||
| const shareDialogTab = ref<ShareDialogTab>(ShareDialogTab.InvitedPeople) | ||
| const settingsHasWarning = ref(false) | ||
| const settingsAvailable = ref(false) | ||
|
|
||
| // Once the share is submitted we swap the form for the confirmation view. | ||
| const submitted = ref(false) | ||
| const submitResult = ref<{ link: string | null, isPublic: boolean } | null>(null) | ||
|
|
||
| /** | ||
| * Switch to the confirmation view with the submitted share's link. | ||
| * | ||
| * @param payload The resolved link and whether it is a public link | ||
| * @param payload.link | ||
|
Check warning on line 153 in lib/dialog/SharingDialog.vue
|
||
| * @param payload.isPublic | ||
|
Check warning on line 154 in lib/dialog/SharingDialog.vue
|
||
| */ | ||
| function onSubmitted(payload: { link: string | null, isPublic: boolean }) { | ||
| submitResult.value = payload | ||
| submitted.value = true | ||
| } | ||
|
|
||
| onMounted(async () => { | ||
| // A ready share was passed in; nothing to create. | ||
| if (share.value) { | ||
| return | ||
| } | ||
|
|
||
| try { | ||
| if (!props.node) { | ||
| throw new Error('Either a share or a node must be provided') | ||
| } | ||
| // Validate source type is registered | ||
| if (!sharingCapabilities.source_types.some((t) => t.class === SOURCE_TYPE_NODE)) { | ||
| throw new Error('File source type not available') | ||
| } | ||
|
|
||
| // Create a draft share and attach the file as source | ||
| const draft = await createShare() | ||
| await draft.addNode(props.node) | ||
| share.value = draft | ||
| } catch (e) { | ||
| const message = e instanceof Error ? e.message : 'Unknown error' | ||
| error.value = message | ||
| logger.error('Failed to initialize share', { error: e }) | ||
| } finally { | ||
| loading.value = false | ||
| } | ||
| }) | ||
|
|
||
| </script> | ||
|
|
||
| <style scoped lang="scss"> | ||
| .sharing-dialog { | ||
| // Hide the default dialog title, we use our own in the content instead | ||
| :deep(.dialog__name) { | ||
| display: none; | ||
| } | ||
|
|
||
| // Consistent vertical rhythm between the header and the form. | ||
| :deep(.dialog__content) { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: calc(var(--default-grid-baseline) * 3); | ||
| } | ||
|
|
||
| // Scroll the form (everything but the fixed header/close) once it grows | ||
| // tall, so the scrollbar never overlaps the header. A max-height rather than | ||
| // flex is used so the dialog still sizes to its content when it is short. | ||
| :deep(.share-panel) { | ||
| max-height: 50vh; | ||
| overflow-y: auto; | ||
| // Match the dialog's inline padding at the bottom (its content has none), | ||
| // so the form does not sit flush against the edge. | ||
| padding-block-end: calc(var(--default-grid-baseline) * 3); | ||
| } | ||
|
|
||
| &__loading, | ||
| &__error { | ||
| display: flex; | ||
| justify-content: center; | ||
| align-items: center; | ||
| padding: calc(var(--default-grid-baseline) * 12); | ||
| } | ||
|
|
||
| .sharing-dialog__settings-toggle { | ||
| z-index: 1; | ||
| position: absolute !important; | ||
| top: var(--default-grid-baseline); | ||
| inset-inline-end: var(--default-grid-baseline); | ||
| margin-inline-end: calc(var(--button-size) + var(--default-grid-baseline)); | ||
|
|
||
| &--warning { | ||
| &::after { | ||
| content: ''; | ||
| position: absolute; | ||
| top: 2px; | ||
| inset-inline-end: 2px; | ||
| width: 10px; | ||
| height: 10px; | ||
| border-radius: 50%; | ||
| border: 2px solid var(--color-main-background); | ||
| background-color: var(--color-warning); | ||
| pointer-events: none; | ||
| } | ||
| } | ||
| } | ||
| .sharing-dialog__header { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: calc(var(--default-grid-baseline) * 2); | ||
| height: calc(var(--default-clickable-area) * 2); | ||
| padding-inline-end: calc(var(--default-clickable-area) * 2); | ||
| } | ||
|
|
||
| .dialog__titles { | ||
| display: flex; | ||
| flex-direction: column; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
|
|
||
| h2.sharing-dialog__title, | ||
| h3.sharing-dialog__subtitle { | ||
| margin-top: 2px; | ||
| margin-bottom: 2px; | ||
| line-height: 1.1em; | ||
| font-size: 21px; | ||
| } | ||
|
|
||
| h2.sharing-dialog__title { | ||
| word-break: break-all; | ||
| } | ||
|
|
||
| h3.sharing-dialog__subtitle { | ||
| color: var(--color-text-maxcontrast); | ||
| font-size: 1em; | ||
| font-weight: normal; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| white-space: nowrap; | ||
| } | ||
| } | ||
| } | ||
| </style> | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.